From ebfceb21bba1269d476ccf15eb078bb7ccffe5e3 Mon Sep 17 00:00:00 2001 From: jack Date: Thu, 28 Aug 2025 14:36:29 +0200 Subject: [PATCH] Fix duplicate messages after channel switch\n\n- Dedup on per-geohash append (skip if ID exists)\n- Dedup and sort timeline by timestamp when switching into a geohash\n- Keep content/layout unchanged; add lightweight debug logs for empty rows --- bitchat/ViewModels/ChatViewModel.swift | 31 +++++++++++++++++++++----- bitchat/Views/ContentView.swift | 6 +++-- 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index ed51b2ea..8a1d121d 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -1412,6 +1412,11 @@ class ChatViewModel: ObservableObject, BitchatDelegate { switch channel { case .mesh: messages = meshTimeline + // Debug: log if any empty messages are present + let emptyMesh = messages.filter { $0.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }.count + if emptyMesh > 0 { + SecureLogger.log("RenderGuard: mesh timeline contains \(emptyMesh) empty messages", category: SecureLogger.session, level: .debug) + } stopGeoParticipantsTimer() geohashPeople = [] teleportedGeo.removeAll() @@ -1419,13 +1424,26 @@ class ChatViewModel: ObservableObject, BitchatDelegate { // Sanitize existing timeline (filter any prior empty-content entries) var arr = geoTimelines[ch.geohash] ?? [] arr.removeAll { $0.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } - // Ensure chronological order when returning to a geohash + // Deduplicate by ID while preserving order (from oldest to newest) if arr.count > 1 { - arr.sort { $0.timestamp < $1.timestamp } + var seen = Set() + var dedup: [BitchatMessage] = [] + for m in arr.sorted(by: { $0.timestamp < $1.timestamp }) { + if !seen.contains(m.id) { + dedup.append(m) + seen.insert(m.id) + } + } + arr = dedup } // Persist the cleaned/sorted timeline for this geohash geoTimelines[ch.geohash] = arr messages = arr + // Debug: log if any empty messages are present post-sanitize + let emptyGeo = messages.filter { $0.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }.count + if emptyGeo > 0 { + SecureLogger.log("RenderGuard: geohash \(ch.geohash) timeline has \(emptyGeo) empty messages after sanitize", category: SecureLogger.session, level: .debug) + } } // Unsubscribe previous if let sub = geoSubscriptionID { @@ -5797,9 +5815,12 @@ class ChatViewModel: ObservableObject, BitchatDelegate { if isGeo && finalMessage.sender != "system" { if let gh = currentGeohash { var arr = geoTimelines[gh] ?? [] - arr.append(finalMessage) - if arr.count > geoTimelineCap { arr = Array(arr.suffix(geoTimelineCap)) } - geoTimelines[gh] = arr + // Dedup by message ID before appending to per-geohash timeline + if !arr.contains(where: { $0.id == finalMessage.id }) { + arr.append(finalMessage) + if arr.count > geoTimelineCap { arr = Array(arr.suffix(geoTimelineCap)) } + geoTimelines[gh] = arr + } } } diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index 83629553..779a9d92 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -281,8 +281,10 @@ struct ContentView: View { } }() let items = windowedMessages.map { (uiID: "\(contextKey)|\($0.id)", message: $0) } - - ForEach(items, id: \.uiID) { item in + // Filter out empty/whitespace-only messages to avoid blank rows + let filteredItems = items.filter { !$0.message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + + ForEach(filteredItems, id: \.uiID) { item in let message = item.message VStack(alignment: .leading, spacing: 0) { // Check if current user is mentioned