diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 0c6303e9..8a1d121d 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -382,6 +382,11 @@ class ChatViewModel: ObservableObject, BitchatDelegate { @Published var showBluetoothAlert = false @Published var bluetoothAlertMessage = "" @Published var bluetoothState: CBManagerState = .unknown + + // Presentation state for privacy gating + @Published var isLocationChannelsSheetPresented: Bool = false + @Published var isAppInfoPresented: Bool = false + @Published var showScreenshotPrivacyWarning: Bool = false // Messages are naturally ephemeral - no persistent storage // Persist mesh public timeline across channel switches @@ -1407,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() @@ -1414,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 { @@ -2588,6 +2611,17 @@ class ChatViewModel: ObservableObject, BitchatDelegate { @MainActor @objc private func userDidTakeScreenshot() { + // Respect privacy: do not broadcast screenshots taken from non-chat sheets + if isLocationChannelsSheetPresented { + // Show a warning about sharing location screenshots publicly + showScreenshotPrivacyWarning = true + return + } + if isAppInfoPresented { + // Silently ignore screenshots of app info + return + } + // Send screenshot notification based on current context let screenshotMessage = "* \(nickname) took a screenshot *" @@ -2607,7 +2641,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate { } } - // Show local notification immediately as system message + // Show local notification immediately as system message (only in chat) let localNotification = BitchatMessage( sender: "system", content: "you took a screenshot", @@ -2658,7 +2692,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate { } - // Show local notification immediately as system message + // Show local notification immediately as system message (only in chat) let localNotification = BitchatMessage( sender: "system", content: "you took a screenshot", @@ -5781,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 cc757ad7..779a9d92 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -165,6 +165,8 @@ struct ContentView: View { } .sheet(isPresented: $showAppInfo) { AppInfoView() + .onAppear { viewModel.isAppInfoPresented = true } + .onDisappear { viewModel.isAppInfoPresented = false } } .sheet(isPresented: Binding( get: { viewModel.showingFingerprintFor != nil }, @@ -279,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 @@ -1182,6 +1186,13 @@ struct ContentView: View { .padding(.horizontal, 12) .sheet(isPresented: $showLocationChannelsSheet) { LocationChannelsSheet(isPresented: $showLocationChannelsSheet) + .onAppear { viewModel.isLocationChannelsSheetPresented = true } + .onDisappear { viewModel.isLocationChannelsSheetPresented = false } + } + .alert("heads up", isPresented: $viewModel.showScreenshotPrivacyWarning) { + Button("ok", role: .cancel) {} + } message: { + Text("screenshots of location channels will reveal your location. think before sharing publicly.") } .background(backgroundColor.opacity(0.95)) } diff --git a/bitchat/Views/LocationChannelsSheet.swift b/bitchat/Views/LocationChannelsSheet.swift index 44a6438e..19ab4a26 100644 --- a/bitchat/Views/LocationChannelsSheet.swift +++ b/bitchat/Views/LocationChannelsSheet.swift @@ -304,22 +304,17 @@ struct LocationChannelsSheet: View { .foregroundColor(.secondary) } } - HStack(spacing: 0) { - Text(subtitlePrefix) - .font(.system(size: 12, design: .monospaced)) - .foregroundColor(.secondary) - if let name = subtitleName { - Text(" • ") - .font(.system(size: 12, design: .monospaced)) - .foregroundColor(.secondary) - Text(name) - .font(.system(size: 12, design: .monospaced)) - .fontWeight(subtitleNameBold ? .bold : .regular) - .foregroundColor(.secondary) - .lineLimit(1) - .truncationMode(.tail) - } + let subtitleFull: String = { + if let name = subtitleName, !name.isEmpty { + return subtitlePrefix + " • " + name } + return subtitlePrefix + }() + Text(subtitleFull) + .font(.system(size: 12, design: .monospaced)) + .foregroundColor(.secondary) + .lineLimit(1) + .truncationMode(.tail) } Spacer() if isSelected {