mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 18:25:19 +00:00
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:
@@ -80,6 +80,9 @@ struct ContentView: View {
|
|||||||
@State private var autocompleteDebounceTimer: Timer?
|
@State private var autocompleteDebounceTimer: Timer?
|
||||||
@State private var showLocationChannelsSheet = false
|
@State private var showLocationChannelsSheet = false
|
||||||
@State private var expandedMessageIDs: Set<String> = []
|
@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
|
// MARK: - Computed Properties
|
||||||
|
|
||||||
@@ -295,8 +298,12 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Implement windowing - show last 300 messages for performance
|
// Implement windowing with adjustable window count per chat
|
||||||
let windowedMessages = messages.suffix(300)
|
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
|
// Build stable UI IDs with a context key to avoid ID collisions when switching channels
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
@@ -413,6 +420,40 @@ struct ContentView: View {
|
|||||||
if message.id == windowedMessages.last?.id {
|
if message.id == windowedMessages.last?.id {
|
||||||
isAtBottom.wrappedValue = true
|
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 {
|
.onDisappear {
|
||||||
if message.id == windowedMessages.last?.id {
|
if message.id == windowedMessages.last?.id {
|
||||||
@@ -549,7 +590,8 @@ struct ContentView: View {
|
|||||||
#else
|
#else
|
||||||
let contextKey: String = "mesh"
|
let contextKey: String = "mesh"
|
||||||
#endif
|
#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 {
|
DispatchQueue.main.async {
|
||||||
if let target = target { proxy.scrollTo(target, anchor: .bottom) }
|
if let target = target { proxy.scrollTo(target, anchor: .bottom) }
|
||||||
}
|
}
|
||||||
@@ -568,7 +610,8 @@ struct ContentView: View {
|
|||||||
#else
|
#else
|
||||||
let contextKey: String = "mesh"
|
let contextKey: String = "mesh"
|
||||||
#endif
|
#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 {
|
DispatchQueue.main.async {
|
||||||
if let target = target { proxy.scrollTo(target, anchor: .bottom) }
|
if let target = target { proxy.scrollTo(target, anchor: .bottom) }
|
||||||
}
|
}
|
||||||
@@ -594,7 +637,8 @@ 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(300).last.map { "\(contextKey)|\($0.id)" }
|
let count = windowCountPrivate[peerID] ?? 300
|
||||||
|
let target = messages.suffix(count).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) }
|
||||||
}
|
}
|
||||||
@@ -603,7 +647,8 @@ 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(300).last.map { "\(contextKey)|\($0.id)" }
|
let count = windowCountPrivate[peerID] ?? 300
|
||||||
|
let target = messages.suffix(count).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) }
|
||||||
}
|
}
|
||||||
@@ -613,19 +658,20 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
.onChange(of: locationManager.selectedChannel) { newChannel in
|
.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 }
|
guard privatePeer == nil else { return }
|
||||||
switch newChannel {
|
switch newChannel {
|
||||||
case .mesh:
|
case .mesh:
|
||||||
break
|
break
|
||||||
case .location(let ch):
|
case .location(let ch):
|
||||||
// Compute the top item (first of the current window)
|
// Reset window size
|
||||||
|
windowCountPublic = 300
|
||||||
let contextKey = "geo:\(ch.geohash)"
|
let contextKey = "geo:\(ch.geohash)"
|
||||||
let top = viewModel.messages.suffix(300).first?.id
|
let last = viewModel.messages.suffix(windowCountPublic).last?.id
|
||||||
let target = top.map { "\(contextKey)|\($0)" }
|
let target = last.map { "\(contextKey)|\($0)" }
|
||||||
isAtBottom.wrappedValue = false
|
isAtBottom.wrappedValue = true
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
if let target = target { proxy.scrollTo(target, anchor: .top) }
|
if let target = target { proxy.scrollTo(target, anchor: .bottom) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user