mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-08-29 07:27:16 +00:00
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:
parent
4122a4dacd
commit
4f30433406
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user