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>
This commit is contained in:
jack 2026-08-01 18:23:34 +03:00
parent 681c180060
commit 435fa945d6
4 changed files with 50 additions and 97 deletions

View File

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

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

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