From 435fa945d6e6ff323d8613e7c38acf5be137677f Mon Sep 17 00:00:00 2001 From: jack Date: Sat, 1 Aug 2026 18:23:34 +0300 Subject: [PATCH] Remove the public-channel screenshot broadcast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Taking a screenshot used to announce "* nickname took a screenshot *" to the entire active public channel — a mesh broadcast, or on geohash channels a mined ephemeral event published to the 5 nearest Nostr relays, permanently timestamping that this nickname was present and active at that place. Documenting something (or someone) is a core use of a protest app; it must not out the person doing it. - Public channels (mesh and geohash) no longer send anything on screenshot. The mined-event helper is deleted and the lifecycle context trimmed of its now-unused requirements. - A geohash timeline screenshot instead raises the existing local location-privacy warning alert (same one the channel sheet uses) — warn the person, tell no one. - DM screenshot notices remain, and the local "you took a screenshot" echo is now honest: it only appears when the notice actually went to the peer (it used to render even when no established session existed and nothing was sent). Co-Authored-By: Claude Fable 5 --- bitchat/App/AppRuntime.swift | 10 +++ .../ViewModels/ChatLifecycleCoordinator.swift | 86 +++++-------------- ...ChatLifecycleCoordinatorContextTests.swift | 45 ++++------ bitchatTests/ChatViewModelTests.swift | 6 +- 4 files changed, 50 insertions(+), 97 deletions(-) diff --git a/bitchat/App/AppRuntime.swift b/bitchat/App/AppRuntime.swift index 0158ea0a..d743bb8f 100644 --- a/bitchat/App/AppRuntime.swift +++ b/bitchat/App/AppRuntime.swift @@ -433,6 +433,16 @@ private extension AppRuntime { return } + // Screenshots are never announced to public channels (see + // ChatLifecycleCoordinator.handleScreenshotCaptured). A geohash + // timeline screenshot still reveals a place, so warn the person + // taking it — locally, the same alert the channel sheet uses. + if chatViewModel.selectedPrivateChatPeer == nil, + case .location = chatViewModel.activeChannel { + appChromeModel.triggerScreenshotPrivacyWarning() + return + } + chatViewModel.handleScreenshotCaptured() } diff --git a/bitchat/ViewModels/ChatLifecycleCoordinator.swift b/bitchat/ViewModels/ChatLifecycleCoordinator.swift index a2d8e8ea..d6684e8e 100644 --- a/bitchat/ViewModels/ChatLifecycleCoordinator.swift +++ b/bitchat/ViewModels/ChatLifecycleCoordinator.swift @@ -40,7 +40,6 @@ protocol ChatLifecycleContext: AnyObject { /// Schedules main-actor work after a UI-timing delay. Injected so tests /// can run the work synchronously instead of polling wall-clock queues. func scheduleOnMainAfter(_ delay: TimeInterval, _ work: @escaping @MainActor () -> Void) - func addSystemMessage(_ content: String) // MARK: Peers & sessions func peerNickname(for peerID: PeerID) -> String? @@ -55,13 +54,10 @@ protocol ChatLifecycleContext: AnyObject { func routePrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) @discardableResult func routeReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) -> Bool - func sendMeshMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) // MARK: Nostr & geohash - var isTeleported: Bool { get } func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity - func recordGeoParticipant(pubkeyHex: String) // MARK: Favorites (shared with `ChatPrivateConversationContext`) /// The persisted favorite relationship for the peer's Noise static key, if any. @@ -80,10 +76,10 @@ extension ChatViewModel: ChatLifecycleContext { // `selectedPrivateChatPeer`, `sentReadReceipts`, `nickname`, `myPeerID`, // `activeChannel`, `nostrKeyMapping`, `markReadReceiptSent(_:)`, // `markPrivateMessagesAsRead(from:)`, `appendPrivateMessage(_:to:)`, - // `markPrivateChatRead(_:)`, `addSystemMessage(_:)`, + // `markPrivateChatRead(_:)`, // `peerNickname(for:)`, `unifiedPeer(for:)`, `noiseSessionState(for:)`, - // the routing/ack members, `isTeleported`, - // `deriveNostrIdentity(forGeohash:)`, `recordGeoParticipant(pubkeyHex:)`, + // the routing/ack members, + // `deriveNostrIdentity(forGeohash:)`, // and `favoriteRelationship(forNoiseKey:)` // are shared requirements with the other contexts or satisfied by // existing `ChatViewModel` members. The members below flatten nested @@ -143,34 +139,21 @@ final class ChatLifecycleCoordinator { } func handleScreenshotCaptured() { + // Public channels never announce screenshots. The old broadcast told + // everyone in radio range — and, on geohash channels, public Nostr + // relays, permanently — that this nickname was present and active + // here right now. Documenting something (or someone) is a core use + // of a protest app; it must not out the person doing it. Screenshot + // notices remain a DM-only courtesy between the two people involved. + guard let peerID = context.selectedPrivateChatPeer else { return } + let screenshotMessage = "* \(context.nickname) took a screenshot *" - - if let peerID = context.selectedPrivateChatPeer { - sendPrivateScreenshotNotificationIfPossible( - screenshotMessage, - to: peerID - ) + // Only echo "you took a screenshot" when the peer was actually + // notified — the unconditional echo used to imply a notice that + // frequently was never sent (no established session). + if sendPrivateScreenshotNotificationIfPossible(screenshotMessage, to: peerID) { appendPrivateScreenshotNotice(for: peerID) - return } - - switch context.activeChannel { - case .mesh: - context.sendMeshMessage( - screenshotMessage, - mentions: [], - messageID: UUID().uuidString, - timestamp: Date() - ) - - case .location(let channel): - sendPublicGeohashScreenshotMessage( - screenshotMessage, - channel: channel - ) - } - - context.addSystemMessage("you took a screenshot") } func saveIdentityState() { @@ -293,8 +276,10 @@ final class ChatLifecycleCoordinator { } private extension ChatLifecycleCoordinator { - func sendPrivateScreenshotNotificationIfPossible(_ message: String, to peerID: PeerID) { - guard let peerNickname = context.peerNickname(for: peerID) else { return } + /// Returns whether the notice actually went out, so the caller can keep + /// the local echo honest. + func sendPrivateScreenshotNotificationIfPossible(_ message: String, to peerID: PeerID) -> Bool { + guard let peerNickname = context.peerNickname(for: peerID) else { return false } let sessionState = context.noiseSessionState(for: peerID) switch sessionState { @@ -305,12 +290,14 @@ private extension ChatLifecycleCoordinator { recipientNickname: peerNickname, messageID: UUID().uuidString ) + return true case .none, .failed, .handshakeQueued, .handshaking: SecureLogger.debug( "Skipping screenshot notification to \(peerID) - no established session", category: .security ) + return false } } @@ -329,37 +316,6 @@ private extension ChatLifecycleCoordinator { context.appendPrivateMessage(notice, to: peerID) } - func sendPublicGeohashScreenshotMessage(_ message: String, channel: GeohashChannel) { - Task { @MainActor [weak context = self.context] in - guard let context else { return } - - do { - let identity = try context.deriveNostrIdentity(forGeohash: channel.geohash) - let event = try await NostrProtocol.createMinedEphemeralGeohashEvent( - content: message, - geohash: channel.geohash, - senderIdentity: identity, - nickname: context.nickname, - teleported: context.isTeleported - ) - - let targetRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: channel.geohash, count: 5) - if targetRelays.isEmpty { - SecureLogger.warning("Geo: no geohash relays available for \(channel.geohash); not sending", category: .session) - } else { - NostrRelayManager.shared.sendEvent(event, to: targetRelays) - } - - context.recordGeoParticipant(pubkeyHex: identity.publicKeyHex) - } catch { - SecureLogger.error("❌ Failed to send geohash screenshot message: \(error)", category: .session) - context.addSystemMessage( - String(localized: "system.location.send_failed", comment: "System message when a location channel send fails") - ) - } - } - } - func deliveryStatusRank(_ status: DeliveryStatus) -> Int { switch status { case .notSentYet: return 0 diff --git a/bitchatTests/ChatLifecycleCoordinatorContextTests.swift b/bitchatTests/ChatLifecycleCoordinatorContextTests.swift index 1ed083a0..02e5ff18 100644 --- a/bitchatTests/ChatLifecycleCoordinatorContextTests.swift +++ b/bitchatTests/ChatLifecycleCoordinatorContextTests.swift @@ -7,12 +7,10 @@ // `ChatDeliveryCoordinatorContextTests` / // `ChatPrivateConversationCoordinatorContextTests` exemplars. // -// Scope note: the geohash-screenshot branch publishes via -// `NostrRelayManager.shared` / `GeoRelayDirectory.shared`; that stays covered -// by the full view-model tests. The GeoDM read pass, the favorites-backed -// mesh/Nostr read-receipt branch (favorites are injected through the -// context), message merging, screenshot notices, and lifecycle persistence -// flows are covered here. +// Scope note: the GeoDM read pass, the favorites-backed mesh/Nostr +// read-receipt branch (favorites are injected through the context), message +// merging, DM screenshot notices, and lifecycle persistence flows are +// covered here. Screenshots are never announced to public channels. // import Testing @@ -42,7 +40,6 @@ private final class MockChatLifecycleContext: ChatLifecycleContext { var nostrKeyMapping: [PeerID: String] = [:] private(set) var ownerLevelReadPasses: [PeerID] = [] private(set) var managerReadMarks: [PeerID] = [] - private(set) var systemMessages: [String] = [] // Conversation store intents @discardableResult @@ -79,8 +76,6 @@ private final class MockChatLifecycleContext: ChatLifecycleContext { work() } - func addSystemMessage(_ content: String) { systemMessages.append(content) } - // Peers & sessions var nicknamesByPeerID: [PeerID: String] = [:] var peersByID: [PeerID: BitchatPeer] = [:] @@ -99,7 +94,6 @@ private final class MockChatLifecycleContext: ChatLifecycleContext { // Routing & receipts private(set) var routedPrivateMessages: [(content: String, peerID: PeerID, recipientNickname: String)] = [] private(set) var routedReadReceipts: [(messageID: String, peerID: PeerID)] = [] - private(set) var meshBroadcasts: [String] = [] private(set) var geoReadReceipts: [(messageID: String, recipientHex: String)] = [] func routePrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) { @@ -112,20 +106,12 @@ private final class MockChatLifecycleContext: ChatLifecycleContext { return routeReadReceiptResult } - func sendMeshMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) { - meshBroadcasts.append(content) - } - func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) { geoReadReceipts.append((messageID, recipientHex)) } // Nostr & geohash - var isTeleported = false - private(set) var recordedGeoParticipants: [String] = [] - func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity { Self.dummyIdentity } - func recordGeoParticipant(pubkeyHex: String) { recordedGeoParticipants.append(pubkeyHex) } // Favorites var favoriteRelationshipsByNoiseKey: [Data: FavoritesPersistenceService.FavoriteRelationship] = [:] @@ -264,40 +250,39 @@ struct ChatLifecycleCoordinatorContextTests { } @Test @MainActor - func handleScreenshotCaptured_privateChat_appendsNoticeAndRoutesWhenEstablished() async { + func handleScreenshotCaptured_privateChat_echoesOnlyWhenPeerWasNotified() async { let context = MockChatLifecycleContext() let coordinator = ChatLifecycleCoordinator(context: context) let peerID = PeerID(str: "1122334455667788") context.selectedPrivateChatPeer = peerID context.nicknamesByPeerID[peerID] = "alice" - // No established session: local notice only, no network send. + // No established session: nothing goes out, so nothing is echoed — + // a local "you took a screenshot" would imply the peer was told. coordinator.handleScreenshotCaptured() #expect(context.routedPrivateMessages.isEmpty) - #expect(context.privateChats[peerID]?.map(\.content) == ["you took a screenshot"]) - #expect(context.privateChats[peerID]?.first?.sender == "system") + #expect(context.privateChats.isEmpty) - // Established session: the peer is notified too. + // Established session: the peer is notified and the echo appears. context.noiseSessionStates[peerID] = .established coordinator.handleScreenshotCaptured() #expect(context.routedPrivateMessages.count == 1) #expect(context.routedPrivateMessages.first?.content == "* me took a screenshot *") #expect(context.routedPrivateMessages.first?.recipientNickname == "alice") - #expect(context.privateChats[peerID]?.count == 2) - // The public-channel system message is not used for private chats. - #expect(context.systemMessages.isEmpty) - #expect(context.meshBroadcasts.isEmpty) + #expect(context.privateChats[peerID]?.map(\.content) == ["you took a screenshot"]) + #expect(context.privateChats[peerID]?.first?.sender == "system") } @Test @MainActor - func handleScreenshotCaptured_meshChannel_broadcastsAndConfirmsLocally() async { + func handleScreenshotCaptured_publicChannel_staysSilent() async { let context = MockChatLifecycleContext() let coordinator = ChatLifecycleCoordinator(context: context) + // No DM selected: screenshots must never announce presence to a + // public channel (mesh broadcast or Nostr relays). coordinator.handleScreenshotCaptured() - #expect(context.meshBroadcasts == ["* me took a screenshot *"]) - #expect(context.systemMessages == ["you took a screenshot"]) + #expect(context.routedPrivateMessages.isEmpty) #expect(context.privateChats.isEmpty) } diff --git a/bitchatTests/ChatViewModelTests.swift b/bitchatTests/ChatViewModelTests.swift index 7e4e46a1..0393b3ce 100644 --- a/bitchatTests/ChatViewModelTests.swift +++ b/bitchatTests/ChatViewModelTests.swift @@ -429,7 +429,7 @@ struct ChatViewModelServiceLifecycleTests { } @Test @MainActor - func handleScreenshotCaptured_privateChatAddsLocalNoticeWithoutSession() async { + func handleScreenshotCaptured_privateChatStaysSilentWithoutSession() async { let (viewModel, transport) = makeTestableViewModel() let peerID = PeerID(str: "0000000000000002") transport.simulateConnect(peerID, nickname: "Alice") @@ -437,8 +437,10 @@ struct ChatViewModelServiceLifecycleTests { viewModel.selectedPrivateChatPeer = peerID viewModel.handleScreenshotCaptured() + // No session means no notice went out, so no local echo either — + // an echo here would imply Alice was told when she wasn't. #expect(transport.sentPrivateMessages.isEmpty) - #expect(viewModel.privateChats[peerID]?.last?.content == "you took a screenshot") + #expect(viewModel.privateChats[peerID]?.contains { $0.content == "you took a screenshot" } != true) } }