chat: fix blanking and gaps when switching geohashes; channel-aware row IDs + deferred autoscroll\n\n- Add channel-aware UI IDs (mesh|id, geo:<gh>|id, dm:<peer>|id) to prevent SwiftUI reuse gaps\n- Defer scrollTo to next runloop for stability; auto-scroll on appear/switch\n- Collapse very long messages with Show more/less; skip heavy parsing for huge content\n- Simplify LazyLinkPreviewView (remove GeometryReader in list) (#480)

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
This commit is contained in:
jack
2025-08-22 16:38:40 +02:00
committed by GitHub
co-authored by jack
parent 60a42ffecb
commit 7f2cbd6621
3 changed files with 178 additions and 21 deletions
+19 -9
View File
@@ -2776,15 +2776,24 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Process content with hashtags and mentions
let content = message.content
let hashtagPattern = "#([a-zA-Z0-9_]+)"
// Allow optional '#abcd' suffix in mentions
let mentionPattern = "@([\\p{L}0-9_]+(?:#[a-fA-F0-9]{4})?)"
let hashtagRegex = try? NSRegularExpression(pattern: hashtagPattern, options: [])
let mentionRegex = try? NSRegularExpression(pattern: mentionPattern, options: [])
// Use NSDataDetector for URL detection
let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
// For extremely long content, render as plain text to avoid heavy regex/layout work
if content.count > 4000 || content.hasVeryLongToken(threshold: 1024) {
var plainStyle = AttributeContainer()
plainStyle.foregroundColor = baseColor
plainStyle.font = isSelf
? .system(size: 14, weight: .bold, design: .monospaced)
: .system(size: 14, design: .monospaced)
result.append(AttributedString(content).mergingAttributes(plainStyle))
} else {
let hashtagPattern = "#([a-zA-Z0-9_]+)"
// Allow optional '#abcd' suffix in mentions
let mentionPattern = "@([\\p{L}0-9_]+(?:#[a-fA-F0-9]{4})?)"
let hashtagRegex = try? NSRegularExpression(pattern: hashtagPattern, options: [])
let mentionRegex = try? NSRegularExpression(pattern: mentionPattern, options: [])
// Use NSDataDetector for URL detection
let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
let hashtagMatches = hashtagRegex?.matches(in: content, options: [], range: NSRange(location: 0, length: content.count)) ?? []
let mentionMatches = mentionRegex?.matches(in: content, options: [], range: NSRange(location: 0, length: content.count)) ?? []
@@ -2903,6 +2912,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
}
result.append(AttributedString(remainingText).mergingAttributes(remainingStyle))
}
}
// Add timestamp at the end (smaller, light grey)
let timestamp = AttributedString(" [\(formatTimestamp(message.timestamp))]")
+144 -12
View File
@@ -34,21 +34,17 @@ struct LazyLinkPreviewView: View {
@State private var isVisible = false
var body: some View {
GeometryReader { geometry in
Group {
if isVisible {
LinkPreviewView(url: url, title: title)
} else {
// Placeholder while not visible
RoundedRectangle(cornerRadius: 10)
.fill(Color.gray.opacity(0.1))
.frame(height: 80)
.onAppear {
// Only load when view appears on screen
isVisible = true
}
}
}
.frame(height: 80)
.onAppear { isVisible = true }
}
}
@@ -83,6 +79,7 @@ struct ContentView: View {
@State private var scrollThrottleTimer: Timer?
@State private var autocompleteDebounceTimer: Timer?
@State private var showLocationChannelsSheet = false
@State private var expandedMessageIDs: Set<String> = []
// MARK: - Computed Properties
@@ -291,8 +288,22 @@ struct ContentView: View {
// Implement windowing - show last 100 messages for performance
let windowedMessages = messages.suffix(100)
// Build stable UI IDs with a context key to avoid ID collisions when switching channels
#if os(iOS)
let contextKey: String = {
switch locationManager.selectedChannel {
case .mesh: return "mesh"
case .location(let ch): return "geo:\(ch.geohash)"
}
}()
#else
let contextKey: String = "mesh"
#endif
let items = windowedMessages.map { (uiID: "\(contextKey)|\($0.id)", message: $0) }
ForEach(windowedMessages, id: \.id) { message in
ForEach(items, id: \.uiID) { item in
let message = item.message
VStack(alignment: .leading, spacing: 0) {
// Check if current user is mentioned
@@ -307,9 +318,12 @@ struct ContentView: View {
VStack(alignment: .leading, spacing: 0) {
HStack(alignment: .top, spacing: 0) {
// Single text view for natural wrapping
let isLong = message.content.count > 2000 || message.content.hasVeryLongToken(threshold: 512)
let isExpanded = expandedMessageIDs.contains(message.id)
Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme))
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
.lineLimit(isLong && !isExpanded ? 30 : nil)
.frame(maxWidth: .infinity, alignment: .leading)
// Delivery status indicator for private messages
@@ -320,6 +334,18 @@ struct ContentView: View {
}
}
// Expand/Collapse for very long messages
if (message.content.count > 2000 || message.content.hasVeryLongToken(threshold: 512)) {
let isExpanded = expandedMessageIDs.contains(message.id)
Button(isExpanded ? "Show less" : "Show more") {
if isExpanded { expandedMessageIDs.remove(message.id) }
else { expandedMessageIDs.insert(message.id) }
}
.font(.system(size: 11, weight: .medium, design: .monospaced))
.foregroundColor(Color.blue)
.padding(.top, 4)
}
// Check for plain URLs
let urls = message.content.extractURLs()
if !urls.isEmpty {
@@ -334,7 +360,7 @@ struct ContentView: View {
}
}
}
.id(message.id)
.id(item.uiID)
.onAppear {
// Track if last item is visible to enable auto-scroll only when near bottom
if message.id == windowedMessages.last?.id {
@@ -377,6 +403,78 @@ struct ContentView: View {
// Triple-tap to clear current chat
viewModel.sendMessage("/clear")
}
.onAppear {
// Force scroll to bottom when opening a chat view
let targetID: String? = {
if let peer = privatePeer,
let last = viewModel.getPrivateChatMessages(for: peer).suffix(100).last?.id {
return "dm:\(peer)|\(last)"
}
#if os(iOS)
let contextKey: String = {
switch locationManager.selectedChannel {
case .mesh: return "mesh"
case .location(let ch): return "geo:\(ch.geohash)"
}
}()
#else
let contextKey: String = "mesh"
#endif
if let last = viewModel.messages.suffix(100).last?.id { return "\(contextKey)|\(last)" }
return nil
}()
isAtBottom.wrappedValue = true
DispatchQueue.main.async {
if let target = targetID { proxy.scrollTo(target, anchor: .bottom) }
}
// Second pass after a brief delay to handle late layout
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
let targetID2: String? = {
if let peer = privatePeer,
let last = viewModel.getPrivateChatMessages(for: peer).suffix(100).last?.id {
return "dm:\(peer)|\(last)"
}
#if os(iOS)
let contextKey: String = {
switch locationManager.selectedChannel {
case .mesh: return "mesh"
case .location(let ch): return "geo:\(ch.geohash)"
}
}()
#else
let contextKey: String = "mesh"
#endif
if let last = viewModel.messages.suffix(100).last?.id { return "\(contextKey)|\(last)" }
return nil
}()
if let t2 = targetID2 { proxy.scrollTo(t2, anchor: .bottom) }
}
}
.onChange(of: privatePeer) { _ in
// When switching to a different private chat, jump to bottom
let targetID: String? = {
if let peer = privatePeer,
let last = viewModel.getPrivateChatMessages(for: peer).suffix(100).last?.id {
return "dm:\(peer)|\(last)"
}
#if os(iOS)
let contextKey: String = {
switch locationManager.selectedChannel {
case .mesh: return "mesh"
case .location(let ch): return "geo:\(ch.geohash)"
}
}()
#else
let contextKey: String = "mesh"
#endif
if let last = viewModel.messages.suffix(100).last?.id { return "\(contextKey)|\(last)" }
return nil
}()
isAtBottom.wrappedValue = true
DispatchQueue.main.async {
if let target = targetID { proxy.scrollTo(target, anchor: .bottom) }
}
}
.onChange(of: viewModel.messages.count) { _ in
if privatePeer == nil && !viewModel.messages.isEmpty {
// Only autoscroll when user is at/near bottom
@@ -386,13 +484,39 @@ struct ContentView: View {
if now.timeIntervalSince(lastScrollTime) > 0.5 {
// Immediate scroll if enough time has passed
lastScrollTime = now
proxy.scrollTo(viewModel.messages.suffix(100).last?.id, anchor: .bottom)
#if os(iOS)
let contextKey: String = {
switch locationManager.selectedChannel {
case .mesh: return "mesh"
case .location(let ch): return "geo:\(ch.geohash)"
}
}()
#else
let contextKey: String = "mesh"
#endif
let target = viewModel.messages.suffix(100).last.map { "\(contextKey)|\($0.id)" }
DispatchQueue.main.async {
if let target = target { proxy.scrollTo(target, anchor: .bottom) }
}
} else {
// Schedule a delayed scroll
scrollThrottleTimer?.invalidate()
scrollThrottleTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { _ in
lastScrollTime = Date()
proxy.scrollTo(viewModel.messages.suffix(100).last?.id, anchor: .bottom)
#if os(iOS)
let contextKey: String = {
switch locationManager.selectedChannel {
case .mesh: return "mesh"
case .location(let ch): return "geo:\(ch.geohash)"
}
}()
#else
let contextKey: String = "mesh"
#endif
let target = viewModel.messages.suffix(100).last.map { "\(contextKey)|\($0.id)" }
DispatchQueue.main.async {
if let target = target { proxy.scrollTo(target, anchor: .bottom) }
}
}
}
}
@@ -407,12 +531,20 @@ struct ContentView: View {
let now = Date()
if now.timeIntervalSince(lastScrollTime) > 0.5 {
lastScrollTime = now
proxy.scrollTo(messages.suffix(100).last?.id, anchor: .bottom)
let contextKey = "dm:\(peerID)"
let target = messages.suffix(100).last.map { "\(contextKey)|\($0.id)" }
DispatchQueue.main.async {
if let target = target { proxy.scrollTo(target, anchor: .bottom) }
}
} else {
scrollThrottleTimer?.invalidate()
scrollThrottleTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { _ in
lastScrollTime = Date()
proxy.scrollTo(messages.suffix(100).last?.id, anchor: .bottom)
let contextKey = "dm:\(peerID)"
let target = messages.suffix(100).last.map { "\(contextKey)|\($0.id)" }
DispatchQueue.main.async {
if let target = target { proxy.scrollTo(target, anchor: .bottom) }
}
}
}
}
+15
View File
@@ -424,6 +424,21 @@ extension String {
return urls
}
// Detect if there is an extremely long token (no whitespace/newlines) that could break layout
func hasVeryLongToken(threshold: Int) -> Bool {
var current = 0
for ch in self {
if ch.isWhitespace || ch.isNewline {
if current >= threshold { return true }
current = 0
} else {
current += 1
if current >= threshold { return true }
}
}
return current >= threshold
}
}
#Preview {