ux(chat): scroll to bottom on geohash open; add infinite scroll up with stable anchor; track window size per chat

This commit is contained in:
jack
2025-08-22 19:01:29 +02:00
parent 2229a28bb4
commit 2758afa126
+58 -12
View File
@@ -80,6 +80,9 @@ struct ContentView: View {
@State private var autocompleteDebounceTimer: Timer?
@State private var showLocationChannelsSheet = false
@State private var expandedMessageIDs: Set<String> = []
// 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) }
}
}
}