mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 03:45:20 +00:00
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:
@@ -2776,15 +2776,24 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
// Process content with hashtags and mentions
|
// Process content with hashtags and mentions
|
||||||
let content = message.content
|
let content = message.content
|
||||||
|
|
||||||
let hashtagPattern = "#([a-zA-Z0-9_]+)"
|
// For extremely long content, render as plain text to avoid heavy regex/layout work
|
||||||
// Allow optional '#abcd' suffix in mentions
|
if content.count > 4000 || content.hasVeryLongToken(threshold: 1024) {
|
||||||
let mentionPattern = "@([\\p{L}0-9_]+(?:#[a-fA-F0-9]{4})?)"
|
var plainStyle = AttributeContainer()
|
||||||
|
plainStyle.foregroundColor = baseColor
|
||||||
let hashtagRegex = try? NSRegularExpression(pattern: hashtagPattern, options: [])
|
plainStyle.font = isSelf
|
||||||
let mentionRegex = try? NSRegularExpression(pattern: mentionPattern, options: [])
|
? .system(size: 14, weight: .bold, design: .monospaced)
|
||||||
|
: .system(size: 14, design: .monospaced)
|
||||||
// Use NSDataDetector for URL detection
|
result.append(AttributedString(content).mergingAttributes(plainStyle))
|
||||||
let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
|
} 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 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)) ?? []
|
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))
|
result.append(AttributedString(remainingText).mergingAttributes(remainingStyle))
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Add timestamp at the end (smaller, light grey)
|
// Add timestamp at the end (smaller, light grey)
|
||||||
let timestamp = AttributedString(" [\(formatTimestamp(message.timestamp))]")
|
let timestamp = AttributedString(" [\(formatTimestamp(message.timestamp))]")
|
||||||
|
|||||||
+144
-12
@@ -34,21 +34,17 @@ struct LazyLinkPreviewView: View {
|
|||||||
@State private var isVisible = false
|
@State private var isVisible = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
GeometryReader { geometry in
|
Group {
|
||||||
if isVisible {
|
if isVisible {
|
||||||
LinkPreviewView(url: url, title: title)
|
LinkPreviewView(url: url, title: title)
|
||||||
} else {
|
} else {
|
||||||
// Placeholder while not visible
|
|
||||||
RoundedRectangle(cornerRadius: 10)
|
RoundedRectangle(cornerRadius: 10)
|
||||||
.fill(Color.gray.opacity(0.1))
|
.fill(Color.gray.opacity(0.1))
|
||||||
.frame(height: 80)
|
.frame(height: 80)
|
||||||
.onAppear {
|
|
||||||
// Only load when view appears on screen
|
|
||||||
isVisible = true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(height: 80)
|
.frame(height: 80)
|
||||||
|
.onAppear { isVisible = true }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,6 +79,7 @@ struct ContentView: View {
|
|||||||
@State private var scrollThrottleTimer: Timer?
|
@State private var scrollThrottleTimer: Timer?
|
||||||
@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> = []
|
||||||
|
|
||||||
// MARK: - Computed Properties
|
// MARK: - Computed Properties
|
||||||
|
|
||||||
@@ -291,8 +288,22 @@ struct ContentView: View {
|
|||||||
|
|
||||||
// Implement windowing - show last 100 messages for performance
|
// Implement windowing - show last 100 messages for performance
|
||||||
let windowedMessages = messages.suffix(100)
|
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) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
// Check if current user is mentioned
|
// Check if current user is mentioned
|
||||||
|
|
||||||
@@ -307,9 +318,12 @@ struct ContentView: View {
|
|||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
HStack(alignment: .top, spacing: 0) {
|
HStack(alignment: .top, spacing: 0) {
|
||||||
// Single text view for natural wrapping
|
// 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))
|
Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme))
|
||||||
.textSelection(.enabled)
|
.textSelection(.enabled)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
.lineLimit(isLong && !isExpanded ? 30 : nil)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|
||||||
// Delivery status indicator for private messages
|
// 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
|
// Check for plain URLs
|
||||||
let urls = message.content.extractURLs()
|
let urls = message.content.extractURLs()
|
||||||
if !urls.isEmpty {
|
if !urls.isEmpty {
|
||||||
@@ -334,7 +360,7 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.id(message.id)
|
.id(item.uiID)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
// Track if last item is visible to enable auto-scroll only when near bottom
|
// Track if last item is visible to enable auto-scroll only when near bottom
|
||||||
if message.id == windowedMessages.last?.id {
|
if message.id == windowedMessages.last?.id {
|
||||||
@@ -377,6 +403,78 @@ struct ContentView: View {
|
|||||||
// Triple-tap to clear current chat
|
// Triple-tap to clear current chat
|
||||||
viewModel.sendMessage("/clear")
|
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
|
.onChange(of: viewModel.messages.count) { _ in
|
||||||
if privatePeer == nil && !viewModel.messages.isEmpty {
|
if privatePeer == nil && !viewModel.messages.isEmpty {
|
||||||
// Only autoscroll when user is at/near bottom
|
// Only autoscroll when user is at/near bottom
|
||||||
@@ -386,13 +484,39 @@ struct ContentView: View {
|
|||||||
if now.timeIntervalSince(lastScrollTime) > 0.5 {
|
if now.timeIntervalSince(lastScrollTime) > 0.5 {
|
||||||
// Immediate scroll if enough time has passed
|
// Immediate scroll if enough time has passed
|
||||||
lastScrollTime = now
|
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 {
|
} else {
|
||||||
// Schedule a delayed scroll
|
// Schedule a delayed scroll
|
||||||
scrollThrottleTimer?.invalidate()
|
scrollThrottleTimer?.invalidate()
|
||||||
scrollThrottleTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { _ in
|
scrollThrottleTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { _ in
|
||||||
lastScrollTime = Date()
|
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()
|
let now = Date()
|
||||||
if now.timeIntervalSince(lastScrollTime) > 0.5 {
|
if now.timeIntervalSince(lastScrollTime) > 0.5 {
|
||||||
lastScrollTime = now
|
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 {
|
} else {
|
||||||
scrollThrottleTimer?.invalidate()
|
scrollThrottleTimer?.invalidate()
|
||||||
scrollThrottleTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { _ in
|
scrollThrottleTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { _ in
|
||||||
lastScrollTime = Date()
|
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) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -424,6 +424,21 @@ extension String {
|
|||||||
|
|
||||||
return urls
|
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 {
|
#Preview {
|
||||||
|
|||||||
Reference in New Issue
Block a user