Feature/screenshot privacy warning (#541)

* Screenshot privacy: warn on location sheet; gate screenshot broadcasts to chat only\n\n- Track sheet presentation in ChatViewModel\n- Show privacy alert when screenshot taken on location channels\n- Ignore screenshots for App Info and Location sheet for broadcast\n- ContentView wires sheet onAppear/onDisappear and presents alert\n- Fix location sheet subtitle wrapping: render as single Text to avoid orphaned bullets

* 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

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
This commit is contained in:
jack
2025-08-28 14:37:17 +02:00
committed by GitHub
co-authored by jack
parent 3e5043f875
commit e79bcf531b
3 changed files with 67 additions and 24 deletions
+44 -7
View File
@@ -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<String>()
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
}
}
}
+13 -2
View File
@@ -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))
}
+10 -15
View File
@@ -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 {