From 2758afa1268abc0fbef158b99e8af1887f521134 Mon Sep 17 00:00:00 2001 From: jack Date: Fri, 22 Aug 2025 19:01:29 +0200 Subject: [PATCH] ux(chat): scroll to bottom on geohash open; add infinite scroll up with stable anchor; track window size per chat --- bitchat/Views/ContentView.swift | 70 +++++++++++++++++++++++++++------ 1 file changed, 58 insertions(+), 12 deletions(-) diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index ae94cce4..0aeabf49 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -80,6 +80,9 @@ struct ContentView: View { @State private var autocompleteDebounceTimer: Timer? @State private var showLocationChannelsSheet = false @State private var expandedMessageIDs: Set = [] + // Window sizes for rendering (infinite scroll up) + @State private var windowCountPublic: Int = 300 + @State private var windowCountPrivate: [String: Int] = [:] // MARK: - Computed Properties @@ -295,8 +298,12 @@ struct ContentView: View { } }() - // Implement windowing - show last 300 messages for performance - let windowedMessages = messages.suffix(300) + // Implement windowing with adjustable window count per chat + let currentWindowCount: Int = { + if let peer = privatePeer { return windowCountPrivate[peer] ?? 300 } + return windowCountPublic + }() + let windowedMessages = messages.suffix(currentWindowCount) // Build stable UI IDs with a context key to avoid ID collisions when switching channels #if os(iOS) @@ -413,6 +420,40 @@ struct ContentView: View { if message.id == windowedMessages.last?.id { isAtBottom.wrappedValue = true } + // Infinite scroll up: when top row appears, increase window and preserve anchor + if message.id == windowedMessages.first?.id, messages.count > windowedMessages.count { + let step = 200 + #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 preserveID = "\(contextKey)|\(message.id)" + if let peer = privatePeer { + let current = windowCountPrivate[peer] ?? 300 + let newCount = min(messages.count, current + step) + if newCount != current { + windowCountPrivate[peer] = newCount + DispatchQueue.main.async { + proxy.scrollTo(preserveID, anchor: .top) + } + } + } else { + let current = windowCountPublic + let newCount = min(messages.count, current + step) + if newCount != current { + windowCountPublic = newCount + DispatchQueue.main.async { + proxy.scrollTo(preserveID, anchor: .top) + } + } + } + } } .onDisappear { if message.id == windowedMessages.last?.id { @@ -549,7 +590,8 @@ struct ContentView: View { #else let contextKey: String = "mesh" #endif - let target = viewModel.messages.suffix(300).last.map { "\(contextKey)|\($0.id)" } + let count = windowCountPublic + let target = viewModel.messages.suffix(count).last.map { "\(contextKey)|\($0.id)" } DispatchQueue.main.async { if let target = target { proxy.scrollTo(target, anchor: .bottom) } } @@ -568,7 +610,8 @@ struct ContentView: View { #else let contextKey: String = "mesh" #endif - let target = viewModel.messages.suffix(300).last.map { "\(contextKey)|\($0.id)" } + let count = windowCountPublic + let target = viewModel.messages.suffix(count).last.map { "\(contextKey)|\($0.id)" } DispatchQueue.main.async { if let target = target { proxy.scrollTo(target, anchor: .bottom) } } @@ -594,7 +637,8 @@ struct ContentView: View { if now.timeIntervalSince(lastScrollTime) > 0.5 { lastScrollTime = now let contextKey = "dm:\(peerID)" - let target = messages.suffix(300).last.map { "\(contextKey)|\($0.id)" } + let count = windowCountPrivate[peerID] ?? 300 + let target = messages.suffix(count).last.map { "\(contextKey)|\($0.id)" } DispatchQueue.main.async { if let target = target { proxy.scrollTo(target, anchor: .bottom) } } @@ -603,7 +647,8 @@ struct ContentView: View { scrollThrottleTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { _ in lastScrollTime = Date() let contextKey = "dm:\(peerID)" - let target = messages.suffix(300).last.map { "\(contextKey)|\($0.id)" } + let count = windowCountPrivate[peerID] ?? 300 + let target = messages.suffix(count).last.map { "\(contextKey)|\($0.id)" } DispatchQueue.main.async { if let target = target { proxy.scrollTo(target, anchor: .bottom) } } @@ -613,19 +658,20 @@ struct ContentView: View { } #if os(iOS) .onChange(of: locationManager.selectedChannel) { newChannel in - // When switching to a new geohash channel, scroll to the top of that chat + // When switching to a new geohash channel, scroll to the bottom guard privatePeer == nil else { return } switch newChannel { case .mesh: break case .location(let ch): - // Compute the top item (first of the current window) + // Reset window size + windowCountPublic = 300 let contextKey = "geo:\(ch.geohash)" - let top = viewModel.messages.suffix(300).first?.id - let target = top.map { "\(contextKey)|\($0)" } - isAtBottom.wrappedValue = false + let last = viewModel.messages.suffix(windowCountPublic).last?.id + let target = last.map { "\(contextKey)|\($0)" } + isAtBottom.wrappedValue = true DispatchQueue.main.async { - if let target = target { proxy.scrollTo(target, anchor: .top) } + if let target = target { proxy.scrollTo(target, anchor: .bottom) } } } }