perf(chat): reduce rendering cost and improve stability for large chats\n\n- Window last 300 messages (up from 100) while keeping timeline cap at 1337+\n- Remove .textSelection from message rows to avoid expensive layout\n- Limit link previews per message to 2\n- Keep deferred autoscroll and channel-aware IDs for smooth scrolling

This commit is contained in:
jack
2025-08-22 18:56:26 +02:00
parent dadc896ed8
commit 2229a28bb4
+14 -17
View File
@@ -295,8 +295,8 @@ struct ContentView: View {
} }
}() }()
// Implement windowing - show last 100 messages for performance // Implement windowing - show last 300 messages for performance
let windowedMessages = messages.suffix(100) let windowedMessages = messages.suffix(300)
// Build stable UI IDs with a context key to avoid ID collisions when switching channels // Build stable UI IDs with a context key to avoid ID collisions when switching channels
#if os(iOS) #if os(iOS)
@@ -319,7 +319,6 @@ struct ContentView: View {
if message.sender == "system" { if message.sender == "system" {
// System messages // System messages
Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme)) Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme))
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
} else { } else {
@@ -330,7 +329,6 @@ struct ContentView: View {
let isLong = message.content.count > 2000 || message.content.hasVeryLongToken(threshold: 512) let isLong = message.content.count > 2000 || message.content.hasVeryLongToken(threshold: 512)
let isExpanded = expandedMessageIDs.contains(message.id) let isExpanded = expandedMessageIDs.contains(message.id)
Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme)) Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme))
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
.lineLimit(isLong && !isExpanded ? 30 : nil) .lineLimit(isLong && !isExpanded ? 30 : nil)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
@@ -358,7 +356,7 @@ struct ContentView: View {
// Check for plain URLs // Check for plain URLs
let urls = message.content.extractURLs() let urls = message.content.extractURLs()
if !urls.isEmpty { if !urls.isEmpty {
ForEach(urls.prefix(3).indices, id: \.self) { index in ForEach(urls.prefix(2).indices, id: \.self) { index in
let urlInfo = urls[index] let urlInfo = urls[index]
LazyLinkPreviewView(url: urlInfo.url, title: nil) LazyLinkPreviewView(url: urlInfo.url, title: nil)
.padding(.top, 3) .padding(.top, 3)
@@ -456,7 +454,7 @@ struct ContentView: View {
// Force scroll to bottom when opening a chat view // Force scroll to bottom when opening a chat view
let targetID: String? = { let targetID: String? = {
if let peer = privatePeer, if let peer = privatePeer,
let last = viewModel.getPrivateChatMessages(for: peer).suffix(100).last?.id { let last = viewModel.getPrivateChatMessages(for: peer).suffix(300).last?.id {
return "dm:\(peer)|\(last)" return "dm:\(peer)|\(last)"
} }
#if os(iOS) #if os(iOS)
@@ -469,7 +467,7 @@ struct ContentView: View {
#else #else
let contextKey: String = "mesh" let contextKey: String = "mesh"
#endif #endif
if let last = viewModel.messages.suffix(100).last?.id { return "\(contextKey)|\(last)" } if let last = viewModel.messages.suffix(300).last?.id { return "\(contextKey)|\(last)" }
return nil return nil
}() }()
isAtBottom.wrappedValue = true isAtBottom.wrappedValue = true
@@ -480,7 +478,7 @@ struct ContentView: View {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
let targetID2: String? = { let targetID2: String? = {
if let peer = privatePeer, if let peer = privatePeer,
let last = viewModel.getPrivateChatMessages(for: peer).suffix(100).last?.id { let last = viewModel.getPrivateChatMessages(for: peer).suffix(300).last?.id {
return "dm:\(peer)|\(last)" return "dm:\(peer)|\(last)"
} }
#if os(iOS) #if os(iOS)
@@ -493,7 +491,7 @@ struct ContentView: View {
#else #else
let contextKey: String = "mesh" let contextKey: String = "mesh"
#endif #endif
if let last = viewModel.messages.suffix(100).last?.id { return "\(contextKey)|\(last)" } if let last = viewModel.messages.suffix(300).last?.id { return "\(contextKey)|\(last)" }
return nil return nil
}() }()
if let t2 = targetID2 { proxy.scrollTo(t2, anchor: .bottom) } if let t2 = targetID2 { proxy.scrollTo(t2, anchor: .bottom) }
@@ -503,7 +501,7 @@ struct ContentView: View {
// When switching to a different private chat, jump to bottom // When switching to a different private chat, jump to bottom
let targetID: String? = { let targetID: String? = {
if let peer = privatePeer, if let peer = privatePeer,
let last = viewModel.getPrivateChatMessages(for: peer).suffix(100).last?.id { let last = viewModel.getPrivateChatMessages(for: peer).suffix(300).last?.id {
return "dm:\(peer)|\(last)" return "dm:\(peer)|\(last)"
} }
#if os(iOS) #if os(iOS)
@@ -516,7 +514,7 @@ struct ContentView: View {
#else #else
let contextKey: String = "mesh" let contextKey: String = "mesh"
#endif #endif
if let last = viewModel.messages.suffix(100).last?.id { return "\(contextKey)|\(last)" } if let last = viewModel.messages.suffix(300).last?.id { return "\(contextKey)|\(last)" }
return nil return nil
}() }()
isAtBottom.wrappedValue = true isAtBottom.wrappedValue = true
@@ -551,7 +549,7 @@ struct ContentView: View {
#else #else
let contextKey: String = "mesh" let contextKey: String = "mesh"
#endif #endif
let target = viewModel.messages.suffix(100).last.map { "\(contextKey)|\($0.id)" } let target = viewModel.messages.suffix(300).last.map { "\(contextKey)|\($0.id)" }
DispatchQueue.main.async { DispatchQueue.main.async {
if let target = target { proxy.scrollTo(target, anchor: .bottom) } if let target = target { proxy.scrollTo(target, anchor: .bottom) }
} }
@@ -570,7 +568,7 @@ struct ContentView: View {
#else #else
let contextKey: String = "mesh" let contextKey: String = "mesh"
#endif #endif
let target = viewModel.messages.suffix(100).last.map { "\(contextKey)|\($0.id)" } let target = viewModel.messages.suffix(300).last.map { "\(contextKey)|\($0.id)" }
DispatchQueue.main.async { DispatchQueue.main.async {
if let target = target { proxy.scrollTo(target, anchor: .bottom) } if let target = target { proxy.scrollTo(target, anchor: .bottom) }
} }
@@ -596,7 +594,7 @@ struct ContentView: View {
if now.timeIntervalSince(lastScrollTime) > 0.5 { if now.timeIntervalSince(lastScrollTime) > 0.5 {
lastScrollTime = now lastScrollTime = now
let contextKey = "dm:\(peerID)" let contextKey = "dm:\(peerID)"
let target = messages.suffix(100).last.map { "\(contextKey)|\($0.id)" } let target = messages.suffix(300).last.map { "\(contextKey)|\($0.id)" }
DispatchQueue.main.async { DispatchQueue.main.async {
if let target = target { proxy.scrollTo(target, anchor: .bottom) } if let target = target { proxy.scrollTo(target, anchor: .bottom) }
} }
@@ -605,7 +603,7 @@ struct ContentView: View {
scrollThrottleTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { _ in scrollThrottleTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { _ in
lastScrollTime = Date() lastScrollTime = Date()
let contextKey = "dm:\(peerID)" let contextKey = "dm:\(peerID)"
let target = messages.suffix(100).last.map { "\(contextKey)|\($0.id)" } let target = messages.suffix(300).last.map { "\(contextKey)|\($0.id)" }
DispatchQueue.main.async { DispatchQueue.main.async {
if let target = target { proxy.scrollTo(target, anchor: .bottom) } if let target = target { proxy.scrollTo(target, anchor: .bottom) }
} }
@@ -623,7 +621,7 @@ struct ContentView: View {
case .location(let ch): case .location(let ch):
// Compute the top item (first of the current window) // Compute the top item (first of the current window)
let contextKey = "geo:\(ch.geohash)" let contextKey = "geo:\(ch.geohash)"
let top = viewModel.messages.suffix(100).first?.id let top = viewModel.messages.suffix(300).first?.id
let target = top.map { "\(contextKey)|\($0)" } let target = top.map { "\(contextKey)|\($0)" }
isAtBottom.wrappedValue = false isAtBottom.wrappedValue = false
DispatchQueue.main.async { DispatchQueue.main.async {
@@ -1402,7 +1400,6 @@ struct MessageContentView: View {
Text(message.content) Text(message.content)
.font(.system(size: 14, design: .monospaced)) .font(.system(size: 14, design: .monospaced))
.fontWeight(isMentioned ? .bold : .regular) .fontWeight(isMentioned ? .bold : .regular)
.textSelection(.enabled)
} }
// MARK: - Helper Methods // MARK: - Helper Methods