Remove the public-channel screenshot broadcast (#1596)

* Remove the public-channel screenshot broadcast

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 <noreply@anthropic.com>

* Review fixes: pin the screenshot routing table

The geohash warn path lived only in AppRuntime with no coverage. The
decision is now a pure nonisolated table
(AppRuntime.resolveScreenshotResponse) pinned by tests: location sheet
or geohash timeline → local privacy alert (nothing sent), App Info →
nothing, mesh/DM → chat layer (where public channels stay silent).

Mesh deliberately gets no local alert: a mesh screenshot reveals no
place and triggers no send, so there is nothing actionable to warn
about — alerting on every screenshot would train people to dismiss the
one alert that matters. Rationale now documented on the response enum.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jack 2026-08-10 07:57:25 +02:00 committed by GitHub
parent 4122a4dacd
commit 4f30433406
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 149 additions and 105 deletions

View File

@ -424,16 +424,24 @@ private extension AppRuntime {
}
func handleScreenshotCaptured() {
if appChromeModel.isLocationChannelsSheetPresented {
let isLocationChannelActive: Bool = {
if case .location = chatViewModel.activeChannel { return true }
return false
}()
switch Self.resolveScreenshotResponse(
isLocationChannelsSheetPresented: appChromeModel.isLocationChannelsSheetPresented,
isAppInfoPresented: appChromeModel.isAppInfoPresented,
hasPrivateChatOpen: chatViewModel.selectedPrivateChatPeer != nil,
isLocationChannelActive: isLocationChannelActive
) {
case .warnLocally:
appChromeModel.triggerScreenshotPrivacyWarning()
return
case .ignore:
break
case .forwardToChat:
chatViewModel.handleScreenshotCaptured()
}
if appChromeModel.isAppInfoPresented {
return
}
chatViewModel.handleScreenshotCaptured()
}
func openExternalURL(_ url: URL) {
@ -450,3 +458,40 @@ private extension AppRuntime {
}
}
}
// MARK: - Screenshot routing
extension AppRuntime {
/// What a screenshot triggers. Nothing on this table sends anything to
/// a public channel (see `ChatLifecycleCoordinator.handleScreenshotCaptured`).
enum ScreenshotCaptureResponse: Equatable {
/// Show the local location-privacy alert; nothing is sent anywhere.
case warnLocally
/// Do nothing. App Info holds no conversation or location content.
case ignore
/// Hand to the chat layer: a DM notice goes to the peer when a
/// secure session exists; public timelines stay silent. Mesh
/// deliberately gets no local alert either a mesh screenshot
/// reveals no place and triggers no send, so there is nothing to
/// warn about, and alerting on every screenshot would train people
/// to dismiss the one alert that matters (the location one).
case forwardToChat
}
/// Pure decision table so the screenshot routing is testable without a
/// runtime.
nonisolated static func resolveScreenshotResponse(
isLocationChannelsSheetPresented: Bool,
isAppInfoPresented: Bool,
hasPrivateChatOpen: Bool,
isLocationChannelActive: Bool
) -> ScreenshotCaptureResponse {
if isLocationChannelsSheetPresented { return .warnLocally }
if isAppInfoPresented { return .ignore }
// A geohash timeline screenshot still reveals a place warn the
// person taking it, locally, with the same alert the channel sheet
// uses.
if !hasPrivateChatOpen, isLocationChannelActive { return .warnLocally }
return .forwardToChat
}
}

View File

@ -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

View File

@ -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)
}
@ -365,3 +350,59 @@ struct ChatLifecycleCoordinatorContextTests {
}
}
// MARK: - Screenshot Routing
/// Pins `AppRuntime`'s screenshot decision table: the geohash warn path,
/// the App Info exemption, and the deliberate mesh silence. Combined with
/// `handleScreenshotCaptured_publicChannel_staysSilent` above, this proves
/// location + screenshot local privacy alert, and nothing sent anywhere.
struct ScreenshotCaptureRoutingTests {
@Test("Location channels warn locally; the sheet warns everywhere")
func locationScreenshotsWarnLocally() {
// Geohash timeline, no DM open: warn the person, tell no one.
#expect(AppRuntime.resolveScreenshotResponse(
isLocationChannelsSheetPresented: false,
isAppInfoPresented: false,
hasPrivateChatOpen: false,
isLocationChannelActive: true
) == .warnLocally)
// The channel sheet reveals location regardless of active channel.
#expect(AppRuntime.resolveScreenshotResponse(
isLocationChannelsSheetPresented: true,
isAppInfoPresented: false,
hasPrivateChatOpen: true,
isLocationChannelActive: false
) == .warnLocally)
}
@Test("App Info is exempt; mesh and DMs forward to the chat layer")
func nonLocationScreenshotsForwardOrIgnore() {
#expect(AppRuntime.resolveScreenshotResponse(
isLocationChannelsSheetPresented: false,
isAppInfoPresented: true,
hasPrivateChatOpen: false,
isLocationChannelActive: true
) == .ignore)
// Mesh timeline: forwarded, where the coordinator stays silent for
// public channels no local alert either (nothing is sent and no
// place is revealed; see ScreenshotCaptureResponse docs).
#expect(AppRuntime.resolveScreenshotResponse(
isLocationChannelsSheetPresented: false,
isAppInfoPresented: false,
hasPrivateChatOpen: false,
isLocationChannelActive: false
) == .forwardToChat)
// An open DM keeps its peer notice even from a location channel.
#expect(AppRuntime.resolveScreenshotResponse(
isLocationChannelsSheetPresented: false,
isAppInfoPresented: false,
hasPrivateChatOpen: true,
isLocationChannelActive: true
) == .forwardToChat)
}
}

View File

@ -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)
}
}