mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-08-08 06:56:10 +00:00
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:
parent
681c180060
commit
435fa945d6
@ -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()
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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