From 9c84d4ce4f8e67af44b4d6df3d2ed417da6c3a82 Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Sat, 8 Aug 2026 12:12:27 +0200 Subject: [PATCH 01/11] Fail closed on unverifiable handshake peer IDs + check SecRandomCopyBytes results (#1645) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two hardening fixes from the repo evaluation: - NoiseSessionManager.authenticatedRemoteKey returned true for peer IDs that are neither 16-hex wire IDs nor full Noise-key IDs — an accept-any-key fallback kept for test harnesses. It now fails closed; the Noise/integration/E2E tests that relied on it address peers by key-derived wire IDs instead (the pattern NoiseCoverageTests already used), and a new regression test pins the rejection. - Four SecRandomCopyBytes call sites discarded the return status. The two verification nonces now fail their operation on error, the Nostr device identity seed uses CryptoKit key generation (cannot fail, and can no longer silently persist an all-zero seed), and BIP-340 aux randomness throws on failure like the adjacent nonce path. Co-authored-by: jack Co-authored-by: Claude Fable 5 --- bitchat/Noise/NoiseSessionManager.swift | 6 +++--- bitchat/Nostr/NostrIdentityBridge.swift | 7 +++---- bitchat/Nostr/NostrProtocol.swift | 5 ++++- bitchat/Services/VerificationService.swift | 3 ++- .../ChatVerificationCoordinator.swift | 3 ++- .../EndToEnd/PrivateChatE2ETests.swift | 19 ++++++++++------- .../Integration/IntegrationTests.swift | 14 +++++++------ .../Integration/LargeTopologyTests.swift | 2 +- .../Integration/TestNetworkHelper.swift | 10 +++++---- bitchatTests/Noise/NoiseCoverageTests.swift | 20 ++++++++++++++++++ bitchatTests/Noise/NoiseProtocolTests.swift | 21 ++++++++++++------- 11 files changed, 75 insertions(+), 35 deletions(-) diff --git a/bitchat/Noise/NoiseSessionManager.swift b/bitchat/Noise/NoiseSessionManager.swift index 6e2b3c23..c2aacbb0 100644 --- a/bitchat/Noise/NoiseSessionManager.swift +++ b/bitchat/Noise/NoiseSessionManager.swift @@ -1126,8 +1126,8 @@ final class NoiseSessionManager { /// Mesh handshakes normally use a 16-hex wire ID. Full Noise-key IDs are /// also accepted by internal callers when they exactly match the static - /// key. Non-wire identifiers remain available to protocol test harnesses; - /// BLE packet ingress always supplies a short hexadecimal ID. + /// key. Anything else fails closed: an identifier that can't be checked + /// against the remote static key must never complete a handshake. private func authenticatedRemoteKey( _ remoteKey: Curve25519.KeyAgreement.PublicKey, matches claimedPeerID: PeerID @@ -1139,7 +1139,7 @@ final class NoiseSessionManager { if let claimedNoiseKey = claimedPeerID.noiseKey { return claimedNoiseKey == rawKey } - return true + return false } // MARK: - Encryption/Decryption diff --git a/bitchat/Nostr/NostrIdentityBridge.swift b/bitchat/Nostr/NostrIdentityBridge.swift index 2e18a235..51d14e84 100644 --- a/bitchat/Nostr/NostrIdentityBridge.swift +++ b/bitchat/Nostr/NostrIdentityBridge.swift @@ -76,10 +76,9 @@ final class NostrIdentityBridge { deviceSeedCache = existing return existing } - var seed = Data(count: 32) - _ = seed.withUnsafeMutableBytes { ptr in - SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!) - } + // CryptoKit key generation cannot fail, unlike SecRandomCopyBytes — + // a discarded failure here would persist an all-zero identity seed. + let seed = SymmetricKey(size: .bits256).withUnsafeBytes { Data($0) } // Ensure availability after first unlock to prevent unintended rotation when locked keychain.save(key: deviceSeedKey, data: seed, service: keychainService, accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly) deviceSeedCache = seed diff --git a/bitchat/Nostr/NostrProtocol.swift b/bitchat/Nostr/NostrProtocol.swift index bfef85e2..2e3e35d8 100644 --- a/bitchat/Nostr/NostrProtocol.swift +++ b/bitchat/Nostr/NostrProtocol.swift @@ -836,9 +836,12 @@ struct NostrEvent: Codable { // Sign with Schnorr (BIP-340) var messageBytes = [UInt8](eventIdHash) var auxRand = [UInt8](repeating: 0, count: 32) - _ = auxRand.withUnsafeMutableBytes { ptr in + let auxStatus = auxRand.withUnsafeMutableBytes { ptr in SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!) } + guard auxStatus == errSecSuccess else { + throw NostrError.cryptographicFailure + } let schnorrSignature = try key.signature(message: &messageBytes, auxiliaryRand: &auxRand) let signatureHex = schnorrSignature.dataRepresentation.hexEncodedString() diff --git a/bitchat/Services/VerificationService.swift b/bitchat/Services/VerificationService.swift index ae983cca..8aa46ed1 100644 --- a/bitchat/Services/VerificationService.swift +++ b/bitchat/Services/VerificationService.swift @@ -84,7 +84,8 @@ final class VerificationService { let signKey = transport.noiseSigningPublicKeyData().hexEncodedString() let ts = Int64(Date().timeIntervalSince1970) var nonce = Data(count: 16) - _ = nonce.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, 16, $0.baseAddress!) } + let status = nonce.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, 16, $0.baseAddress!) } + guard status == errSecSuccess else { return nil } let nonceB64 = nonce.base64EncodedString().replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "") let payload = VerificationQR(v: 1, noiseKeyHex: noiseKey, signKeyHex: signKey, npub: npub, nickname: nickname, ts: ts, nonceB64: nonceB64, sigHex: "") let msg = payload.canonicalBytes() diff --git a/bitchat/ViewModels/ChatVerificationCoordinator.swift b/bitchat/ViewModels/ChatVerificationCoordinator.swift index f6499988..42b39a94 100644 --- a/bitchat/ViewModels/ChatVerificationCoordinator.swift +++ b/bitchat/ViewModels/ChatVerificationCoordinator.swift @@ -294,7 +294,8 @@ final class ChatVerificationCoordinator { } var nonce = Data(count: 16) - _ = nonce.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, 16, $0.baseAddress!) } + let status = nonce.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, 16, $0.baseAddress!) } + guard status == errSecSuccess else { return false } var pending = PendingVerification( noiseKeyHex: qr.noiseKeyHex, signKeyHex: qr.signKeyHex, diff --git a/bitchatTests/EndToEnd/PrivateChatE2ETests.swift b/bitchatTests/EndToEnd/PrivateChatE2ETests.swift index dc8de7ab..d3bb2ab9 100644 --- a/bitchatTests/EndToEnd/PrivateChatE2ETests.swift +++ b/bitchatTests/EndToEnd/PrivateChatE2ETests.swift @@ -115,13 +115,18 @@ struct PrivateChatE2ETests { let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain) let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain) - + + // Manager sessions are keyed by key-derived wire IDs: handshake + // completion fails closed on IDs the remote key can't vouch for. + let aliceNoiseID = PeerID(publicKey: aliceKey.publicKey.rawRepresentation) + let bobNoiseID = PeerID(publicKey: bobKey.publicKey.rawRepresentation) + // Establish encrypted session do { - let handshake1 = try aliceManager.initiateHandshake(with: bob.peerID) - let handshake2 = try bobManager.handleIncomingHandshake(from: alice.peerID, message: handshake1)! - let handshake3 = try aliceManager.handleIncomingHandshake(from: bob.peerID, message: handshake2)! - _ = try bobManager.handleIncomingHandshake(from: alice.peerID, message: handshake3) + let handshake1 = try aliceManager.initiateHandshake(with: bobNoiseID) + let handshake2 = try bobManager.handleIncomingHandshake(from: aliceNoiseID, message: handshake1)! + let handshake3 = try aliceManager.handleIncomingHandshake(from: bobNoiseID, message: handshake2)! + _ = try bobManager.handleIncomingHandshake(from: aliceNoiseID, message: handshake3) } catch { Issue.record("Failed to establish Noise session: \(error)") } @@ -134,7 +139,7 @@ struct PrivateChatE2ETests { let message = BitchatMessage(packet.payload), message.isPrivate { do { - let encrypted = try aliceManager.encrypt(packet.payload, for: bob.peerID) + let encrypted = try aliceManager.encrypt(packet.payload, for: bobNoiseID) let encryptedPacket = BitchatPacket( type: 0x02, // Encrypted message type senderID: packet.senderID, @@ -155,7 +160,7 @@ struct PrivateChatE2ETests { // Decrypt incoming encrypted messages if packet.type == 0x02 { do { - let decrypted = try bobManager.decrypt(packet.payload, from: alice.peerID) + let decrypted = try bobManager.decrypt(packet.payload, from: aliceNoiseID) if let message = BitchatMessage(decrypted) { #expect(message.content == TestConstants.testMessage1) #expect(message.isPrivate) diff --git a/bitchatTests/Integration/IntegrationTests.swift b/bitchatTests/Integration/IntegrationTests.swift index e07f0fae..70311b49 100644 --- a/bitchatTests/Integration/IntegrationTests.swift +++ b/bitchatTests/Integration/IntegrationTests.swift @@ -18,10 +18,10 @@ struct IntegrationTests { private var helper = TestNetworkHelper() init() { - helper.createNode("Alice", peerID: PeerID(str: UUID().uuidString)) - helper.createNode("Bob", peerID: PeerID(str: UUID().uuidString)) - helper.createNode("Charlie", peerID: PeerID(str: UUID().uuidString)) - helper.createNode("David", peerID: PeerID(str: UUID().uuidString)) + helper.createNode("Alice") + helper.createNode("Bob") + helper.createNode("Charlie") + helper.createNode("David") } // MARK: - Multi-Peer Scenarios @@ -266,9 +266,11 @@ struct IntegrationTests { helper.nodes["Alice"]!.sendPrivateMessage("Before restart", to: helper.nodes["Bob"]!.peerID, recipientNickname: "Bob") } - // Simulate Bob restart by recreating his Noise manager + // Simulate Bob restart by recreating his Noise manager. A new static + // key means a new key-derived wire ID, just like production. let bobKey = Curve25519.KeyAgreement.PrivateKey() helper.noiseManagers["Bob"] = NoiseSessionManager(localStaticKey: bobKey, keychain: helper.mockKeychain) + helper.nodes["Bob"]!.myPeerID = PeerID(publicKey: bobKey.publicKey.rawRepresentation) // Re-establish Noise handshake explicitly via managers do { @@ -320,7 +322,7 @@ struct IntegrationTests { @Test func largeScaleNetwork() async throws { // Create larger network for i in 5...10 { - helper.createNode("Node\(i)", peerID: PeerID(str: "PEER\(i)")) + helper.createNode("Node\(i)") } // Connect in ring topology with cross-connections diff --git a/bitchatTests/Integration/LargeTopologyTests.swift b/bitchatTests/Integration/LargeTopologyTests.swift index 3f4c44dc..a87be438 100644 --- a/bitchatTests/Integration/LargeTopologyTests.swift +++ b/bitchatTests/Integration/LargeTopologyTests.swift @@ -37,7 +37,7 @@ struct LargeTopologyTests { private func makeNodes(_ names: [String]) { for name in names { - helper.createNode(name, peerID: PeerID(str: UUID().uuidString)) + helper.createNode(name) } } diff --git a/bitchatTests/Integration/TestNetworkHelper.swift b/bitchatTests/Integration/TestNetworkHelper.swift index 0131739c..e11d1917 100644 --- a/bitchatTests/Integration/TestNetworkHelper.swift +++ b/bitchatTests/Integration/TestNetworkHelper.swift @@ -22,15 +22,17 @@ final class TestNetworkHelper { // MARK: - Node/Manager management @discardableResult - func createNode(_ name: String, peerID: PeerID) -> MockBLEService { + func createNode(_ name: String) -> MockBLEService { let node = MockBLEService(bus: bus) - node.myPeerID = peerID + // Wire IDs must derive from the node's Noise static key: handshake + // completion fails closed on IDs the remote key can't vouch for. + let key = Curve25519.KeyAgreement.PrivateKey() + node.myPeerID = PeerID(publicKey: key.publicKey.rawRepresentation) node.mockNickname = name nodes[name] = node - + // This synchronous helper directly drives all three XX messages and // has no transport callback loop for delayed collision recovery. - let key = Curve25519.KeyAgreement.PrivateKey() noiseManagers[name] = NoiseSessionManager( localStaticKey: key, keychain: mockKeychain, diff --git a/bitchatTests/Noise/NoiseCoverageTests.swift b/bitchatTests/Noise/NoiseCoverageTests.swift index d083bd47..763f5816 100644 --- a/bitchatTests/Noise/NoiseCoverageTests.swift +++ b/bitchatTests/Noise/NoiseCoverageTests.swift @@ -578,6 +578,26 @@ struct NoiseCoverageTests { #expect(failingManager.getSession(for: charliePeerID) == nil) } + @Test("Handshake completion fails closed on non-wire peer IDs") + func handshakeCompletionRejectsNonWirePeerIDs() throws { + let aliceManager = NoiseSessionManager(localStaticKey: aliceStaticKey, keychain: keychain) + let bobManager = NoiseSessionManager(localStaticKey: bobStaticKey, keychain: keychain) + + // Alice addresses Bob by an identifier no static key can vouch for: + // neither a 16-hex wire ID nor a full Noise-key ID. Completion must + // reject it rather than accept any remote static key. + let nonWireID = PeerID(str: "not-a-wire-identifier") + let msg1 = try aliceManager.initiateHandshake(with: nonWireID) + let msg2 = try #require( + try bobManager.handleIncomingHandshake(from: bobPeerID, message: msg1) + ) + + #expect(throws: (any Error).self) { + try aliceManager.handleIncomingHandshake(from: nonWireID, message: msg2) + } + #expect(aliceManager.getSession(for: nonWireID)?.isEstablished() != true) + } + @Test("Session manager cleans up initiator sessions after start-handshake failures") func sessionManagerCleansUpInitiatorSessionsAfterStartHandshakeFailures() { let manager = NoiseSessionManager( diff --git a/bitchatTests/Noise/NoiseProtocolTests.swift b/bitchatTests/Noise/NoiseProtocolTests.swift index ac8ef93d..007bbef0 100644 --- a/bitchatTests/Noise/NoiseProtocolTests.swift +++ b/bitchatTests/Noise/NoiseProtocolTests.swift @@ -68,22 +68,29 @@ struct NoiseProtocolTests { private let bobKey = Curve25519.KeyAgreement.PrivateKey() private let mockKeychain = MockKeychain() - private let alicePeerID = PeerID(str: UUID().uuidString) - private let bobPeerID = PeerID(str: UUID().uuidString) - + // Manager sessions are keyed by the remote peer. Keep the historical + // names, but derive each wire ID from the static key that the + // corresponding manager authenticates during the handshake. + private var alicePeerID: PeerID { + PeerID(publicKey: bobKey.publicKey.rawRepresentation) + } + private var bobPeerID: PeerID { + PeerID(publicKey: aliceKey.publicKey.rawRepresentation) + } + private let aliceSession: NoiseSession private let bobSession: NoiseSession - + init() { aliceSession = NoiseSession( - peerID: alicePeerID, + peerID: PeerID(publicKey: bobKey.publicKey.rawRepresentation), role: .initiator, keychain: mockKeychain, localStaticKey: aliceKey ) - + bobSession = NoiseSession( - peerID: bobPeerID, + peerID: PeerID(publicKey: aliceKey.publicKey.rawRepresentation), role: .responder, keychain: mockKeychain, localStaticKey: bobKey From 2a9fb4d53f67beed91064eb97fa7f2cdb1f17dfb Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Sat, 8 Aug 2026 12:12:30 +0200 Subject: [PATCH 02/11] Make SwiftLint blocking in CI now that the violation backlog is zero (#1646) * Make SwiftLint blocking in CI now that the violation backlog is zero The lint job has been advisory since #1361, with the backlog tracked in #1088. That backlog is now down to 28 violations, so this clears it and flips the job to enforcing instead of adding baseline machinery: - Fix all remaining violations: closure parameters/capture lists joined onto their opening-brace line (BLEService, NoiseSessionManager, ChatMediaTransferCoordinator, BLENoisePacketHandlerTests), one trailing comma, one opening-brace spacing (both via swiftlint --fix). - Move the two periphery:ignore comments above the doc blocks they were orphaning; verified with a periphery --strict scan that the commands still register (no new findings vs baseline). - Disable the todo rule: the single TODO cites tracked issue #1434 and is a deliberate marker, not lint debt. - Exclude .device-lab from local runs (untracked; CI never sees it). - CI: drop continue-on-error and the "(advisory)" label; run with --strict so any new warning fails the job. Full SwiftPM suite green (2,004 tests / 216 suites) after the formatting changes; swiftlint lint --strict exits 0 with the same 0.65.0 the CI container pins. Co-Authored-By: Claude Fable 5 * Fix is_disjoint violation introduced by #1542 The strict lint gate caught this on its first run: the mention-suggestion key handler used modifierFlags.intersection([...]).isEmpty, which is exactly the pattern isDisjoint(with:) expresses directly. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: jack Co-authored-by: Claude Fable 5 --- .github/workflows/swift-tests.yml | 13 +++++++------ .swiftlint.yml | 2 ++ bitchat/Noise/NoiseSessionManager.swift | 3 +-- bitchat/Services/BLE/BLEService.swift | 15 +++++---------- bitchat/Utils/QuickJoinRegions.swift | 2 +- .../ViewModels/ChatMediaTransferCoordinator.swift | 3 +-- bitchat/Views/ContentComposerView.swift | 2 +- ...ChatMediaTransferCoordinatorContextTests.swift | 3 +-- .../Services/BLENoisePacketHandlerTests.swift | 6 ++---- .../Sources/BitFoundation/AnnounceV2Packet.swift | 6 +++--- .../Sources/BitFoundation/PeerIDRotation.swift | 8 ++++---- 11 files changed, 28 insertions(+), 35 deletions(-) diff --git a/.github/workflows/swift-tests.yml b/.github/workflows/swift-tests.yml index 81c42eb2..e64683de 100644 --- a/.github/workflows/swift-tests.yml +++ b/.github/workflows/swift-tests.yml @@ -264,11 +264,13 @@ jobs: CODE_SIGNING_ALLOWED=NO \ test - # Advisory only: SwiftLint reports style violations without ever failing the - # build. Runs in a pinned container (no Xcode plugin, no pbxproj changes) so - # it can never break the documented xcodebuild path or block a merge. + # Blocking since the backlog reached zero (all violations fixed; rules the + # codebase deliberately breaks are disabled in .swiftlint.yml). --strict + # promotes warnings to errors so new violations fail the job. Runs in a + # pinned container (no Xcode plugin, no pbxproj changes) so it can never + # break the documented xcodebuild path. lint: - name: SwiftLint (advisory) + name: SwiftLint runs-on: ubuntu-latest timeout-minutes: 15 # This job runs a third-party container image, so give it the least @@ -280,10 +282,9 @@ jobs: # Tag for readability, digest for immutability (tags can be repointed). # Bump both together, deliberately — never a floating tag. image: ghcr.io/realm/swiftlint:0.65.0@sha256:a482729f4b58741875af1566f23397f3f6db300372756fc31606d0a4527fab9e - continue-on-error: true steps: - uses: actions/checkout@v5 with: persist-credentials: false - name: Run SwiftLint - run: swiftlint lint --reporter github-actions-logging + run: swiftlint lint --strict --reporter github-actions-logging diff --git a/.swiftlint.yml b/.swiftlint.yml index 833662e9..8d916f35 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -3,6 +3,7 @@ excluded: - .build - .claude + - .device-lab - .swiftpm - .DerivedData - DerivedData @@ -21,6 +22,7 @@ disabled_rules: - control_statement - void_function_in_ternary - redundant_discardable_let # SwiftUI breaks without it + - todo # TODOs that cite a tracked issue are deliberate markers, not lint debt # To be enabled as we fix the issues - trailing_whitespace - cyclomatic_complexity diff --git a/bitchat/Noise/NoiseSessionManager.swift b/bitchat/Noise/NoiseSessionManager.swift index c2aacbb0..80b3cc42 100644 --- a/bitchat/Noise/NoiseSessionManager.swift +++ b/bitchat/Noise/NoiseSessionManager.swift @@ -1001,8 +1001,7 @@ final class NoiseSessionManager { 0, recentInitiatorCompletionGracePeriod - elapsed ) - let timeout = DispatchWorkItem(flags: .barrier) { - [weak self, weak establishedSession] in + let timeout = DispatchWorkItem(flags: .barrier) { [weak self, weak establishedSession] in guard let self, let establishedSession, let current = self.sessions[peerID], diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index 0c4ecfb1..5f9b97af 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -4027,10 +4027,8 @@ extension BLEService { ) } } - service.onRekeyHandshakeReady = { - [weak self, weak service] peerID, initiation in - self?.messageQueue.async { - [weak self, weak service] in + service.onRekeyHandshakeReady = { [weak self, weak service] peerID, initiation in + self?.messageQueue.async { [weak self, weak service] in guard let self, let service, self.noiseService === service else { @@ -4046,14 +4044,12 @@ extension BLEService { self.broadcastNoiseHandshake(message, to: peerID) } } - service.onHandshakeRecoveryRequired = { - [weak self, weak service] request in + service.onHandshakeRecoveryRequired = { [weak self, weak service] request in guard let self, let service else { return } #if DEBUG self._test_beforeHandshakeRecoveryEnqueued?(request.peerID) #endif - self.messageQueue.async { - [weak self, weak service] in + self.messageQueue.async { [weak self, weak service] in guard let self, let service, self.noiseService === service else { @@ -5294,8 +5290,7 @@ extension BLEService { ) else { return } - messageQueue.async { - [weak self, weak service] in + messageQueue.async { [weak self, weak service] in guard let self, let service, self.noiseService === service, diff --git a/bitchat/Utils/QuickJoinRegions.swift b/bitchat/Utils/QuickJoinRegions.swift index 31a6fae6..149aa8d3 100644 --- a/bitchat/Utils/QuickJoinRegions.swift +++ b/bitchat/Utils/QuickJoinRegions.swift @@ -108,6 +108,6 @@ struct QuickJoinSuggestion { "UY": "6c", "UZ": "tx", "VA": "sr", "VC": "dd", "VE": "d9", "VI": "de", "VN": "w7", "VU": "rs", "WS": "2j", "XK": "sr", "YE": "sf", "YT": "mj", - "ZA": "ke", "ZM": "kt", "ZW": "ks", + "ZA": "ke", "ZM": "kt", "ZW": "ks" ] } diff --git a/bitchat/ViewModels/ChatMediaTransferCoordinator.swift b/bitchat/ViewModels/ChatMediaTransferCoordinator.swift index 84295135..fe314f62 100644 --- a/bitchat/ViewModels/ChatMediaTransferCoordinator.swift +++ b/bitchat/ViewModels/ChatMediaTransferCoordinator.swift @@ -1859,8 +1859,7 @@ private extension ChatMediaTransferCoordinator { TimeInterval(UInt64.max) / 1_000_000_000 ) * 1_000_000_000 ) - reconnectRetryExpiryTasks[messageID] = Task { - @MainActor [weak self] in + reconnectRetryExpiryTasks[messageID] = Task { @MainActor [weak self] in if nanoseconds > 0 { try? await Task.sleep(nanoseconds: nanoseconds) } diff --git a/bitchat/Views/ContentComposerView.swift b/bitchat/Views/ContentComposerView.swift index 64021d52..356c4141 100644 --- a/bitchat/Views/ContentComposerView.swift +++ b/bitchat/Views/ContentComposerView.swift @@ -482,7 +482,7 @@ private struct AutocompleteKeyboardNavigationModifier: ViewModifier { /// list is up. Inactive monitors pass everything through. private func handleKeyDown(_ event: NSEvent) -> NSEvent? { guard isActive(), - event.modifierFlags.intersection([.command, .option, .control]).isEmpty else { + event.modifierFlags.isDisjoint(with: [.command, .option, .control]) else { return event } diff --git a/bitchatTests/ChatMediaTransferCoordinatorContextTests.swift b/bitchatTests/ChatMediaTransferCoordinatorContextTests.swift index ad200113..1969602c 100644 --- a/bitchatTests/ChatMediaTransferCoordinatorContextTests.swift +++ b/bitchatTests/ChatMediaTransferCoordinatorContextTests.swift @@ -653,8 +653,7 @@ struct ChatMediaTransferCoordinatorContextTests { @Test @MainActor func deleteStableMediaReleasesRetainedRetryBeforeTombstoneCommit() - async throws - { + async throws { let context = MockChatMediaTransferContext() let peerID = PeerID(str: "1122334455667788") context.selectedPrivateChatPeer = peerID diff --git a/bitchatTests/Services/BLENoisePacketHandlerTests.swift b/bitchatTests/Services/BLENoisePacketHandlerTests.swift index cdfa6f81..e2b80def 100644 --- a/bitchatTests/Services/BLENoisePacketHandlerTests.swift +++ b/bitchatTests/Services/BLENoisePacketHandlerTests.swift @@ -150,14 +150,12 @@ struct BLENoisePacketHandlerTests { recorder.clearedSessions.append(peerID) service.clearSession(for: peerID) }, - handleAuthenticatedPeerState: { - peerID, payload, generation in + handleAuthenticatedPeerState: { peerID, payload, generation in recorder.authenticatedPeerStates.append( (peerID, payload, generation) ) }, - deliverNoisePayload: { - peerID, type, payload, timestamp in + deliverNoisePayload: { peerID, type, payload, timestamp in recorder.deliveries.append( (peerID, type, payload, timestamp) ) diff --git a/localPackages/BitFoundation/Sources/BitFoundation/AnnounceV2Packet.swift b/localPackages/BitFoundation/Sources/BitFoundation/AnnounceV2Packet.swift index 0bee3e6f..72b2fd14 100644 --- a/localPackages/BitFoundation/Sources/BitFoundation/AnnounceV2Packet.swift +++ b/localPackages/BitFoundation/Sources/BitFoundation/AnnounceV2Packet.swift @@ -8,6 +8,9 @@ import Foundation +// periphery:ignore - intentionally unreferenced by production code; nothing +// emits or consumes this type yet, and BLEService parses it only to ignore it. +// Delete this annotation when the mesh starts using it. /// Identity-free presence announcement for rotating peer IDs. /// /// The v1 `AnnouncementPacket` broadcasts, in cleartext, every 4–30 seconds: the @@ -37,9 +40,6 @@ import Foundation /// `docs/PEER-ID-ROTATION.md`. /// /// Not emitted or consumed by the shipping mesh yet. -// periphery:ignore - intentionally unreferenced by production code; nothing -// emits or consumes this type yet, and BLEService parses it only to ignore it. -// Delete this annotation when the mesh starts using it. public struct AnnounceV2Packet: Equatable, Sendable { /// Rotation epoch this announce was built for. Carried explicitly so a /// receiver matches against a stated epoch instead of guessing. diff --git a/localPackages/BitFoundation/Sources/BitFoundation/PeerIDRotation.swift b/localPackages/BitFoundation/Sources/BitFoundation/PeerIDRotation.swift index 17a079d9..2edfa951 100644 --- a/localPackages/BitFoundation/Sources/BitFoundation/PeerIDRotation.swift +++ b/localPackages/BitFoundation/Sources/BitFoundation/PeerIDRotation.swift @@ -9,6 +9,10 @@ import Foundation private import CryptoKit +// periphery:ignore - intentionally unreferenced by production code. These are +// the reviewable primitives for a protocol change that cannot ship until both +// platforms agree on it; wiring them into the transport is the next step, not +// this one. Delete this annotation when the mesh starts using them. /// Derivations for rotating peer IDs and pairwise recognition tags. /// /// See `docs/PEER-ID-ROTATION.md` for the design, the threat model, and the @@ -32,10 +36,6 @@ private import CryptoKit /// Recognition tags come from the X25519 shared secret between two static /// keys, so exactly two parties can compute a given tag and an observer can /// compute none of them. -// periphery:ignore - intentionally unreferenced by production code. These are -// the reviewable primitives for a protocol change that cannot ship until both -// platforms agree on it; wiring them into the transport is the next step, not -// this one. Delete this annotation when the mesh starts using them. public enum PeerIDRotation { // MARK: - Parameters From 6f961638f2eec16e4e3ab90fd7ef3fbf571686f8 Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Sun, 9 Aug 2026 11:39:00 +0200 Subject: [PATCH 03/11] Timestamp sanity windows: future-dated QR codes and implausible Nostr DM rumors (#1647) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two re-assessment findings: - verifyScannedQR checked only staleness (now - ts > maxAge), so a future-dated timestamp bought a QR a longer validity window than a fresh one. The freshness check is now symmetric. - Inbound Nostr DMs had no client-side created_at validation; the age bound relied entirely on relays honoring the subscription's `since` filter. Both gift-wrap decrypt paths now drop rumors outside [now - lookback - skew, now + skew]. The inner rumor timestamp is the sender's true send time (only the outer gift wrap is randomized per NIP-17), so the window mirrors exactly what an honest relay already guarantees — a dishonest relay can no longer inject stale or future-dated DMs. Existing mitigations (persistent gift-wrap dedup, relay-side since) are unchanged; this closes the malicious-relay gap. Co-authored-by: jack Co-authored-by: Claude Fable 5 --- bitchat/Services/TransportConfig.swift | 4 +++ bitchat/Services/VerificationService.swift | 5 +-- bitchat/ViewModels/NostrInboundPipeline.swift | 28 ++++++++++++++++ .../NostrInboundPipelineTimestampTests.swift | 32 +++++++++++++++++++ .../Services/VerificationServiceTests.swift | 13 ++++++++ 5 files changed, 80 insertions(+), 2 deletions(-) create mode 100644 bitchatTests/NostrInboundPipelineTimestampTests.swift diff --git a/bitchat/Services/TransportConfig.swift b/bitchat/Services/TransportConfig.swift index 98029094..b1e19701 100644 --- a/bitchat/Services/TransportConfig.swift +++ b/bitchat/Services/TransportConfig.swift @@ -216,6 +216,10 @@ enum TransportConfig { static let nostrGeohashSampleLookbackSeconds: TimeInterval = 300 static let nostrGeohashSampleLimit: Int = 100 static let nostrDMSubscribeLookbackSeconds: TimeInterval = 86400 + // Tolerated clock skew for the client-side rumor-timestamp window on + // inbound Nostr DMs (senders stamp the inner rumor with real time; only + // the outer gift wrap is randomized per NIP-17). + static let nostrDMMaxClockSkewSeconds: TimeInterval = 900 // A sampled chat message this recent means "a conversation is happening // there" for the empty-timeline nearby-activity hint. static let uiGeohashChatActivityWindowSeconds: TimeInterval = 900 diff --git a/bitchat/Services/VerificationService.swift b/bitchat/Services/VerificationService.swift index 8aa46ed1..26247379 100644 --- a/bitchat/Services/VerificationService.swift +++ b/bitchat/Services/VerificationService.swift @@ -106,9 +106,10 @@ final class VerificationService { /// Verify a scanned QR and return the parsed payload if valid (signature + freshness checks) func verifyScannedQR(_ urlString: String, maxAge: TimeInterval = TransportConfig.verificationQRMaxAgeSeconds) -> VerificationQR? { guard let url = URL(string: urlString), let qr = VerificationQR.fromURL(url) else { return nil } - // Freshness + // Freshness, in both directions: a future-dated timestamp must not + // buy a QR a longer validity window than a fresh one gets. let now = Date().timeIntervalSince1970 - if now - Double(qr.ts) > maxAge { return nil } + if abs(now - Double(qr.ts)) > maxAge { return nil } // Verify signature using embedded ed25519 signKey guard let sig = Data(hexString: qr.sigHex), let signKey = Data(hexString: qr.signKeyHex) else { return nil } guard let transport = transport else { return nil } diff --git a/bitchat/ViewModels/NostrInboundPipeline.swift b/bitchat/ViewModels/NostrInboundPipeline.swift index 85024436..a6592802 100644 --- a/bitchat/ViewModels/NostrInboundPipeline.swift +++ b/bitchat/ViewModels/NostrInboundPipeline.swift @@ -357,6 +357,13 @@ final class NostrInboundPipeline { return } + guard Self.isPlausibleRumorTimestamp(rumorTs) else { + if verbose { + SecureLogger.warning("GeoDM: dropping gift-wrap with implausible rumor timestamp id=\(giftWrap.id.prefix(8))…", category: .session) + } + return + } + if verbose { SecureLogger.debug( "GeoDM: decrypted gift-wrap id=\(giftWrap.id.prefix(16))... from=\(senderPubkey.prefix(8))...", @@ -445,6 +452,11 @@ final class NostrInboundPipeline { recipientIdentity: currentIdentity ) + guard Self.isPlausibleRumorTimestamp(rumorTimestamp) else { + SecureLogger.warning("Dropping Nostr DM with implausible rumor timestamp id=\(giftWrap.id.prefix(8))…", category: .session) + return + } + if content.hasPrefix("verify:") { return } @@ -542,6 +554,22 @@ final class NostrInboundPipeline { } } +extension NostrInboundPipeline { + /// Client-side mirror of the relay-side `since` filter on DM + /// subscriptions: a relay that ignores `since` — or replays archived + /// events — must not inject stale or future-dated DMs. The inner rumor + /// timestamp is the sender's true send time (only the outer gift wrap + /// is randomized per NIP-17), so the plausible window is the + /// subscription lookback plus tolerated clock skew on both ends. + /// Internal (not private) so tests can pin the window directly. + static func isPlausibleRumorTimestamp(_ ts: Int, now: Date = Date()) -> Bool { + let age = now.timeIntervalSince1970 - TimeInterval(ts) + return age >= -TransportConfig.nostrDMMaxClockSkewSeconds + && age <= TransportConfig.nostrDMSubscribeLookbackSeconds + + TransportConfig.nostrDMMaxClockSkewSeconds + } +} + private extension NostrInboundPipeline { @MainActor static func decodeEmbeddedBitChatPacket(from content: String) -> BitchatPacket? { diff --git a/bitchatTests/NostrInboundPipelineTimestampTests.swift b/bitchatTests/NostrInboundPipelineTimestampTests.swift new file mode 100644 index 00000000..187133be --- /dev/null +++ b/bitchatTests/NostrInboundPipelineTimestampTests.swift @@ -0,0 +1,32 @@ +// +// NostrInboundPipelineTimestampTests.swift +// bitchatTests +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation +import Testing +@testable import bitchat + +struct NostrInboundPipelineTimestampTests { + private let now = Date(timeIntervalSince1970: 1_700_000_000) + private let nowSeconds = 1_700_000_000 + private let skew = Int(TransportConfig.nostrDMMaxClockSkewSeconds) + private let lookback = Int(TransportConfig.nostrDMSubscribeLookbackSeconds) + + @Test("Rumor timestamps inside the lookback-plus-skew window are accepted") + func acceptsPlausibleTimestamps() { + #expect(NostrInboundPipeline.isPlausibleRumorTimestamp(nowSeconds, now: now)) + #expect(NostrInboundPipeline.isPlausibleRumorTimestamp(nowSeconds - lookback + 60, now: now)) + // A sender clock slightly ahead of the receiver is tolerated. + #expect(NostrInboundPipeline.isPlausibleRumorTimestamp(nowSeconds + skew - 60, now: now)) + } + + @Test("Future-dated and stale rumor timestamps are rejected") + func rejectsImplausibleTimestamps() { + #expect(!NostrInboundPipeline.isPlausibleRumorTimestamp(nowSeconds + skew + 60, now: now)) + #expect(!NostrInboundPipeline.isPlausibleRumorTimestamp(nowSeconds - lookback - skew - 60, now: now)) + } +} diff --git a/bitchatTests/Services/VerificationServiceTests.swift b/bitchatTests/Services/VerificationServiceTests.swift index b8f688d3..ce854a58 100644 --- a/bitchatTests/Services/VerificationServiceTests.swift +++ b/bitchatTests/Services/VerificationServiceTests.swift @@ -39,6 +39,19 @@ final class VerificationServiceTests: XCTestCase { XCTAssertNil(service.verifyScannedQR(qrString, maxAge: 60)) } + func test_verifyScannedQR_rejectsFutureDatedPayload() throws { + let (service, noise) = makeService() + let futureTimestamp = Int64(Date().addingTimeInterval(3600).timeIntervalSince1970) + let qrString = try makeSignedQR( + noise: noise, + nickname: "future-\(UUID().uuidString)", + npub: nil, + ts: futureTimestamp + ) + + XCTAssertNil(service.verifyScannedQR(qrString, maxAge: 60)) + } + func test_verifyScannedQR_rejectsTamperedSignature() throws { let (service, noise) = makeService() let badSignature = Data(repeating: 0xAA, count: 64) From f0249d9cd71d0f206f65430ad2707bfa4d5be70a Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Sun, 9 Aug 2026 11:39:03 +0200 Subject: [PATCH 04/11] Make the Periphery scan blocking (#1648) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The job shipped with its own promotion condition — "drop continue-on-error once the baseline proves stable" — and the baseline has now been stable across a month of merges since it landed July 8 (#1410). Known macOS-scan false positives stay suppressed by the committed baseline; --strict already fails the step on NEW dead code, so the only change is that the failure now blocks instead of annotating. Co-authored-by: jack Co-authored-by: Claude Fable 5 --- .github/workflows/periphery.yml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/periphery.yml b/.github/workflows/periphery.yml index e33fc7c2..b5367338 100644 --- a/.github/workflows/periphery.yml +++ b/.github/workflows/periphery.yml @@ -11,9 +11,11 @@ jobs: name: Periphery scan runs-on: macos-latest timeout-minutes: 30 - # Advisory, like SwiftLint (#1361): findings annotate the PR but don't - # block merges. Drop continue-on-error once the baseline proves stable. - continue-on-error: true + # Blocking, like SwiftLint since #1646. The baseline (committed July 8) + # has been stable across a month of merges — the "drop continue-on-error + # once the baseline proves stable" condition this job shipped with is met. + # Known macOS-scan false positives stay suppressed by the baseline; only + # NEW dead code fails the job (--strict below). steps: - name: Checkout code From 6ef39451798ac93b0306882a23444a242e8d968f Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Mon, 10 Aug 2026 07:56:12 +0200 Subject: [PATCH 05/11] Deflake gift-wrap tests: settle deadlines, not latency budgets (#1651) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Deflake gift-wrap tests: settle deadlines, not latency budgets The gift-wrap round-trip tests failed three CI runs this week (handleGiftWrap_privateMessageStoresConversationAndMapping, handleGiftWrap_deliveredAckUpdatesExistingMessage, handleGiftWrap_routesEmbeddedPrivateMessageAndDeduplicates), each with the same signature: the async NIP-17 unwrap missed a 5s wait on a loaded runner. 40 local iterations of both suites pass clean — the failures are scheduler starvation, exactly the class TestConstants.settleTimeout documents. Every positive wait in ChatViewModelExtensionsTests and ChatNostrCoordinatorContextTests now uses settleTimeout (30s): the explicit 5.0s literals on the gift-wrap waits, the longTimeout media waits, and the bare-default channel-switch waits. All are expected-true waits, so passing runs return immediately and never pay the deadline. Co-Authored-By: Claude Fable 5 * Deflake SimulatedMesh TTL budget: settle discovery before the baseline publicMessageRelaysAcrossLineTopologyWithinTTLBudget snapshotted its frame baseline after a single 2s advance, but discovery is not quiet by then: every first-seen peer schedules an afterglow re-announce at a random 0.3-0.6s delay (BLEAnnounceHandler), and each of those can cascade another relay round. Whether that traffic lands before or after the snapshot depends on the draw — CI measured the "single message" at 14 and 18 frames against a budget of 12. The test now advances until the mesh goes a full window with no new frames before taking the baseline, so the budget only ever measures the message under test. 30 local iterations green. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: jack Co-authored-by: Claude Fable 5 --- .../ChatNostrCoordinatorContextTests.swift | 4 +-- .../ChatViewModelExtensionsTests.swift | 34 +++++++++---------- .../Simulation/SimulatedMeshTests.swift | 16 ++++++++- 3 files changed, 34 insertions(+), 20 deletions(-) diff --git a/bitchatTests/ChatNostrCoordinatorContextTests.swift b/bitchatTests/ChatNostrCoordinatorContextTests.swift index 80d249c4..6200d4bf 100644 --- a/bitchatTests/ChatNostrCoordinatorContextTests.swift +++ b/bitchatTests/ChatNostrCoordinatorContextTests.swift @@ -356,7 +356,7 @@ struct ChatNostrCoordinatorContextTests { // The NIP-17 unwrap runs off the main actor; wait for the hop back. let convKey = PeerID(nostr_: sender.publicKeyHex) - let routed = await TestHelpers.waitUntil({ context.handledPrivateMessages.count == 1 }) + let routed = await TestHelpers.waitUntil({ context.handledPrivateMessages.count == 1 }, timeout: TestConstants.settleTimeout) #expect(routed) #expect(context.recordedNostrEventIDs == [giftWrap.id]) #expect(context.nostrKeyMapping[convKey] == sender.publicKeyHex) @@ -412,7 +412,7 @@ struct ChatNostrCoordinatorContextTests { // The pipeline itself stays usable: a gift wrap spawned AFTER the // wipe (new generation) still decrypts and delivers. coordinator.inbound.handleGiftWrap(giftWrap, id: recipient) - let delivered = await TestHelpers.waitUntil({ context.handledPrivateMessages.count == 1 }) + let delivered = await TestHelpers.waitUntil({ context.handledPrivateMessages.count == 1 }, timeout: TestConstants.settleTimeout) #expect(delivered) } diff --git a/bitchatTests/ChatViewModelExtensionsTests.swift b/bitchatTests/ChatViewModelExtensionsTests.swift index a1d12550..6f9609ee 100644 --- a/bitchatTests/ChatViewModelExtensionsTests.swift +++ b/bitchatTests/ChatViewModelExtensionsTests.swift @@ -277,11 +277,11 @@ struct ChatViewModelNostrExtensionTests { LocationChannelManager.shared.select(channel) defer { LocationChannelManager.shared.select(.mesh) } - _ = await TestHelpers.waitUntil({ LocationChannelManager.shared.selectedChannel == channel }) + _ = await TestHelpers.waitUntil({ LocationChannelManager.shared.selectedChannel == channel }, timeout: TestConstants.settleTimeout) let (viewModel, _) = makeTestableViewModel() - _ = await TestHelpers.waitUntil({ viewModel.activeChannel == channel }) + _ = await TestHelpers.waitUntil({ viewModel.activeChannel == channel }, timeout: TestConstants.settleTimeout) let signer = try NostrIdentity.generate() let event = NostrEvent( @@ -312,7 +312,7 @@ struct ChatViewModelNostrExtensionTests { viewModel.handleNostrEvent(signed) } return false - }, timeout: TestConstants.longTimeout) + }, timeout: TestConstants.settleTimeout) #expect(didAppend) } @@ -463,7 +463,7 @@ struct ChatViewModelNostrExtensionTests { let didUpdate = await TestHelpers.waitUntil( { isDelivered(status: deliveryStatus(in: viewModel, peerID: convKey, messageID: messageID)) }, - timeout: 5.0 + timeout: TestConstants.settleTimeout ) #expect(didUpdate) } @@ -501,7 +501,7 @@ struct ChatViewModelNostrExtensionTests { let didUpdate = await TestHelpers.waitUntil( { isRead(status: deliveryStatus(in: viewModel, peerID: convKey, messageID: messageID)) }, - timeout: 5.0 + timeout: TestConstants.settleTimeout ) #expect(didUpdate) } @@ -529,7 +529,7 @@ struct ChatViewModelNostrExtensionTests { let didStore = await TestHelpers.waitUntil( { viewModel.privateChats[convKey]?.first?.content == "Hello from gift wrap" }, - timeout: 5.0 + timeout: TestConstants.settleTimeout ) #expect(didStore) #expect(viewModel.nostrKeyMapping[convKey] == sender.publicKeyHex) @@ -563,7 +563,7 @@ struct ChatViewModelNostrExtensionTests { // (sent even for blocked senders) to know processing finished. let didAck = await TestHelpers.waitUntil( { viewModel.sentGeoDeliveryAcks.contains(messageID) }, - timeout: 5.0 + timeout: TestConstants.settleTimeout ) #expect(didAck) #expect(viewModel.privateChats[convKey] == nil) @@ -602,7 +602,7 @@ struct ChatViewModelNostrExtensionTests { let didUpdate = await TestHelpers.waitUntil( { isDelivered(status: deliveryStatus(in: viewModel, peerID: convKey, messageID: messageID)) }, - timeout: 5.0 + timeout: TestConstants.settleTimeout ) #expect(didUpdate) } @@ -1022,10 +1022,10 @@ struct ChatViewModelMediaTransferTests { viewModel.sendVoiceNote(at: url) // Media sends hop through Task.detached; the global executor is - // shared with every parallel test worker, so a loaded runner can - // exceed the 5s default. waitUntil returns as soon as the condition - // holds, so passing runs never pay the longer timeout. - let didSend = await TestHelpers.waitUntil({ transport.sentPrivateFiles.count == 1 }, timeout: TestConstants.longTimeout) + // shared with every parallel test worker, so a loaded runner can be + // starved for seconds. waitUntil returns as soon as the condition + // holds, so passing runs never pay the settle deadline. + let didSend = await TestHelpers.waitUntil({ transport.sentPrivateFiles.count == 1 }, timeout: TestConstants.settleTimeout) #expect(didSend) #expect(transport.sentPrivateFiles.first?.peerID == peerID) #expect(viewModel.privateChats[peerID]?.last?.content.contains("[voice]") == true) @@ -1056,7 +1056,7 @@ struct ChatViewModelMediaTransferTests { viewModel.resolveLegacyPrivateMediaConsent(requestID: firstRequestID, approved: true) let showedSecond = await TestHelpers.waitUntil( { viewModel.legacyPrivateMediaConsentRequest?.peerID == secondPeer }, - timeout: TestConstants.longTimeout + timeout: TestConstants.settleTimeout ) #expect(showedSecond) let secondRequestID = try #require(viewModel.legacyPrivateMediaConsentRequest?.id) @@ -1098,7 +1098,7 @@ struct ChatViewModelMediaTransferTests { ) let advanced = await TestHelpers.waitUntil( { viewModel.legacyPrivateMediaConsentRequest?.peerID == secondPeer }, - timeout: TestConstants.longTimeout + timeout: TestConstants.settleTimeout ) #expect(advanced) #expect(decisions.isEmpty, "Invalidation drops the request rather than resolving its send") @@ -1128,7 +1128,7 @@ struct ChatViewModelMediaTransferTests { let didFail = await TestHelpers.waitUntil({ isFailed(status: viewModel.privateChats[peerID]?.last?.deliveryStatus) - }, timeout: TestConstants.longTimeout) + }, timeout: TestConstants.settleTimeout) #expect(didFail) #expect(!FileManager.default.fileExists(atPath: url.path)) #expect(transport.sentPrivateFiles.isEmpty) @@ -1144,7 +1144,7 @@ struct ChatViewModelMediaTransferTests { viewModel.selectedPrivateChatPeer = peerID viewModel.sendImage(from: sourceURL) - let didSend = await TestHelpers.waitUntil({ transport.sentPrivateFiles.count == 1 }, timeout: TestConstants.longTimeout) + let didSend = await TestHelpers.waitUntil({ transport.sentPrivateFiles.count == 1 }, timeout: TestConstants.settleTimeout) #expect(didSend) #expect(transport.sentPrivateFiles.first?.peerID == peerID) #expect(transport.sentPrivateFiles.first?.packet.mimeType == "image/jpeg") @@ -1165,7 +1165,7 @@ struct ChatViewModelMediaTransferTests { let didNotify = await TestHelpers.waitUntil({ viewModel.messages.contains(where: { $0.sender == "system" && $0.content.contains("Failed to prepare image") }) - }, timeout: TestConstants.longTimeout) + }, timeout: TestConstants.settleTimeout) #expect(didNotify) #expect(transport.sentPrivateFiles.isEmpty) #expect(viewModel.privateChats[peerID]?.isEmpty != false) diff --git a/bitchatTests/Simulation/SimulatedMeshTests.swift b/bitchatTests/Simulation/SimulatedMeshTests.swift index c03ed21f..b4630076 100644 --- a/bitchatTests/Simulation/SimulatedMeshTests.swift +++ b/bitchatTests/Simulation/SimulatedMeshTests.swift @@ -56,7 +56,21 @@ struct SimulatedMeshTests { mesh.connect(1, 2) mesh.announceAll() - mesh.advanceTime(by: 2) + // Discovery is not quiet after one advance: every first-seen peer + // schedules an afterglow re-announce at a RANDOM 0.3–0.6s delay + // (BLEAnnounceHandler), and each of those can cascade another relay + // round. Whether that traffic lands before or after a one-shot + // baseline snapshot depends on the draw — the budget assertion below + // flaked on CI at 14 and 18 frames for exactly that reason. Advance + // until the mesh goes a full window with no new frames, so the + // baseline only ever measures the message under test. + var settled = mesh.deliveredFrameCount + for _ in 0..<20 { + mesh.advanceTime(by: 2) + let now = mesh.deliveredFrameCount + if now == settled { break } + settled = now + } let baseline = mesh.deliveredFrameCount let capture = TransportEventCapture() From 4122a4dacde9e94348325aa4b12c48ccf33d290c Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Mon, 10 Aug 2026 07:56:33 +0200 Subject: [PATCH 06/11] Stop claiming encryption and privacy properties that aren't live (#1595) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Stop claiming encryption and privacy properties that aren't live Two of the app's most safety-critical signals could show more security than actually existed: 1. getEncryptionStatus was sticky-optimistic: any peer whose fingerprint was ever persisted resolved to noiseSecured/noiseVerified even when the live Noise session was .none, .handshaking, or .failed. After a cold launch or a handshake failure the DM header showed a solid lock and the composer caption claimed "end-to-end encrypted" with no secure session in existence. The status now reflects only the live session; a remembered fingerprint decides what an established session upgrades to (verified vs secured), nothing more. Peers reachable only over Nostr keep the encrypted caption — gift-wrapped delivery is end-to-end without a Noise session. 2. Privacy copy told three lies (all 30 locales fixed): - "ephemeral identity — new peer ID generated regularly": the peer ID is stable until a panic wipe (rotation is spec-only). Now "local-only identity — your identity is a keypair created on this device; panic wipe replaces it instantly." - "no servers, accounts, or data collection": location channels and internet delivery go through Nostr relays. Now "no accounts, no phone numbers, no data collection." - location channels: "your IP address is hidden by routing all traffic over tor" — tor is a toggle. The description now says tor is the default, and the sheet shows the existing red tor-off warning at the point of joining whenever the toggle is off. Co-Authored-By: Claude Fable 5 * Review fixes: gate nostr availability on a stored recipient key - Codex P2 / review: a mutual favorite with a nil peerNostrPublicKey still reported .nostrAvailable, so the DM caption could claim "end-to-end encrypted" with neither a Noise session nor a usable Nostr key. BitchatPeer.connectionState (and the header fallback in PrivateConversationModel) now require a stored recipient key — mirroring NostrTransport's reachability rule. Regression test: mutual + nil key → .offline; key present → .nostrAvailable. - Copy: "local-only identity" → "device-local identity" (all 30 locales) — once a fingerprint or QR is shared it isn't "only" local; device-local says where the keys live without over-claiming. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: jack Co-authored-by: Claude Fable 5 --- bitchat/App/PrivateConversationModels.swift | 5 +- bitchat/Localizable.xcstrings | 240 +++++++++--------- bitchat/Models/BitchatPeer.swift | 13 +- .../ChatPeerIdentityCoordinator.swift | 14 +- bitchat/Views/ContentSheetViews.swift | 7 +- bitchat/Views/LocationChannelsSheet.swift | 13 + bitchatTests/BitchatPeerTests.swift | 24 ++ ...tPeerIdentityCoordinatorContextTests.swift | 31 +++ 8 files changed, 218 insertions(+), 129 deletions(-) diff --git a/bitchat/App/PrivateConversationModels.swift b/bitchat/App/PrivateConversationModels.swift index a4e89149..80d5cf29 100644 --- a/bitchat/App/PrivateConversationModels.swift +++ b/bitchat/App/PrivateConversationModels.swift @@ -357,7 +357,10 @@ final class PrivateConversationModel: ObservableObject { } if let noiseKey = Data(hexString: headerPeerID.id), let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey), - favoriteStatus.isMutual { + favoriteStatus.isMutual, + // No stored recipient key means no Nostr delivery — and the + // "end-to-end encrypted" caption keys off .nostrAvailable. + favoriteStatus.peerNostrPublicKey != nil { return .nostrAvailable } if chatViewModel.meshService.isPeerConnected(headerPeerID) || chatViewModel.connectedPeers.contains(headerPeerID) { diff --git a/bitchat/Localizable.xcstrings b/bitchat/Localizable.xcstrings index f869ab85..b05370a7 100644 --- a/bitchat/Localizable.xcstrings +++ b/bitchat/Localizable.xcstrings @@ -11375,181 +11375,181 @@ "ar" : { "stringUnit" : { "state" : "translated", - "value" : "يُولد معرف قرين جديد بانتظام" + "value" : "هويتك زوج مفاتيح يُنشأ على هذا الجهاز — المسح الطارئ يستبدلها فورًا" } }, "bn" : { "stringUnit" : { "state" : "translated", - "value" : "নিয়মিত নতুন পিয়ার আইডি তৈরি হয়" + "value" : "আপনার পরিচয় এই ডিভাইসে তৈরি একটি কী-জোড়া — প্যানিক ওয়াইপ সঙ্গে সঙ্গে এটি বদলে দেয়" } }, "de" : { "stringUnit" : { "state" : "translated", - "value" : "neue peer-id wird regelmäßig erzeugt" + "value" : "deine identität ist ein schlüsselpaar, das auf diesem gerät erzeugt wird — der panik-löscher ersetzt es sofort" } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "new peer ID generated regularly" + "value" : "your identity is a keypair created on this device — panic wipe replaces it instantly" } }, "es" : { "stringUnit" : { "state" : "translated", - "value" : "nuevo ID de peer generado periódicamente" + "value" : "tu identidad es un par de claves creado en este dispositivo — el borrado de pánico la reemplaza al instante" } }, "fa" : { "stringUnit" : { "state" : "translated", - "value" : "شناسهٔ همتای جدید به‌طور منظم ساخته می‌شود" + "value" : "هویت شما یک جفت کلید است که روی این دستگاه ساخته می‌شود — پاک‌سازی اضطراری آن را فوراً جایگزین می‌کند" } }, "fil" : { "stringUnit" : { "state" : "translated", - "value" : "regular na lumilikha ng bagong peer ID" + "value" : "ang iyong pagkakakilanlan ay isang key pair na ginawa sa device na ito — agad itong pinapalitan ng panic wipe" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "nouvel id de pair généré régulièrement" + "value" : "votre identité est une paire de clés créée sur cet appareil — l'effacement d'urgence la remplace instantanément" } }, "he" : { "stringUnit" : { "state" : "translated", - "value" : "מזהה עמית חדש נוצר באופן קבוע" + "value" : "הזהות שלך היא צמד מפתחות שנוצר במכשיר הזה — מחיקת חירום מחליפה אותה מיד" } }, "hi" : { "stringUnit" : { "state" : "translated", - "value" : "नया पीयर आईडी नियमित रूप से बनाया जाता है" + "value" : "आपकी पहचान इस डिवाइस पर बनी एक कुंजी-जोड़ी है — पैनिक वाइप इसे तुरंत बदल देता है" } }, "id" : { "stringUnit" : { "state" : "translated", - "value" : "id peer baru dibuat secara berkala" + "value" : "identitas anda adalah pasangan kunci yang dibuat di perangkat ini — panic wipe langsung menggantinya" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "nuovo id peer generato regolarmente" + "value" : "la tua identità è una coppia di chiavi creata su questo dispositivo — la cancellazione di emergenza la sostituisce all'istante" } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "新しいpeer idが定期的に生成されます" + "value" : "アイデンティティはこの端末上で生成される鍵ペアです — パニックワイプで即座に置き換えられます" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "새로운 피어 ID가 주기적으로 생성됩니다" + "value" : "신원은 이 기기에서 생성되는 키 쌍입니다 — 패닉 와이프로 즉시 교체됩니다" } }, "ms" : { "stringUnit" : { "state" : "translated", - "value" : "id peer baru dibuat secara berkala" + "value" : "identiti anda ialah pasangan kunci yang dicipta pada peranti ini — panic wipe menggantikannya serta-merta" } }, "ne" : { "stringUnit" : { "state" : "translated", - "value" : "नयाँ peer id नियमित रूपमा सिर्जना हुन्छ" + "value" : "तपाईंको परिचय यही यन्त्रमा बनेको कुञ्जी-जोडी हो — प्यानिक वाइपले तुरुन्तै बदल्छ" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "nieuw peer-ID wordt regelmatig aangemaakt" + "value" : "je identiteit is een sleutelpaar dat op dit apparaat wordt gemaakt — de paniekwis vervangt het direct" } }, "pl" : { "stringUnit" : { "state" : "translated", - "value" : "nowe ID peera generowane regularnie" + "value" : "twoja tożsamość to para kluczy utworzona na tym urządzeniu — wymazanie awaryjne natychmiast ją zastępuje" } }, "pt" : { "stringUnit" : { "state" : "translated", - "value" : "novo ID de par gerado regularmente" + "value" : "a tua identidade é um par de chaves criado neste dispositivo — a limpeza de pânico substitui-a de imediato" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "novo id de peer gerado regularmente" + "value" : "sua identidade é um par de chaves criado neste dispositivo — a limpeza de pânico a substitui na hora" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "новый id пира создаётся регулярно" + "value" : "ваша идентичность — пара ключей, созданная на этом устройстве. экстренное стирание мгновенно её заменяет" } }, "sv" : { "stringUnit" : { "state" : "translated", - "value" : "nytt peer-ID skapas regelbundet" + "value" : "din identitet är ett nyckelpar som skapas på den här enheten — panikradering ersätter det direkt" } }, "ta" : { "stringUnit" : { "state" : "translated", - "value" : "புதிய peer ID வழக்கமாக உருவாக்கப்படுகிறது" + "value" : "உங்கள் அடையாளம் இந்தச் சாதனத்தில் உருவாக்கப்படும் விசை-இணை — பேனிக் அழிப்பு உடனே அதை மாற்றும்" } }, "th" : { "stringUnit" : { "state" : "translated", - "value" : "สร้างรหัสเพียร์ใหม่เป็นระยะ" + "value" : "ตัวตนของคุณคือคู่กุญแจที่สร้างบนเครื่องนี้ — panic wipe จะแทนที่ทันที" } }, "tr" : { "stringUnit" : { "state" : "translated", - "value" : "yeni eş kimliği düzenli olarak oluşturulur" + "value" : "kimliğiniz bu cihazda oluşturulan bir anahtar çiftidir — panik silme onu anında değiştirir" } }, "uk" : { "stringUnit" : { "state" : "translated", - "value" : "новий id піра генерується регулярно" + "value" : "ваша ідентичність — пара ключів, створена на цьому пристрої. екстрене стирання миттєво її замінює" } }, "ur" : { "stringUnit" : { "state" : "translated", - "value" : "نیا peer ID باقاعدگی سے بنایا جاتا ہے" + "value" : "آپ کی شناخت اس ڈیوائس پر بنایا گیا کلیدی جوڑا ہے — پینک وائپ اسے فوراً بدل دیتا ہے" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "tạo ID nút mới định kỳ" + "value" : "danh tính của bạn là một cặp khoá được tạo trên thiết bị này — panic wipe thay thế nó ngay lập tức" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "定期生成新的 peer id" + "value" : "你的身份是在本机生成的密钥对 — 紧急清除会立即更换它" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "定期生成新的 peer id" + "value" : "你的身分是在本機產生的金鑰對 — 緊急清除會立即更換它" } } } @@ -11560,181 +11560,181 @@ "ar" : { "stringUnit" : { "state" : "translated", - "value" : "هوية مؤقتة" + "value" : "هوية محفوظة على الجهاز" } }, "bn" : { "stringUnit" : { "state" : "translated", - "value" : "অস্থায়ী পরিচয়" + "value" : "ডিভাইসে থাকা পরিচয়" } }, "de" : { "stringUnit" : { "state" : "translated", - "value" : "flüchtige identität" + "value" : "gerätelokale identität" } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "ephemeral identity" + "value" : "device-local identity" } }, "es" : { "stringUnit" : { "state" : "translated", - "value" : "identidad efímera" + "value" : "identidad local del dispositivo" } }, "fa" : { "stringUnit" : { "state" : "translated", - "value" : "هویت موقت" + "value" : "هویت محلی دستگاه" } }, "fil" : { "stringUnit" : { "state" : "translated", - "value" : "panandaliang pagkakakilanlan" + "value" : "pagkakakilanlang nasa device" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "identité éphémère" + "value" : "identité locale à l'appareil" } }, "he" : { "stringUnit" : { "state" : "translated", - "value" : "זהות זמנית" + "value" : "זהות מקומית במכשיר" } }, "hi" : { "stringUnit" : { "state" : "translated", - "value" : "अस्थायी पहचान" + "value" : "डिवाइस-स्थानीय पहचान" } }, "id" : { "stringUnit" : { "state" : "translated", - "value" : "identitas sementara" + "value" : "identitas lokal perangkat" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "identità effimera" + "value" : "identità locale al dispositivo" } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "一時的なアイデンティティ" + "value" : "端末ローカルのアイデンティティ" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "임시 신원" + "value" : "기기 로컬 신원" } }, "ms" : { "stringUnit" : { "state" : "translated", - "value" : "identitas sementara" + "value" : "identiti setempat peranti" } }, "ne" : { "stringUnit" : { "state" : "translated", - "value" : "क्षणिक पहिचान" + "value" : "यन्त्र-स्थानीय परिचय" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "tijdelijke identiteit" + "value" : "apparaat-lokale identiteit" } }, "pl" : { "stringUnit" : { "state" : "translated", - "value" : "tymczasowa tożsamość" + "value" : "tożsamość lokalna urządzenia" } }, "pt" : { "stringUnit" : { "state" : "translated", - "value" : "identidade efémera" + "value" : "identidade local do dispositivo" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "identidade efêmera" + "value" : "identidade local do dispositivo" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "эфемерная личность" + "value" : "идентичность на устройстве" } }, "sv" : { "stringUnit" : { "state" : "translated", - "value" : "tillfällig identitet" + "value" : "enhetslokal identitet" } }, "ta" : { "stringUnit" : { "state" : "translated", - "value" : "தற்காலிக அடையாளம்" + "value" : "சாதன-உள்ளூர் அடையாளம்" } }, "th" : { "stringUnit" : { "state" : "translated", - "value" : "ตัวตนชั่วคราว" + "value" : "ตัวตนในเครื่อง" } }, "tr" : { "stringUnit" : { "state" : "translated", - "value" : "geçici kimlik" + "value" : "cihaza yerel kimlik" } }, "uk" : { "stringUnit" : { "state" : "translated", - "value" : "ефемерна ідентичність" + "value" : "ідентичність на пристрої" } }, "ur" : { "stringUnit" : { "state" : "translated", - "value" : "عارضی شناخت" + "value" : "ڈیوائس مقامی شناخت" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "danh tính tạm thời" + "value" : "danh tính cục bộ trên thiết bị" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "临时身份" + "value" : "设备本地身份" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "臨時身份" + "value" : "裝置本機身分" } } } @@ -11745,181 +11745,181 @@ "ar" : { "stringUnit" : { "state" : "translated", - "value" : "لا خوادم أو حسابات أو جمع بيانات" + "value" : "لا حسابات، لا أرقام هواتف، لا جمع بيانات" } }, "bn" : { "stringUnit" : { "state" : "translated", - "value" : "কোনো সার্ভার, অ্যাকাউন্ট বা ডেটা সংগ্রহ নেই" + "value" : "কোনো অ্যাকাউন্ট নেই, ফোন নম্বর নেই, ডেটা সংগ্রহ নেই" } }, "de" : { "stringUnit" : { "state" : "translated", - "value" : "keine server, konten oder datensammlung" + "value" : "keine konten, keine telefonnummern, keine datensammlung" } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "no servers, accounts, or data collection" + "value" : "no accounts, no phone numbers, no data collection" } }, "es" : { "stringUnit" : { "state" : "translated", - "value" : "sin servidores, cuentas ni recopilación de datos" + "value" : "sin cuentas, sin números de teléfono, sin recopilación de datos" } }, "fa" : { "stringUnit" : { "state" : "translated", - "value" : "بدون سرور، حساب کاربری یا جمع‌آوری داده" + "value" : "بدون حساب، بدون شماره تلفن، بدون جمع‌آوری داده" } }, "fil" : { "stringUnit" : { "state" : "translated", - "value" : "walang mga server, account, o pagkuha ng data" + "value" : "walang account, walang numero ng telepono, walang pangongolekta ng data" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "sans serveurs, comptes ni collecte de données" + "value" : "pas de comptes, pas de numéros de téléphone, pas de collecte de données" } }, "he" : { "stringUnit" : { "state" : "translated", - "value" : "אין שרתים, חשבונות או איסוף נתונים" + "value" : "בלי חשבונות, בלי מספרי טלפון, בלי איסוף נתונים" } }, "hi" : { "stringUnit" : { "state" : "translated", - "value" : "कोई सर्वर, खाते या डेटा संग्रह नहीं" + "value" : "कोई खाता नहीं, कोई फ़ोन नंबर नहीं, कोई डेटा संग्रह नहीं" } }, "id" : { "stringUnit" : { "state" : "translated", - "value" : "tanpa server, akun, atau pengumpulan data" + "value" : "tanpa akun, tanpa nomor telepon, tanpa pengumpulan data" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "niente server, account o raccolta dati" + "value" : "niente account, niente numeri di telefono, nessuna raccolta dati" } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "サーバーもアカウントもデータ収集もなし" + "value" : "アカウントなし・電話番号なし・データ収集なし" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "서버와 계정이 없으며 데이터를 수집하지 않습니다" + "value" : "계정 없음, 전화번호 없음, 데이터 수집 없음" } }, "ms" : { "stringUnit" : { "state" : "translated", - "value" : "tanpa server, akun, atau pengumpulan data" + "value" : "tiada akaun, tiada nombor telefon, tiada pengumpulan data" } }, "ne" : { "stringUnit" : { "state" : "translated", - "value" : "सर्भर, खाताहरू वा तथ्याङ्क सङ्कलन छैन" + "value" : "कुनै खाता छैन, फोन नम्बर छैन, डेटा सङ्कलन छैन" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "geen servers, accounts of dataverzameling" + "value" : "geen accounts, geen telefoonnummers, geen dataverzameling" } }, "pl" : { "stringUnit" : { "state" : "translated", - "value" : "bez serwerów, kont ani zbierania danych" + "value" : "bez kont, bez numerów telefonów, bez zbierania danych" } }, "pt" : { "stringUnit" : { "state" : "translated", - "value" : "sem servidores, contas ou recolha de dados" + "value" : "sem contas, sem números de telefone, sem recolha de dados" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "sem servidores, contas ou coleta de dados" + "value" : "sem contas, sem números de telefone, sem coleta de dados" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "без серверов, аккаунтов и сбора данных" + "value" : "без аккаунтов, без номеров телефона, без сбора данных" } }, "sv" : { "stringUnit" : { "state" : "translated", - "value" : "inga servrar, konton eller datainsamling" + "value" : "inga konton, inga telefonnummer, ingen datainsamling" } }, "ta" : { "stringUnit" : { "state" : "translated", - "value" : "சர்வர்கள் இல்லை, கணக்குகள் இல்லை, தரவுச் சேகரிப்பு இல்லை" + "value" : "கணக்குகள் இல்லை, தொலைபேசி எண்கள் இல்லை, தரவு சேகரிப்பு இல்லை" } }, "th" : { "stringUnit" : { "state" : "translated", - "value" : "ไม่มีเซิร์ฟเวอร์ บัญชี หรือการเก็บข้อมูล" + "value" : "ไม่มีบัญชี ไม่มีเบอร์โทรศัพท์ ไม่มีการเก็บข้อมูล" } }, "tr" : { "stringUnit" : { "state" : "translated", - "value" : "sunucu, hesap veya veri toplama yok" + "value" : "hesap yok, telefon numarası yok, veri toplama yok" } }, "uk" : { "stringUnit" : { "state" : "translated", - "value" : "жодних серверів, обліковок чи збору даних" + "value" : "без облікових записів, без номерів телефону, без збору даних" } }, "ur" : { "stringUnit" : { "state" : "translated", - "value" : "کوئی سرور، اکاؤنٹس یا ڈیٹا جمع نہیں کیا جاتا" + "value" : "نہ اکاؤنٹ، نہ فون نمبر، نہ ڈیٹا اکٹھا کرنا" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "không máy chủ, không tài khoản, không thu thập dữ liệu" + "value" : "không tài khoản, không số điện thoại, không thu thập dữ liệu" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "无服务器、无账号、无数据收集" + "value" : "无账号、无电话号码、无数据收集" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "無伺服器、無帳號、無數據收集" + "value" : "無帳號、無電話號碼、無資料收集" } } } @@ -52662,181 +52662,181 @@ "ar" : { "stringUnit" : { "state" : "translated", - "value" : "تحدث مع أشخاص قريبين عبر قنوات geohash. نشارك geohash تقريبي فقط، وليس gps الدقيق. يتم إخفاء عنوان ip لأن كل المرور يمر عبر tor." + "value" : "دردش مع من حولك عبر قنوات geohash. الـ geohash المحدد علني وقد يكشف منطقة تقريبية؛ لا تتم مشاركة إحداثيات GPS الدقيقة أبدًا. مع تشغيل tor (الوضع الافتراضي)، ترى المرحّلات عنوان tor بدلاً من عنوان IP الخاص بك." } }, "bn" : { "stringUnit" : { "state" : "translated", - "value" : "জিওহ্যাশ চ্যানেল দিয়ে কাছাকাছি অঞ্চলের মানুষের সঙ্গে চ্যাট করুন। শুধুই স্থূল জিওহ্যাশ শেয়ার হয়, কখনো সঠিক GPS নয়। আপনার IP টর দিয়ে সব ট্রাফিক রাউট করে লুকানো থাকে।" + "value" : "geohash চ্যানেল ব্যবহার করে কাছের মানুষদের সঙ্গে চ্যাট করুন। নির্বাচিত geohash সর্বজনীন এবং আনুমানিক এলাকা প্রকাশ করতে পারে; সঠিক GPS কখনও শেয়ার হয় না। tor চালু থাকলে (ডিফল্ট), রিলেগুলো আপনার IP-এর বদলে tor-এর ঠিকানা দেখে।" } }, "de" : { "stringUnit" : { "state" : "translated", - "value" : "chatte mit menschen in deiner nähe über geohash-kanäle. geteilt wird nur ein grober geohash, niemals exakte gps-daten. deine ip bleibt verborgen, weil der gesamte verkehr über tor läuft." + "value" : "chatte mit leuten in deiner nähe über geohash-kanäle. der gewählte geohash ist öffentlich und kann ein ungefähres gebiet verraten; exaktes GPS wird nie geteilt. mit eingeschaltetem tor (standard) sehen relays tors adresse statt deiner IP-adresse." } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "chat with people near you using geohash channels. the selected geohash is public and may reveal an approximate area; exact GPS is never shared. your IP address is hidden by routing all traffic over tor." + "value" : "chat with people near you using geohash channels. the selected geohash is public and may reveal an approximate area; exact GPS is never shared. with tor on (the default), relays see tor's address instead of your IP address." } }, "es" : { "stringUnit" : { "state" : "translated", - "value" : "chatea con personas cercanas usando canales geohash. Solo se comparte un geohash aproximado, nunca GPS exacto. Tu IP se oculta al enrutar todo el tráfico por Tor." + "value" : "chatea con gente cercana usando canales geohash. el geohash seleccionado es público y puede revelar una zona aproximada; el GPS exacto nunca se comparte. con tor activado (el valor predeterminado), los relés ven la dirección de tor en lugar de tu IP." } }, "fa" : { "stringUnit" : { "state" : "translated", - "value" : "با کانال‌های ژئوهش با افراد نزدیک خود گفتگو کنید. ژئوهش انتخاب‌شده عمومی است و ممکن است منطقهٔ تقریبی شما را نشان دهد؛ GPS دقیق هرگز به اشتراک گذاشته نمی‌شود. با عبور همهٔ ترافیک از Tor، آدرس IP شما پنهان می‌ماند." + "value" : "با افراد نزدیک خود از طریق کانال‌های geohash گفتگو کنید. geohash انتخابی عمومی است و ممکن است منطقه‌ای تقریبی را نشان دهد؛ GPS دقیق هرگز به اشتراک گذاشته نمی‌شود. با روشن بودن tor (پیش‌فرض)، رله‌ها به جای IP شما آدرس tor را می‌بینند." } }, "fil" : { "stringUnit" : { "state" : "translated", - "value" : "makipag-chat sa mga taong malapit sa iyo gamit ang mga geohash channel. coarse na geohash lang ang ibinabahagi, hindi ang eksaktong GPS. tinatago ang iyong IP address sa pagreruta ng lahat ng traffic sa tor." + "value" : "makipag-chat sa mga taong malapit sa iyo gamit ang mga geohash channel. pampubliko ang napiling geohash at maaaring magbunyag ng tinatayang lugar; hindi kailanman ibinabahagi ang eksaktong GPS. kapag naka-on ang tor (ang default), ang address ng tor ang nakikita ng mga relay sa halip na ang iyong IP." } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "discute avec les personnes proches grâce aux canaux geohash. seul un geohash grossier est partagé, jamais de gps exact. ton ip reste cachée car tout le trafic passe par tor." + "value" : "discutez avec les gens autour de vous via les canaux geohash. le geohash choisi est public et peut révéler une zone approximative ; le GPS exact n'est jamais partagé. avec tor activé (par défaut), les relais voient l'adresse de tor au lieu de votre IP." } }, "he" : { "stringUnit" : { "state" : "translated", - "value" : "שוחח עם אנשים קרובים בערוצי geohash. משתף רק geohash גס, אף פעם לא gps מדויק. כתובת ה-ip מוסתרת כי כל התעבורה עוברת דרך tor." + "value" : "שוחחו עם אנשים בסביבה דרך ערוצי geohash. ה-geohash שנבחר הוא ציבורי ועשוי לחשוף אזור משוער; מיקום GPS מדויק לעולם לא משותף. כש-tor פועל (ברירת המחדל), הממסרים רואים את הכתובת של tor במקום ה-IP שלכם." } }, "hi" : { "stringUnit" : { "state" : "translated", - "value" : "जियोहैश चैनलों से आसपास के लोगों से चैट करें। केवल मोटा जियोहैश साझा होता है, कभी सटीक GPS नहीं। आपका IP सारा ट्रैफ़िक Tor से रूट कर छिपा रहता है।" + "value" : "geohash चैनलों के ज़रिए आस-पास के लोगों से चैट करें। चुना गया geohash सार्वजनिक है और अनुमानित क्षेत्र दिखा सकता है; सटीक GPS कभी साझा नहीं होता। tor चालू होने पर (डिफ़ॉल्ट), रिले आपके IP की जगह tor का पता देखते हैं।" } }, "id" : { "stringUnit" : { "state" : "translated", - "value" : "ngobrol dengan orang terdekat lewat kanal geohash. hanya geohash kasar yang dibagikan, tidak pernah gps tepat. alamat ip-mu tersembunyi karena seluruh trafik lewat tor." + "value" : "mengobrol dengan orang di sekitar melalui kanal geohash. geohash yang dipilih bersifat publik dan dapat mengungkap area perkiraan; GPS persis tidak pernah dibagikan. dengan tor aktif (bawaan), relay melihat alamat tor alih-alih IP anda." } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "chatta con le persone vicine tramite canali geohash. condividiamo solo geohash approssimativi, mai il gps esatto. il tuo ip resta nascosto perché tutto il traffico passa da tor." + "value" : "chatta con chi ti sta vicino usando i canali geohash. il geohash selezionato è pubblico e può rivelare un'area approssimativa; il GPS esatto non viene mai condiviso. con tor attivo (impostazione predefinita), i relay vedono l'indirizzo di tor invece del tuo IP." } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "geohashチャンネルで近くの人と会話。共有されるのはざっくりしたgeohashだけで正確なgpsは含みません。全トラフィックをtor経由にすることであなたのipを隠します。" + "value" : "geohash チャンネルで近くの人とチャットできます。選択した geohash は公開され、おおよそのエリアが分かる場合があります。正確な GPS は決して共有されません。tor が有効(デフォルト)なら、リレーにはあなたの IP ではなく tor のアドレスが見えます。" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "geohash 채널을 사용해 주변 사람들과 대화하세요. 정확한 GPS가 아닌 대략적인 geohash만 공유됩니다. 모든 트래픽을 tor로 라우팅하여 IP 주소를 숨깁니다." + "value" : "geohash 채널로 주변 사람들과 대화하세요. 선택한 geohash는 공개되며 대략적인 지역이 드러날 수 있습니다. 정확한 GPS는 절대 공유되지 않습니다. tor가 켜져 있으면(기본값) 릴레이에는 내 IP 대신 tor의 주소가 보입니다." } }, "ms" : { "stringUnit" : { "state" : "translated", - "value" : "ngobrol dengan orang terdekat lewat kanal geohash. hanya geohash kasar yang dibagikan, tidak pernah gps tepat. alamat ip-mu tersembunyi karena seluruh trafik lewat tor." + "value" : "berbual dengan orang berdekatan melalui saluran geohash. geohash yang dipilih adalah umum dan mungkin mendedahkan kawasan anggaran; GPS tepat tidak pernah dikongsi. dengan tor dihidupkan (lalai), relay melihat alamat tor dan bukan IP anda." } }, "ne" : { "stringUnit" : { "state" : "translated", - "value" : "geohash च्यानलबाट नजिकका मानिससँग कुरा गर। केवल मोटामो geohash साझा हुन्छ, कहिल्यै सहि gps होइन। सबै ट्राफिक tor मार्फत गएका कारण तिम्रो ip लुकेको हुन्छ।" + "value" : "geohash च्यानलहरू प्रयोग गरेर नजिकका मानिसहरूसँग कुराकानी गर्नुहोस्। चयन गरिएको geohash सार्वजनिक हुन्छ र अनुमानित क्षेत्र देखाउन सक्छ; सटीक GPS कहिल्यै साझा हुँदैन। tor खुला हुँदा (पूर्वनिर्धारित), रिलेहरूले तपाईंको IP को सट्टा tor को ठेगाना देख्छन्।" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "chat met mensen bij jou in de buurt via geohash-kanalen. enkel een grove geohash wordt gedeeld, nooit exacte GPS. je IP-adres wordt verborgen doordat al het verkeer via tor wordt gerouteerd." + "value" : "chat met mensen in de buurt via geohash-kanalen. de gekozen geohash is openbaar en kan een globaal gebied onthullen; exacte GPS wordt nooit gedeeld. met tor aan (standaard) zien relays het adres van tor in plaats van je IP-adres." } }, "pl" : { "stringUnit" : { "state" : "translated", - "value" : "rozmawiaj z osobami w pobliżu za pomocą kanałów geohash. udostępniany jest tylko zgrubny geohash, nigdy dokładny GPS. twój adres IP jest ukryty przez kierowanie całego ruchu przez tor." + "value" : "rozmawiaj z ludźmi w pobliżu przez kanały geohash. wybrany geohash jest publiczny i może zdradzić przybliżony obszar; dokładny GPS nigdy nie jest udostępniany. przy włączonym tor (domyślnie) przekaźniki widzą adres tora zamiast twojego IP." } }, "pt" : { "stringUnit" : { "state" : "translated", - "value" : "conversa com pessoas perto de ti usando canais geohash. apenas um geohash de baixa precisão é partilhado, nunca GPS exato. o teu IP fica oculto ao encaminhar todo o tráfego através do Tor." + "value" : "conversa com pessoas por perto através de canais geohash. o geohash selecionado é público e pode revelar uma área aproximada; o GPS exato nunca é partilhado. com o tor ligado (predefinição), os relays veem o endereço do tor em vez do teu IP." } }, "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "converse com pessoas próximas usando canais geohash. apenas um geohash grosseiro é compartilhado, nunca gps exato. seu ip fica oculto ao rotear todo o tráfego por tor." + "value" : "converse com pessoas por perto usando canais geohash. o geohash selecionado é público e pode revelar uma área aproximada; o GPS exato nunca é compartilhado. com o tor ligado (padrão), os relays veem o endereço do tor em vez do seu IP." } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "общайся с людьми рядом через каналы geohash. делится только грубый geohash, без точного gps. твой ip скрывается за счёт маршрутизации трафика через tor." + "value" : "общайтесь с людьми поблизости через geohash-каналы. выбранный geohash публичен и может раскрыть примерный район; точные GPS-координаты никогда не передаются. при включённом tor (по умолчанию) релеи видят адрес tor вместо вашего IP." } }, "sv" : { "stringUnit" : { "state" : "translated", - "value" : "chatta med folk nära dig via geohash-kanaler. endast en grov geohash delas, aldrig exakt GPS. din IP-adress döljs genom att all trafik går via tor." + "value" : "chatta med folk i närheten via geohash-kanaler. vald geohash är offentlig och kan avslöja ett ungefärligt område; exakt GPS delas aldrig. med tor på (standard) ser reläer tors adress istället för din IP." } }, "ta" : { "stringUnit" : { "state" : "translated", - "value" : "geohash சேனல்கள் மூலம் அருகிலுள்ளவர்களுடன் உரையாடுங்கள். மிகவும் பொது geohash மட்டுமே பகிரப்படும், துல்லியமான GPS அல்ல. உங்கள் IP, அனைத்து போக்குவரத்தையும் tor வழியாக மாற்றுவதன் மூலம் மறைக்கப்படுகிறது." + "value" : "geohash சேனல்கள் மூலம் அருகிலுள்ளவர்களுடன் அரட்டையடிக்கவும். தேர்ந்தெடுத்த geohash பொதுவானது, தோராயமான பகுதியை வெளிப்படுத்தலாம்; துல்லியமான GPS ஒருபோதும் பகிரப்படாது. tor இயக்கத்தில் இருந்தால் (இயல்புநிலை), ரிலேக்கள் உங்கள் IP க்குப் பதிலாக tor இன் முகவரியைக் காண்கின்றன." } }, "th" : { "stringUnit" : { "state" : "translated", - "value" : "แชทกับคนใกล้คุณผ่านช่อง geohash แบ่งปันเพียง geohash แบบหยาบ ไม่ใช่ GPS ที่แม่นยำ ที่อยู่ IP ของคุณถูกซ่อนด้วยการส่งข้อมูลผ่าน tor" + "value" : "แชทกับผู้คนใกล้ตัวผ่านช่อง geohash โดย geohash ที่เลือกเป็นแบบสาธารณะและอาจเผยพื้นที่โดยประมาณ พิกัด GPS ที่แน่นอนจะไม่ถูกแชร์ เมื่อเปิด tor (ค่าเริ่มต้น) รีเลย์จะเห็นที่อยู่ของ tor แทน IP ของคุณ" } }, "tr" : { "stringUnit" : { "state" : "translated", - "value" : "geohash kanallarıyla yakınınızdaki insanlarla sohbet edin. yalnızca kaba bir geohash paylaşılır, tam GPS asla değil. IP adresiniz tüm trafik Tor üzerinden yönlendirilerek gizlenir." + "value" : "geohash kanallarıyla yakınınızdaki insanlarla sohbet edin. seçilen geohash herkese açıktır ve yaklaşık bir bölgeyi açığa çıkarabilir; tam GPS asla paylaşılmaz. tor açıkken (varsayılan) röleler IP'niz yerine tor'un adresini görür." } }, "uk" : { "stringUnit" : { "state" : "translated", - "value" : "спілкуйся з людьми поруч у каналах geohash. передається лише грубий geohash, без точного gps. твій ip приховується, бо весь трафік йде через tor." + "value" : "спілкуйтеся з людьми поруч через geohash-канали. вибраний geohash є публічним і може розкрити приблизний район; точні GPS-координати ніколи не передаються. з увімкненим tor (типово) релеї бачать адресу tor замість вашого IP." } }, "ur" : { "stringUnit" : { "state" : "translated", - "value" : "قریب کے لوگوں سے گفتگو کیلئے geohash چینلز استعمال کریں۔ صرف عمومی geohash شیئر کیا جاتا ہے، کبھی درست GPS نہیں۔ آپ کا IP tor کے ذریعے ساری ٹریفک روٹ کر کے چھپایا جاتا ہے۔" + "value" : "geohash چینلز کے ذریعے قریبی لوگوں سے چیٹ کریں۔ منتخب geohash عوامی ہے اور تخمینی علاقہ ظاہر کر سکتا ہے؛ درست GPS کبھی شیئر نہیں ہوتا۔ tor آن ہونے پر (ڈیفالٹ)، ریلے آپ کے IP کی بجائے tor کا پتہ دیکھتے ہیں۔" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "trò chuyện với người gần bạn bằng các kênh geohash. chỉ chia sẻ geohash thô, không bao giờ là GPS chính xác. địa chỉ IP của bạn được ẩn bằng cách định tuyến toàn bộ lưu lượng qua tor." + "value" : "trò chuyện với những người gần bạn qua các kênh geohash. geohash đã chọn là công khai và có thể tiết lộ khu vực gần đúng; GPS chính xác không bao giờ được chia sẻ. khi bật tor (mặc định), các relay thấy địa chỉ của tor thay vì IP của bạn." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "使用 geohash 频道与附近的人聊天。只会共享粗略 geohash,从不泄露精确 GPS。所有流量通过 tor 路由来隐藏你的 IP。" + "value" : "通过 geohash 频道与附近的人聊天。所选 geohash 是公开的,可能暴露大致区域;精确 GPS 绝不会被共享。开启 tor 时(默认),中继看到的是 tor 的地址而不是你的 IP。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "使用 geohash 頻道與附近的人聊天。只會共享粗略 geohash,從不洩露精確 GPS。所有流量透過 tor 路由來隱藏你的 IP。" + "value" : "透過 geohash 頻道與附近的人聊天。所選 geohash 是公開的,可能透露大致區域;精確 GPS 絕不會被分享。開啟 tor 時(預設),中繼看到的是 tor 的位址而不是你的 IP。" } } } diff --git a/bitchat/Models/BitchatPeer.swift b/bitchat/Models/BitchatPeer.swift index 61c0a5f8..afa7c53d 100644 --- a/bitchat/Models/BitchatPeer.swift +++ b/bitchat/Models/BitchatPeer.swift @@ -33,13 +33,22 @@ struct BitchatPeer: Equatable { return .bluetoothConnected } else if isReachable { return .meshReachable - } else if favoriteStatus?.isMutual == true { - // Mutual favorites can communicate via Nostr when offline + } else if favoriteStatus?.isMutual == true, reachableNostrPublicKey != nil { + // Mutual favorites can communicate via Nostr when offline — but + // only with a stored recipient key. NostrTransport applies the + // same rule when computing reachable peers; without a key, + // "available" would be a lie, and the DM header's + // "end-to-end encrypted" caption keys off this state. return .nostrAvailable } else { return .offline } } + + /// The Nostr key a private envelope would actually be sealed to, if any. + var reachableNostrPublicKey: String? { + nostrPublicKey ?? favoriteStatus?.peerNostrPublicKey + } var isFavorite: Bool { favoriteStatus?.isFavorite ?? false diff --git a/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift b/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift index a2bd6297..e2d0fae8 100644 --- a/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift +++ b/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift @@ -440,7 +440,13 @@ final class ChatPeerIdentityCoordinator { return cachedStatus } - let hasEverEstablishedSession = getFingerprint(for: peerID) != nil + // The status must reflect the LIVE session, never history. The old + // mapping returned secured/verified for any peer whose fingerprint was + // ever persisted — so after a cold launch or a handshake FAILURE the + // DM header still showed a solid lock and the composer still claimed + // "end-to-end encrypted" with no secure session in existence. A + // remembered fingerprint changes what an established session upgrades + // to (verified vs secured); it must not conjure a lock on its own. let sessionState = context.noiseSessionState(for: peerID) let status: EncryptionStatus @@ -448,11 +454,11 @@ final class ChatPeerIdentityCoordinator { case .established: status = verifiedEncryptionStatus(for: peerID) case .handshaking, .handshakeQueued: - status = hasEverEstablishedSession ? verifiedEncryptionStatus(for: peerID) : .noiseHandshaking + status = .noiseHandshaking case .none: - status = hasEverEstablishedSession ? verifiedEncryptionStatus(for: peerID) : .noHandshake + status = .noHandshake case .failed: - status = hasEverEstablishedSession ? verifiedEncryptionStatus(for: peerID) : .none + status = .none } context.setCachedEncryptionStatus(status, for: peerID) diff --git a/bitchat/Views/ContentSheetViews.swift b/bitchat/Views/ContentSheetViews.swift index a4092b43..4b5bfae1 100644 --- a/bitchat/Views/ContentSheetViews.swift +++ b/bitchat/Views/ContentSheetViews.swift @@ -615,7 +615,9 @@ private struct ContentPrivateChatSheetView: View { // Geohash DMs use BitChat's private-envelope encryption over Nostr — // always end-to-end encrypted, // even though they carry no Noise session status. Mesh DMs earn the - // "encrypted" claim only once the Noise handshake has secured. + // "encrypted" claim only once the Noise handshake has secured — or + // when the peer is reachable only over Nostr, where delivery is + // gift-wrapped end-to-end without a Noise session. let isGeoDM = privateConversationModel.selectedPeerID?.isGeoDM == true let noiseSecured: Bool = { switch privateConversationModel.selectedHeaderState?.encryptionStatus { @@ -623,7 +625,8 @@ private struct ContentPrivateChatSheetView: View { default: return false } }() - if isGeoDM || noiseSecured { + let nostrTransport = privateConversationModel.selectedHeaderState?.availability == .nostrAvailable + if isGeoDM || noiseSecured || nostrTransport { return String(localized: "content.private.caption_encrypted", comment: "Caption above the private chat composer once the session is end-to-end encrypted") } return String(localized: "content.private.caption", comment: "Caption above the private chat composer before encryption is established") diff --git a/bitchat/Views/LocationChannelsSheet.swift b/bitchat/Views/LocationChannelsSheet.swift index 788d66c4..dcc2bc84 100644 --- a/bitchat/Views/LocationChannelsSheet.swift +++ b/bitchat/Views/LocationChannelsSheet.swift @@ -27,6 +27,9 @@ struct LocationChannelsSheet: View { static let grantToFind: LocalizedStringKey = "location_channels.grant_to_find" static let teleport: LocalizedStringKey = "location_channels.action.teleport" static let bookmarked: LocalizedStringKey = "location_channels.bookmarked_section_title" + // Same string the settings pane shows under the tor toggle — the + // warning belongs wherever the exposure is about to happen. + static let torOffWarning = String(localized: "app_info.settings.tor.off_warning", defaultValue: "tor is off: every relay you connect to can see your IP address, including relays carrying your private messages.", comment: "Warning shown under the tor toggle while tor is switched off, stating that relay operators can see the device IP address") static let quickJoinTitle = String(localized: "location_channels.quick_join.title", defaultValue: "quick join", comment: "Section header in the location channels sheet for the one-tap suggestion of the region channel derived from the device region") static func quickJoinDescription(_ regionName: String) -> String { @@ -126,6 +129,16 @@ struct LocationChannelsSheet: View { .bitchatFont(size: 12) .foregroundColor(palette.secondary) + // The description's tor claim is only true while tor is on; + // when it's off, say what that exposes right where the person + // is about to join a channel, not just in settings. + if !locationChannelsModel.userTorEnabled { + Text(verbatim: Strings.torOffWarning) + .bitchatFont(size: 11) + .foregroundColor(palette.alertRed) + .fixedSize(horizontal: false, vertical: true) + } + Group { switch locationChannelsModel.permissionState { case .notDetermined: diff --git a/bitchatTests/BitchatPeerTests.swift b/bitchatTests/BitchatPeerTests.swift index eba4e4c7..101f30f8 100644 --- a/bitchatTests/BitchatPeerTests.swift +++ b/bitchatTests/BitchatPeerTests.swift @@ -36,6 +36,30 @@ struct BitchatPeerTests { #expect(peer.statusIcon == "🌙") } + @Test("Mutual favorite without a stored Nostr key is offline, not nostr-available") + func mutualFavoriteWithoutNostrKeyIsOffline() { + let peerID = PeerID(str: "0123456789abcdef") + let noiseKey = Data((0..<32).map(UInt8.init)) + var peer = BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: "A", isConnected: false, isReachable: false) + peer.favoriteStatus = FavoriteRelationship( + peerNoisePublicKey: noiseKey, + peerNostrPublicKey: nil, + peerNickname: "A", + isFavorite: true, + theyFavoritedUs: true, + favoritedAt: Date(timeIntervalSince1970: 1), + lastUpdated: Date(timeIntervalSince1970: 2) + ) + + // Nothing to seal a Nostr envelope to: claiming availability here + // would relight the DM header's "end-to-end encrypted" caption with + // neither a Noise session nor a usable recipient key. + #expect(peer.connectionState == .offline) + + peer.nostrPublicKey = "abcdef" + #expect(peer.connectionState == .nostrAvailable) + } + @Test("Mutual offline peers show Nostr icon") func mutualFavoriteOfflinePeerShowsNostrIcon() { let peerID = PeerID(str: "0011223344556677") diff --git a/bitchatTests/ChatPeerIdentityCoordinatorContextTests.swift b/bitchatTests/ChatPeerIdentityCoordinatorContextTests.swift index 982a6de3..0bfcd306 100644 --- a/bitchatTests/ChatPeerIdentityCoordinatorContextTests.swift +++ b/bitchatTests/ChatPeerIdentityCoordinatorContextTests.swift @@ -360,6 +360,37 @@ struct ChatPeerIdentityCoordinatorContextTests { #expect(context.cachedEncryptionStatuses[peerID] == nil) } + @Test @MainActor + func getEncryptionStatus_reflectsLiveSessionNotHistory() async { + let context = MockChatPeerIdentityContext() + let coordinator = ChatPeerIdentityCoordinator(context: context) + let peerID = PeerID(str: "8877665544332211") + + // A remembered (even verified) fingerprint must not conjure a lock on + // its own: the DM header and "end-to-end encrypted" caption follow + // this status, and claiming secured with no live session is the false + // security signal this mapping used to produce. + context.fingerprintsByPeerID[peerID] = "fp" + context.verifiedFingerprintSet = ["fp"] + + #expect(coordinator.getEncryptionStatus(for: peerID) == .noHandshake) + + coordinator.invalidateEncryptionCache(for: peerID) + context.noiseSessionStates[peerID] = .handshaking + #expect(coordinator.getEncryptionStatus(for: peerID) == .noiseHandshaking) + + // A FAILED handshake is a red lock.slash, not yesterday's seal. + struct HandshakeError: Error {} + coordinator.invalidateEncryptionCache(for: peerID) + context.noiseSessionStates[peerID] = .failed(HandshakeError()) + #expect(coordinator.getEncryptionStatus(for: peerID) == .none) + + // Only a live established session upgrades to the verified seal. + coordinator.invalidateEncryptionCache(for: peerID) + context.noiseSessionStates[peerID] = .established + #expect(coordinator.getEncryptionStatus(for: peerID) == .noiseVerified) + } + @Test @MainActor func resolveNickname_walksMeshIdentityAndAnonFallbacks() async { let context = MockChatPeerIdentityContext() From 4f30433406f6cfb062a449949be7956c98d81250 Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Mon, 10 Aug 2026 07:57:25 +0200 Subject: [PATCH 07/11] Remove the public-channel screenshot broadcast (#1596) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 --------- Co-authored-by: jack Co-authored-by: Claude Fable 5 --- bitchat/App/AppRuntime.swift | 61 +++++++++-- .../ViewModels/ChatLifecycleCoordinator.swift | 86 ++++----------- ...ChatLifecycleCoordinatorContextTests.swift | 101 ++++++++++++------ bitchatTests/ChatViewModelTests.swift | 6 +- 4 files changed, 149 insertions(+), 105 deletions(-) diff --git a/bitchat/App/AppRuntime.swift b/bitchat/App/AppRuntime.swift index 0158ea0a..3d5bc5bc 100644 --- a/bitchat/App/AppRuntime.swift +++ b/bitchat/App/AppRuntime.swift @@ -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 + } +} 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..d969cd15 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) } @@ -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) + } +} 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) } } From c6cb4210e45b545c39948f4c24003c028891ca2e Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Mon, 10 Aug 2026 07:58:18 +0200 Subject: [PATCH 08/11] Add a recent-chats section so DM conversations can't become unreachable (#1598) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add a recent-chats section so DM conversations can't become unreachable The largest IA hole from the design/UX audit: a direct conversation with someone who went offline (and isn't a mutual favorite) had no row anywhere in the UI. The roster filters to connected/reachable/mutual- favorite, the header envelope only exists while unread, and /msg can't resolve offline strangers — read a DM from a passerby, close the sheet, and the thread was still in memory with no way back to it. - PeerListModel now derives RecentChatRow entries from the ConversationStore's direct conversations: people absent from the rosters above, newest activity first, unread flag, deduplicated by fingerprint across ephemeral/stable peer-ID mirrors (newest wins), groups and blocked peers excluded. - A "chats" section (same header shape as #mesh/groups) renders them in the people sheet on both mesh and location channels — geoDMs from a channel since left stay reachable too. Tapping reopens the DM via the existing startConversation path; the section renders nothing when empty. - Rebuilds are driven by direct-conversation store changes only, so public-timeline traffic doesn't churn the sheet. 2 new strings, all 30 locales. New architecture test pins that an offline stranger's thread gets a row while group threads don't. Co-Authored-By: Claude Fable 5 * Review fixes: geo dedup and geoDM names in recent chats - A person still visible in the geohash roster could also get a recent chat row (the builder only subtracted mesh rows). Visible geohash pubkeys now join the identity set via their PeerID(nostr_:) form, so the absent-from-rosters contract holds on location channels too. - GeoDM rows went through resolveNickname, which only consults mesh identity sources and rendered nostr_… keys as an opaque anon fallback. They now resolve through geohashDisplayName (Nostr key mapping, bare-key fallback). Both pinned in the recent-chats architecture test. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: jack Co-authored-by: Claude Fable 5 --- bitchat/App/PeerListModel.swift | 119 ++++++++ bitchat/Localizable.xcstrings | 372 ++++++++++++++++++++++++ bitchat/Views/ContentSheetViews.swift | 20 ++ bitchat/Views/RecentChatList.swift | 89 ++++++ bitchatTests/AppArchitectureTests.swift | 84 ++++++ 5 files changed, 684 insertions(+) create mode 100644 bitchat/Views/RecentChatList.swift diff --git a/bitchat/App/PeerListModel.swift b/bitchat/App/PeerListModel.swift index b365d09d..4f1fc633 100644 --- a/bitchat/App/PeerListModel.swift +++ b/bitchat/App/PeerListModel.swift @@ -39,12 +39,27 @@ struct GroupChatRow: Identifiable, Equatable { var id: String { peerID.id } } +/// A direct conversation whose person is NOT in any roster above it. Without +/// this section, a DM from a passerby became unreachable the moment they went +/// offline: the roster filters to connected/reachable/mutual-favorite, the +/// header envelope only exists while unread, and /msg can't resolve offline +/// strangers — the thread was still in memory with no row anywhere in the UI. +struct RecentChatRow: Identifiable, Equatable { + let peerID: PeerID + let displayName: String + let hasUnread: Bool + let lastActivity: Date + + var id: String { peerID.id } +} + @MainActor final class PeerListModel: ObservableObject { @Published private(set) var allPeers: [BitchatPeer] = [] @Published private(set) var meshRows: [MeshPeerRow] = [] @Published private(set) var geohashPeople: [GeohashPersonRow] = [] @Published private(set) var groupRows: [GroupChatRow] = [] + @Published private(set) var recentChatRows: [RecentChatRow] = [] @Published private(set) var reachableMeshPeerCount = 0 @Published private(set) var connectedMeshPeerCount = 0 @Published private(set) var visibleGeohashPeerCount = 0 @@ -143,6 +158,17 @@ final class PeerListModel: ObservableObject { } .store(in: &cancellables) + // Direct-conversation changes reorder or create recent-chat rows. + // Filtered to `.direct` so public-timeline traffic (every mesh or + // geohash message emits a change) doesn't rebuild the sheet. + conversations.changes + .receive(on: DispatchQueue.main) + .sink { [weak self] change in + guard case .direct = Self.changedConversationID(change) else { return } + self?.refresh() + } + .store(in: &cancellables) + chatViewModel.groupStore.$groups .receive(on: DispatchQueue.main) .sink { [weak self] _ in @@ -239,6 +265,7 @@ final class PeerListModel: ObservableObject { let geohashPeople = buildGeohashPeople() let groupRows = buildGroupRows() + let recentChatRows = buildRecentChatRows(meshRows: meshRows, geohashPeople: geohashPeople) self.meshRows = meshRows reachableMeshPeerCount = meshCounts.reachable @@ -246,6 +273,7 @@ final class PeerListModel: ObservableObject { self.geohashPeople = geohashPeople visibleGeohashPeerCount = geohashPeople.count self.groupRows = groupRows + self.recentChatRows = recentChatRows renderID = ( meshRows.map { "\($0.id)-\($0.displayName)-\($0.isConnected)-\($0.isReachable)-\($0.hasUnread)-\($0.isFavorite)-\($0.isBlocked)" @@ -255,10 +283,101 @@ final class PeerListModel: ObservableObject { } + groupRows.map { "group:\($0.id)-\($0.name)-\($0.memberCount)-\($0.hasUnread)" + } + + recentChatRows.map { + "chat:\($0.id)-\($0.displayName)-\($0.hasUnread)" } ).joined(separator: "|") } + /// Direct conversations with people absent from the rosters above: + /// the offline passerby DM, the geoDM from a channel since left. Rows + /// mirrored under both an ephemeral and a stable peer ID collapse to + /// one row per identity (newest activity wins), matching how the + /// private-chat coordinators consolidate on open. + private func buildRecentChatRows( + meshRows: [MeshPeerRow], + geohashPeople: [GeohashPersonRow] + ) -> [RecentChatRow] { + // A conversation can be keyed by the stable Noise peer ID while the + // roster lists the ephemeral one — compare fingerprints too. + var visibleIdentities = Set() + for row in meshRows { + visibleIdentities.insert(row.peerID.id) + if let fingerprint = chatViewModel.getFingerprint(for: row.peerID) { + visibleIdentities.insert(fingerprint) + } + } + // Someone visible in the geohash section must not also get a chat + // row: their GeoDM conversation is keyed by the nostr_ form of the + // same pubkey the roster lists. + for person in geohashPeople { + visibleIdentities.insert(PeerID(nostr_: person.id).id) + } + + struct Candidate { + let peerID: PeerID + let lastActivity: Date + } + var bestByIdentity: [String: Candidate] = [:] + + for (id, conversation) in conversations.conversationsByID { + guard case .direct(let handle) = id else { continue } + let peerID = handle.routingPeerID + // Groups have their own section; blocked people get no row. + guard !peerID.isGroup else { continue } + guard let lastMessage = conversation.messages.last else { continue } + guard !chatViewModel.isPeerBlocked(peerID) else { continue } + + let fingerprint = chatViewModel.getFingerprint(for: peerID) + if visibleIdentities.contains(peerID.id) { continue } + if let fingerprint, visibleIdentities.contains(fingerprint) { continue } + + let identityKey = fingerprint ?? peerID.id + if let existing = bestByIdentity[identityKey], + existing.lastActivity >= lastMessage.timestamp { + continue + } + bestByIdentity[identityKey] = Candidate(peerID: peerID, lastActivity: lastMessage.timestamp) + } + + return bestByIdentity.values + .sorted { $0.lastActivity > $1.lastActivity } + .map { candidate in + RecentChatRow( + peerID: candidate.peerID, + displayName: displayName(for: candidate.peerID), + hasUnread: chatViewModel.hasUnreadMessages(for: candidate.peerID), + lastActivity: candidate.lastActivity + ) + } + } + + /// GeoDM conversations resolve through the Nostr mapping — + /// `resolveNickname` only consults mesh identity sources and would + /// render a `nostr_…` key as an opaque `anonnost` fallback. + private func displayName(for peerID: PeerID) -> String { + if peerID.isGeoDM { + return chatViewModel.geohashDisplayName(for: peerID) + } + return chatViewModel.resolveNickname(for: peerID) + } + + private static func changedConversationID(_ change: ConversationChange) -> ConversationID { + switch change { + case .appended(let id, _), + .updated(let id, _), + .statusChanged(let id, _, _), + .messageRemoved(let id, _), + .cleared(let id), + .removed(let id), + .unreadChanged(let id, _): + return id + case .migrated(_, let to): + return to + } + } + private func buildGroupRows() -> [GroupChatRow] { let myFingerprint = chatViewModel.meshService.noiseIdentityFingerprint() return chatViewModel.groupStore.groups.map { group in diff --git a/bitchat/Localizable.xcstrings b/bitchat/Localizable.xcstrings index b05370a7..9ca423a6 100644 --- a/bitchat/Localizable.xcstrings +++ b/bitchat/Localizable.xcstrings @@ -2047,6 +2047,378 @@ } } }, + "chats.accessibility.open_hint" : { + "comment" : "Accessibility hint on a recent chat row explaining activation opens the direct conversation", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "يفتح هذه المحادثة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "এই কথোপকথন খোলে" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "öffnet diese unterhaltung" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "opens this conversation" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "abre esta conversación" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "این گفتگو را باز می‌کند" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "binubuksan ang usapang ito" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "ouvre cette conversation" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "פותח את השיחה הזו" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "यह वार्तालाप खोलता है" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "membuka percakapan ini" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "apre questa conversazione" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "この会話を開きます" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 대화를 엽니다" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "membuka perbualan ini" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "यो वार्तालाप खोल्छ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "opent dit gesprek" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "otwiera tę rozmowę" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "abre esta conversa" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "abre esta conversa" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "открывает этот разговор" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "öppnar den här konversationen" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "இந்த உரையாடலைத் திறக்கும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "เปิดการสนทนานี้" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "bu konuşmayı açar" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "відкриває цю розмову" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "یہ گفتگو کھولتا ہے" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "mở cuộc trò chuyện này" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "打开此对话" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "開啟此對話" + } + } + } + }, + "chats.section.header" : { + "comment" : "Section header above recent direct conversations in the people sheet", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "الدردشات" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "চ্যাট" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "chats" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "chats" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "chats" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "گفتگوها" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "mga chat" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "discussions" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "צ'אטים" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "चैट" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "obrolan" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "chat" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "チャット" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "채팅" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "sembang" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "च्याटहरू" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "chats" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "czaty" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "conversas" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "conversas" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "чаты" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "chattar" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "அரட்டைகள்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "แชท" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "sohbetler" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "чати" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "چیٹس" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "trò chuyện" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "聊天" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "聊天" + } + } + } + }, "content.system.media_delete_refused" : { "comment" : "System message shown in the affected chat when an explicit media delete or /clear was refused and bubbles/files were kept", "extractionState" : "manual", diff --git a/bitchat/Views/ContentSheetViews.swift b/bitchat/Views/ContentSheetViews.swift index 4b5bfae1..18c0f148 100644 --- a/bitchat/Views/ContentSheetViews.swift +++ b/bitchat/Views/ContentSheetViews.swift @@ -365,6 +365,16 @@ private struct ContentPeopleListView: View { showSidebar = true } ) + // Direct conversations survive channel switches; the + // geoDM someone opened from another cell must stay + // reachable here too. + RecentChatList( + chats: peerListModel.recentChatRows, + onTapChat: { peerID in + peerListModel.startConversation(with: peerID) + showSidebar = true + } + ) } else { PeopleSectionHeader( icon: "antenna.radiowaves.left.and.right", @@ -400,6 +410,16 @@ private struct ContentPeopleListView: View { showSidebar = true } ) + // Conversations with people no roster above lists + // anymore — without this, a read DM from an offline + // non-favorite had no row anywhere in the UI. + RecentChatList( + chats: peerListModel.recentChatRows, + onTapChat: { peerID in + peerListModel.startConversation(with: peerID) + showSidebar = true + } + ) } } .padding(.top, 4) diff --git a/bitchat/Views/RecentChatList.swift b/bitchat/Views/RecentChatList.swift new file mode 100644 index 00000000..85180eb4 --- /dev/null +++ b/bitchat/Views/RecentChatList.swift @@ -0,0 +1,89 @@ +// +// RecentChatList.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import BitFoundation +import SwiftUI + +/// "chats" section for the people sheet: direct conversations with people +/// who are not in any roster above it (offline passersby, geoDMs from a +/// channel since left). Before this section existed, those threads were +/// unreachable the moment the unread envelope cleared — still in memory, +/// no row anywhere in the UI. Renders nothing when there are none. +struct RecentChatList: View { + @ThemedPalette private var palette + + let chats: [RecentChatRow] + let onTapChat: (PeerID) -> Void + + private enum Strings { + static let header = String(localized: "chats.section.header", defaultValue: "chats", comment: "Section header above recent direct conversations in the people sheet") + static let unread = String(localized: "mesh_peers.state.unread", comment: "State label for a peer with unread private messages") + static let newMessagesTooltip = String(localized: "mesh_peers.tooltip.new_messages", comment: "Tooltip for the unread messages indicator") + static let openChatHint = String(localized: "chats.accessibility.open_hint", defaultValue: "opens this conversation", comment: "Accessibility hint on a recent chat row explaining activation opens the direct conversation") + } + + /// Relative "5 min ago" stamps; the formatter is locale-aware. + private static let relativeFormatter: RelativeDateTimeFormatter = { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .short + return formatter + }() + + var body: some View { + if !chats.isEmpty { + VStack(alignment: .leading, spacing: 0) { + // Same glyph+label header shape as #mesh / groups. + PeopleSectionHeader( + icon: "bubble.left.and.bubble.right", + iconColor: palette.secondary, + title: Strings.header + ) + + ForEach(chats) { chat in + HStack(spacing: 4) { + Text(verbatim: chat.displayName) + .bitchatFont(size: 14) + .foregroundColor(palette.primary) + .lineLimit(1) + .truncationMode(.tail) + + Text(verbatim: Self.relativeFormatter.localizedString(for: chat.lastActivity, relativeTo: Date())) + .bitchatFont(size: 11) + .foregroundColor(palette.secondary.opacity(0.8)) + + Spacer() + + if chat.hasUnread { + Image(systemName: "envelope.fill") + .font(.bitchatSystem(size: 10)) + .foregroundColor(.orange) + .help(Strings.newMessagesTooltip) + } + } + .padding(.horizontal) + .padding(.vertical, 6) + .contentShape(Rectangle()) + .onTapGesture { onTapChat(chat.peerID) } + .accessibilityElement(children: .ignore) + .accessibilityLabel(accessibilityDescription(for: chat)) + .accessibilityAddTraits(.isButton) + .accessibilityHint(Strings.openChatHint) + } + } + } + } + + private func accessibilityDescription(for chat: RecentChatRow) -> String { + var parts: [String] = [ + chat.displayName, + Self.relativeFormatter.localizedString(for: chat.lastActivity, relativeTo: Date()) + ] + if chat.hasUnread { parts.append(Strings.unread) } + return parts.joined(separator: ", ") + } +} diff --git a/bitchatTests/AppArchitectureTests.swift b/bitchatTests/AppArchitectureTests.swift index 2fdb94df..360c89ab 100644 --- a/bitchatTests/AppArchitectureTests.swift +++ b/bitchatTests/AppArchitectureTests.swift @@ -458,6 +458,90 @@ struct AppArchitectureTests { #expect(chromeModel.showingFingerprintFor == nil) } + @Test("Recent chats list direct conversations with people absent from the rosters") + @MainActor + func peerListModelSurfacesRecentChatsForAbsentPeers() async { + let viewModel = makeArchitectureViewModel() + let offlinePeerID = PeerID(str: "00000000000000aa") + let groupPeerID = PeerID(groupID: Data(repeating: 0xAB, count: 16)) + + // A DM thread from a passerby who has since gone offline: not in + // allPeers, not a favorite — previously unreachable once read. + viewModel.seedPrivateChat([ + BitchatMessage( + id: "dm-1", + sender: "passerby", + content: "hello from the train", + timestamp: Date(timeIntervalSince1970: 100), + isRelay: false, + isPrivate: true, + recipientNickname: "me", + senderPeerID: offlinePeerID + ) + ], for: offlinePeerID) + // Group threads have their own section and must not duplicate here. + viewModel.seedPrivateChat([ + BitchatMessage( + id: "gm-1", + sender: "member", + content: "group hello", + timestamp: Date(timeIntervalSince1970: 200), + isRelay: false, + isPrivate: true, + recipientNickname: "me", + senderPeerID: groupPeerID + ) + ], for: groupPeerID) + + let peerListModel = PeerListModel( + chatViewModel: viewModel, + conversations: viewModel.conversations + ) + + await waitUntil { + peerListModel.recentChatRows.contains { $0.peerID == offlinePeerID } + } + + #expect(peerListModel.recentChatRows.map(\.peerID) == [offlinePeerID]) + #expect(peerListModel.recentChatRows.first?.lastActivity == Date(timeIntervalSince1970: 100)) + // The roster doesn't list this peer — that's exactly why the row exists. + #expect(!peerListModel.meshRows.contains { $0.peerID == offlinePeerID }) + + // A geoDM thread resolves through the Nostr mapping (bare-key + // fallback here), never resolveNickname's mesh-only anon fallback. + let pubkeyHex = String(repeating: "ab", count: 32) + let geoDMPeer = PeerID(nostr_: pubkeyHex) + viewModel.seedPrivateChat([ + BitchatMessage( + id: "geo-1", + sender: "stranger", + content: "hello from another cell", + timestamp: Date(timeIntervalSince1970: 300), + isRelay: false, + isPrivate: true, + recipientNickname: "me", + senderPeerID: geoDMPeer + ) + ], for: geoDMPeer) + + await waitUntil { + peerListModel.recentChatRows.contains { $0.peerID == geoDMPeer } + } + let geoRow = peerListModel.recentChatRows.first { $0.peerID == geoDMPeer } + #expect(geoRow?.displayName == geoDMPeer.bare) + + // While that person is visible in the geohash roster, the chat row + // collapses — same absent-from-rosters contract as mesh. + viewModel.participantTracker.setActiveGeohash("u4pruy") + viewModel.participantTracker.recordParticipant(pubkeyHex: pubkeyHex, geohash: "u4pruy") + + await waitUntil { + !peerListModel.recentChatRows.contains { $0.peerID == geoDMPeer } + } + #expect(!peerListModel.recentChatRows.contains { $0.peerID == geoDMPeer }) + #expect(peerListModel.recentChatRows.map(\.peerID) == [offlinePeerID]) + } + @Test("PrivateConversationModel resolves canonical header state for the selected DM") @MainActor func privateConversationModelResolvesSelectedHeaderState() async { From 66fdea52aea90053700305885e3a3fd4fec8e4db Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Mon, 10 Aug 2026 08:31:45 +0200 Subject: [PATCH 09/11] Make the panic wipe confirmable and its outcome visible (#1588) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Make the panic wipe confirmable and its outcome visible The triple-tap panic wipe fired instantly with no confirmation, on the same 18pt logo whose single tap opens App Info — while the far less destructive triple-tap-to-clear-chat 40pt below it asks first, and the Settings-pane panic button has always confirmed. A fumbled tap on the app's only help affordance could permanently destroy identity, keys, and messages. - Triple-tap now raises the same confirmation dialog the Settings pane uses (one extra tap under duress; zero accidental wipes). - A successful wipe leaves a "all data wiped — new identity created" system message: in duress, "did it work?" must not be a guess — the natural response to uncertainty is tapping again. - A wipe that does not commit (panicRecoveryBlocked) was previously invisible: the app looked wiped, sat silently offline, and keys could still be on disk. It now shows a persistent red banner under the header saying data may remain and a relaunch retries. - Settings/privacy copy no longer says "instantly" (all 30 locales). Co-Authored-By: Claude Fable 5 * Review fixes: dismiss sheets on wipe; host the dialog durably - Codex P1: the App Info danger-zone wipe left its sheet presented, so the failed-wipe banner (and the success message) rendered underneath an opaque modal. panicClearAllData now dismisses all chrome-owned presentation first; pinned by a new architecture test. - Review nit: the confirmation dialog moved from the logo Text to the ContentView header stack, so a covered/removed header can't take a pending dialog down with it. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: jack Co-authored-by: Claude Fable 5 --- bitchat/App/AppChromeModel.swift | 27 + bitchat/Localizable.xcstrings | 492 +++++++++++++++--- bitchat/ViewModels/ChatViewModel.swift | 7 + .../Components/PanicWipeBlockedBanner.swift | 42 ++ bitchat/Views/ContentHeaderView.swift | 11 +- bitchat/Views/ContentView.swift | 40 +- bitchatTests/AppArchitectureTests.swift | 48 ++ bitchatTests/ChatViewModelTests.swift | 4 +- 8 files changed, 601 insertions(+), 70 deletions(-) create mode 100644 bitchat/Views/Components/PanicWipeBlockedBanner.swift diff --git a/bitchat/App/AppChromeModel.swift b/bitchat/App/AppChromeModel.swift index 70a6c28c..8d369c5b 100644 --- a/bitchat/App/AppChromeModel.swift +++ b/bitchat/App/AppChromeModel.swift @@ -18,6 +18,12 @@ final class AppChromeModel: ObservableObject { @Published var bluetoothAlertMessage = "" @Published var bluetoothState: CBManagerState = .unknown @Published var showScreenshotPrivacyWarning = false + /// Triple-tapping the logo asks first; the dialog lives on the header. + @Published var showPanicConfirmation = false + /// Mirrors `ChatViewModel.panicRecoveryBlocked` for the chrome: a wipe + /// that did not commit must be visible, not just logged — the person who + /// triggered it needs to know data may remain on the device. + @Published private(set) var panicWipeBlocked = false private let chatViewModel: ChatViewModel private let onPanicWipe: () -> Void @@ -111,7 +117,24 @@ final class AppChromeModel: ObservableObject { prepareForPanic = preparation } + /// Entry point for the header triple-tap: confirm before destroying. + /// The Settings-pane button has always confirmed; the gesture now goes + /// through the same dialog so a mis-tap can't wipe the device. + func requestPanicWipe() { + showPanicConfirmation = true + } + func panicClearAllData() { + // A wipe invalidates everything on screen, and its outcome must be + // visible: the success message and the failed-wipe banner both live + // on the root timeline, so a sheet left up (the App Info danger-zone + // path keeps its sheet presented) would hide the one signal that says + // whether the wipe worked. + isAppInfoPresented = false + isLocationChannelsSheetPresented = false + isNoticesSheetPresented = false + showingFingerprintFor = nil + prepareForPanic?() onPanicWipe() chatViewModel.panicClearAllData() @@ -145,6 +168,10 @@ final class AppChromeModel: ObservableObject { .receive(on: DispatchQueue.main) .assign(to: &$bluetoothState) + chatViewModel.$panicRecoveryBlocked + .receive(on: DispatchQueue.main) + .assign(to: &$panicWipeBlocked) + hasUnreadPrivateMessages = !privateInboxModel.unreadPeerIDs.isEmpty } } diff --git a/bitchat/Localizable.xcstrings b/bitchat/Localizable.xcstrings index 9ca423a6..7f393629 100644 --- a/bitchat/Localizable.xcstrings +++ b/bitchat/Localizable.xcstrings @@ -2419,6 +2419,192 @@ } } }, + "content.banner.panic_blocked" : { + "comment" : "Banner shown when a panic wipe did not fully commit; relaunching the app retries the wipe", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "المسح غير مكتمل — قد تبقى بعض البيانات. أغلق bitchat وأعد فتحه لإعادة المحاولة." + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "মুছে ফেলা অসম্পূর্ণ — কিছু ডেটা থেকে যেতে পারে। আবার চেষ্টা করতে bitchat বন্ধ করে আবার খুলুন।" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "löschen unvollständig — einige daten könnten übrig sein. bitchat beenden und erneut öffnen, um es noch einmal zu versuchen." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "wipe incomplete — some data may remain. quit and reopen bitchat to retry." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "borrado incompleto — pueden quedar algunos datos. cierra y vuelve a abrir bitchat para reintentar." + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "پاک‌سازی ناقص است — ممکن است برخی داده‌ها باقی مانده باشند. برای تلاش دوباره bitchat را ببندید و دوباره باز کنید." + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "hindi kumpleto ang pagbura — maaaring may natirang data. isara at buksan muli ang bitchat para subukan ulit." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "effacement incomplet — des données peuvent subsister. quittez et rouvrez bitchat pour réessayer." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "המחיקה לא הושלמה — ייתכן שנשארו נתונים. סגרו ופתחו מחדש את bitchat כדי לנסות שוב." + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "मिटाना अधूरा रहा — कुछ डेटा बच सकता है। फिर से कोशिश करने के लिए bitchat बंद करके दोबारा खोलें।" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "penghapusan belum selesai — sebagian data mungkin tersisa. tutup dan buka kembali bitchat untuk mencoba lagi." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "cancellazione incompleta — alcuni dati potrebbero rimanere. chiudi e riapri bitchat per riprovare." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "消去が完了していません — データが残っている可能性があります。bitchat を終了して開き直すと再試行します。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "삭제가 완료되지 않았습니다 — 일부 데이터가 남아 있을 수 있습니다. bitchat을 종료했다가 다시 열면 재시도합니다." + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "pemadaman tidak lengkap — sebahagian data mungkin masih ada. tutup dan buka semula bitchat untuk mencuba lagi." + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "मेटाउने काम अधूरो — केही डेटा बाँकी रहन सक्छ। पुनः प्रयास गर्न bitchat बन्द गरेर फेरि खोल्नुहोस्।" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "wissen onvolledig — er kunnen gegevens achterblijven. sluit bitchat en open het opnieuw om het nogmaals te proberen." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "wymazywanie niekompletne — część danych może pozostać. zamknij i otwórz bitchat ponownie, aby spróbować jeszcze raz." + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "limpeza incompleta — alguns dados podem permanecer. feche e reabra o bitchat para tentar novamente." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "limpeza incompleta — alguns dados podem permanecer. feche e reabra o bitchat para tentar novamente." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "стирание не завершено — часть данных могла остаться. закройте и снова откройте bitchat, чтобы повторить." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "raderingen är ofullständig — viss data kan finnas kvar. avsluta och öppna bitchat igen för att försöka på nytt." + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "அழித்தல் முழுமையடையவில்லை — சில தரவு எஞ்சியிருக்கலாம். மீண்டும் முயற்சிக்க bitchat ஐ மூடி மீண்டும் திறக்கவும்." + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "การล้างข้อมูลไม่สมบูรณ์ — อาจมีข้อมูลหลงเหลืออยู่ ปิดแล้วเปิด bitchat ใหม่เพื่อลองอีกครั้ง" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "silme tamamlanmadı — bazı veriler kalmış olabilir. yeniden denemek için bitchat'i kapatıp tekrar açın." + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "стирання не завершено — частина даних могла залишитися. закрийте та знову відкрийте bitchat, щоб повторити." + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "مٹانا مکمل نہیں ہوا — کچھ ڈیٹا باقی رہ سکتا ہے۔ دوبارہ کوشش کے لیے bitchat بند کر کے دوبارہ کھولیں۔" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "xoá chưa hoàn tất — một số dữ liệu có thể còn lại. thoát và mở lại bitchat để thử lại." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "清除未完成 — 可能仍有数据残留。请退出并重新打开 bitchat 重试。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "清除未完成 — 可能仍有資料殘留。請結束並重新開啟 bitchat 重試。" + } + } + } + }, "content.system.media_delete_refused" : { "comment" : "System message shown in the affected chat when an explicit media delete or /clear was refused and bubbles/files were kept", "extractionState" : "manual", @@ -12487,181 +12673,181 @@ "ar" : { "stringUnit" : { "state" : "translated", - "value" : "اضغط الشعار ثلاث مرات لمسح كل البيانات فوراً" + "value" : "انقر ثلاث مرات على الشعار لمسح جميع البيانات (مع تأكيد)" } }, "bn" : { "stringUnit" : { "state" : "translated", - "value" : "লোগোতে তিনবার ট্যাপ করলে সঙ্গে সঙ্গে সব ডেটা মুছে যাবে" + "value" : "সব ডেটা মুছতে লোগোতে তিনবার ট্যাপ করুন (নিশ্চিতকরণসহ)" } }, "de" : { "stringUnit" : { "state" : "translated", - "value" : "tippe dreimal auf das logo, um alle daten sofort zu löschen" + "value" : "dreimal aufs logo tippen, um alle daten zu löschen (mit bestätigung)" } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "triple-tap logo to instantly clear all data" + "value" : "triple-tap logo to wipe all data (asks to confirm)" } }, "es" : { "stringUnit" : { "state" : "translated", - "value" : "toca el logotipo tres veces para borrar todos los datos al instante" + "value" : "toca tres veces el logo para borrar todos los datos (con confirmación)" } }, "fa" : { "stringUnit" : { "state" : "translated", - "value" : "برای پاک کردن فوری همهٔ داده‌ها سه بار روی لوگو بزن" + "value" : "برای پاک کردن همه داده‌ها سه بار روی لوگو بزنید (با تأیید)" } }, "fil" : { "stringUnit" : { "state" : "translated", - "value" : "i-tap ang logo nang tatlong beses para agad burahin ang lahat ng data" + "value" : "i-tap nang tatlong beses ang logo para burahin ang lahat ng data (may kumpirmasyon)" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "tape trois fois sur le logo pour tout effacer instantanément" + "value" : "touchez trois fois le logo pour effacer toutes les données (avec confirmation)" } }, "he" : { "stringUnit" : { "state" : "translated", - "value" : "הקש על הלוגו שלוש פעמים למחיקת כל הנתונים מייד" + "value" : "הקישו שלוש פעמים על הלוגו כדי למחוק את כל הנתונים (עם אישור)" } }, "hi" : { "stringUnit" : { "state" : "translated", - "value" : "लोगो पर तीन बार टैप करें और तुरंत सारा डेटा साफ़ करें" + "value" : "सारा डेटा मिटाने के लिए लोगो पर तीन बार टैप करें (पुष्टि के साथ)" } }, "id" : { "stringUnit" : { "state" : "translated", - "value" : "ketuk logo tiga kali untuk langsung menghapus semua data" + "value" : "ketuk logo tiga kali untuk menghapus semua data (dengan konfirmasi)" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "tocca il logo tre volte per cancellare subito tutti i dati" + "value" : "tocca tre volte il logo per cancellare tutti i dati (con conferma)" } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "ロゴを3回タップすると全データを即削除" + "value" : "ロゴを3回タップですべてのデータを消去(確認あり)" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "로고를 세 번 탭하여 모든 데이터 즉시 삭제할 수 있습니다" + "value" : "로고를 세 번 탭하면 모든 데이터 삭제 (확인 후 진행)" } }, "ms" : { "stringUnit" : { "state" : "translated", - "value" : "ketuk logo tiga kali untuk langsung menghapus semua data" + "value" : "ketik logo tiga kali untuk memadamkan semua data (dengan pengesahan)" } }, "ne" : { "stringUnit" : { "state" : "translated", - "value" : "लगो तीन पटक ट्याप गर्दा सबै डाटा तुरुन्त मेटिन्छ" + "value" : "सबै डेटा मेटाउन लोगोमा तीन पटक ट्याप गर्नुहोस् (पुष्टिसहित)" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "tik drie keer op het logo om alle data direct te wissen" + "value" : "tik drie keer op het logo om alle gegevens te wissen (met bevestiging)" } }, "pl" : { "stringUnit" : { "state" : "translated", - "value" : "stuknij logo trzy razy, aby natychmiast usunąć wszystkie dane" + "value" : "stuknij logo trzy razy, aby wymazać wszystkie dane (z potwierdzeniem)" } }, "pt" : { "stringUnit" : { "state" : "translated", - "value" : "toca três vezes no logótipo para limpar todos os dados de imediato" + "value" : "toque três vezes no logótipo para apagar todos os dados (com confirmação)" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "toque o logo três vezes para limpar todos os dados instantaneamente" + "value" : "toque três vezes no logo para apagar todos os dados (com confirmação)" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "тройной тап по логотипу мгновенно очищает все данные" + "value" : "трижды коснитесь логотипа, чтобы стереть все данные (с подтверждением)" } }, "sv" : { "stringUnit" : { "state" : "translated", - "value" : "tryck tre gånger på logotypen för att radera all data direkt" + "value" : "trippeltryck på loggan för att radera all data (med bekräftelse)" } }, "ta" : { "stringUnit" : { "state" : "translated", - "value" : "எல்லா தரவையும் உடனடியாக நீக்க லோகோவை மூன்று முறைத் தட்டுங்கள்" + "value" : "எல்லா தரவையும் அழிக்க லோகோவை மூன்று முறை தட்டவும் (உறுதிப்படுத்தலுடன்)" } }, "th" : { "stringUnit" : { "state" : "translated", - "value" : "แตะโลโก้สามครั้งเพื่อลบข้อมูลทั้งหมดทันที" + "value" : "แตะโลโก้สามครั้งเพื่อล้างข้อมูลทั้งหมด (มีการยืนยัน)" } }, "tr" : { "stringUnit" : { "state" : "translated", - "value" : "logoya üç kez dokunun, tüm veriler anında silinsin" + "value" : "tüm verileri silmek için logoya üç kez dokunun (onaylı)" } }, "uk" : { "stringUnit" : { "state" : "translated", - "value" : "тричі торкни логотип, щоб миттєво стерти всі дані" + "value" : "тричі торкніться логотипа, щоб стерти всі дані (з підтвердженням)" } }, "ur" : { "stringUnit" : { "state" : "translated", - "value" : "تمام ڈیٹا فوری صاف کرنے کیلئے لوگو پر تین بار ٹیپ کریں" + "value" : "تمام ڈیٹا مٹانے کے لیے لوگو پر تین بار ٹیپ کریں (تصدیق کے ساتھ)" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "chạm logo ba lần để xóa toàn bộ dữ liệu ngay lập tức" + "value" : "chạm ba lần vào logo để xoá toàn bộ dữ liệu (có xác nhận)" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "三击标志立即清除全部数据" + "value" : "连点三次标志以清除所有数据(需确认)" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "三擊標誌立即清除全部數據" + "value" : "連點三次標誌以清除所有資料(需確認)" } } } @@ -14522,181 +14708,181 @@ "ar" : { "stringUnit" : { "state" : "translated", - "value" : "يمسح كل الرسائل والمفاتيح والهوية. النقر ثلاث مرات على شعار bitchat/ يفعل الشيء نفسه فورًا." + "value" : "يمسح جميع الرسائل والمفاتيح والهوية. النقر ثلاث مرات على شعار bitchat/ يفعل الشيء نفسه." } }, "bn" : { "stringUnit" : { "state" : "translated", - "value" : "সব বার্তা, কী ও পরিচয় মুছে দেয়। bitchat/ লোগোতে তিনবার ট্যাপ করলেও সঙ্গে সঙ্গে একই কাজ হয়।" + "value" : "সব বার্তা, কী এবং পরিচয় মুছে ফেলে। bitchat/ লোগোতে তিনবার ট্যাপ করলেও একই কাজ হয়।" } }, "de" : { "stringUnit" : { "state" : "translated", - "value" : "löscht alle nachrichten, schlüssel und identität. dreimaliges tippen auf das bitchat/-logo macht dasselbe, sofort." + "value" : "löscht alle nachrichten, schlüssel und die identität. dreimaliges tippen aufs bitchat/-logo macht dasselbe." } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "erases all messages, keys, and identity. triple-tapping the bitchat/ logo does the same, instantly." + "value" : "erases all messages, keys, and identity. triple-tapping the bitchat/ logo does the same." } }, "es" : { "stringUnit" : { "state" : "translated", - "value" : "borra todos los mensajes, claves e identidad. tocar tres veces el logotipo de bitchat/ hace lo mismo, al instante." + "value" : "borra todos los mensajes, claves e identidad. tocar tres veces el logo de bitchat/ hace lo mismo." } }, "fa" : { "stringUnit" : { "state" : "translated", - "value" : "همهٔ پیام‌ها، کلیدها و هویت را پاک می‌کند. سه بار زدن روی لوگوی bitchat/ هم همین کار را فوری انجام می‌دهد." + "value" : "همه پیام‌ها، کلیدها و هویت را پاک می‌کند. سه بار زدن روی لوگوی bitchat/ همین کار را می‌کند." } }, "fil" : { "stringUnit" : { "state" : "translated", - "value" : "binubura ang lahat ng mensahe, key, at pagkakakilanlan. ang pag-tap nang tatlong beses sa logo ng bitchat/ ay pareho ang ginagawa, agad-agad." + "value" : "binubura ang lahat ng mensahe, key, at pagkakakilanlan. ganoon din ang mangyayari kapag na-tap nang tatlong beses ang bitchat/ logo." } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "efface tous les messages, les clés et l'identité. taper trois fois sur le logo bitchat/ fait la même chose, instantanément." + "value" : "efface tous les messages, les clés et l'identité. toucher trois fois le logo bitchat/ fait la même chose." } }, "he" : { "stringUnit" : { "state" : "translated", - "value" : "מוחק את כל ההודעות, המפתחות והזהות. הקשה משולשת על הלוגו של bitchat/ עושה את אותו הדבר, מייד." + "value" : "מוחק את כל ההודעות, המפתחות והזהות. הקשה משולשת על הלוגו של bitchat/ עושה את אותו הדבר." } }, "hi" : { "stringUnit" : { "state" : "translated", - "value" : "सभी संदेश, कुंजियाँ और पहचान मिटा देता है। bitchat/ लोगो पर तीन बार टैप करने से भी तुरंत यही होता है।" + "value" : "सभी संदेश, कुंजियाँ और पहचान मिटा देता है। bitchat/ लोगो पर तीन बार टैप करने से भी यही होता है।" } }, "id" : { "stringUnit" : { "state" : "translated", - "value" : "menghapus semua pesan, kunci, dan identitas. mengetuk logo bitchat/ tiga kali melakukan hal yang sama, seketika." + "value" : "menghapus semua pesan, kunci, dan identitas. mengetuk logo bitchat/ tiga kali melakukan hal yang sama." } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "cancella tutti i messaggi, le chiavi e l'identità. toccare tre volte il logo bitchat/ fa lo stesso, all'istante." + "value" : "cancella tutti i messaggi, le chiavi e l'identità. toccare tre volte il logo bitchat/ fa lo stesso." } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "すべてのメッセージ、鍵、アイデンティティを消去します。bitchat/ ロゴを3回タップしても同じことが即座に行われます。" + "value" : "すべてのメッセージ・鍵・アイデンティティを消去します。bitchat/ ロゴの3回タップでも同じことができます。" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "모든 메시지, 키, 신원을 지웁니다. bitchat/ 로고를 세 번 탭해도 즉시 같은 동작이 실행됩니다." + "value" : "모든 메시지, 키, 신원을 지웁니다. bitchat/ 로고를 세 번 탭해도 동일합니다." } }, "ms" : { "stringUnit" : { "state" : "translated", - "value" : "menghapus semua pesan, kunci, dan identiti. mengetuk logo bitchat/ tiga kali melakukan perkara yang sama, serta-merta." + "value" : "memadamkan semua mesej, kunci dan identiti. mengetik logo bitchat/ tiga kali melakukan perkara yang sama." } }, "ne" : { "stringUnit" : { "state" : "translated", - "value" : "सबै सन्देश, कुञ्जी र पहिचान मेटाउँछ। bitchat/ लोगोमा तीन पटक ट्याप गर्दा पनि तुरुन्तै त्यही हुन्छ।" + "value" : "सबै सन्देश, कुञ्जी र परिचय मेटाउँछ। bitchat/ लोगोमा तीन पटक ट्याप गर्दा पनि त्यही हुन्छ।" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "wist alle berichten, sleutels en identiteit. drie keer tikken op het bitchat/-logo doet hetzelfde, direct." + "value" : "wist alle berichten, sleutels en identiteit. drie keer tikken op het bitchat/-logo doet hetzelfde." } }, "pl" : { "stringUnit" : { "state" : "translated", - "value" : "usuwa wszystkie wiadomości, klucze i tożsamość. trzykrotne stuknięcie logo bitchat/ robi to samo, natychmiast." + "value" : "wymazuje wszystkie wiadomości, klucze i tożsamość. trzykrotne stuknięcie logo bitchat/ robi to samo." } }, "pt" : { "stringUnit" : { "state" : "translated", - "value" : "apaga todas as mensagens, chaves e identidade. tocar três vezes no logótipo bitchat/ faz o mesmo, de imediato." + "value" : "apaga todas as mensagens, chaves e identidade. tocar três vezes no logótipo bitchat/ faz o mesmo." } }, "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "apaga todas as mensagens, chaves e identidade. tocar três vezes no logo bitchat/ faz o mesmo, instantaneamente." + "value" : "apaga todas as mensagens, chaves e identidade. tocar três vezes no logo bitchat/ faz o mesmo." } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "стирает все сообщения, ключи и личность. тройной тап по логотипу bitchat/ делает то же самое, мгновенно." + "value" : "стирает все сообщения, ключи и идентичность. тройное касание логотипа bitchat/ делает то же самое." } }, "sv" : { "stringUnit" : { "state" : "translated", - "value" : "raderar alla meddelanden, nycklar och identitet. att trycka tre gånger på bitchat/-logotypen gör detsamma, direkt." + "value" : "raderar alla meddelanden, nycklar och identitet. att trippeltrycka på bitchat/-loggan gör samma sak." } }, "ta" : { "stringUnit" : { "state" : "translated", - "value" : "எல்லா செய்திகள், சாவிகள், அடையாளத்தையும் அழிக்கிறது. bitchat/ லோகோவை மூன்று முறைத் தட்டினாலும் உடனடியாக அதுவே நடக்கும்." + "value" : "எல்லா செய்திகள், விசைகள் மற்றும் அடையாளத்தை அழிக்கிறது. bitchat/ லோகோவை மூன்று முறை தட்டினாலும் இதுவே நடக்கும்." } }, "th" : { "stringUnit" : { "state" : "translated", - "value" : "ลบข้อความ กุญแจ และตัวตนทั้งหมด การแตะโลโก้ bitchat/ สามครั้งก็ทำแบบเดียวกันทันที" + "value" : "ลบข้อความ กุญแจ และตัวตนทั้งหมด การแตะโลโก้ bitchat/ สามครั้งให้ผลเหมือนกัน" } }, "tr" : { "stringUnit" : { "state" : "translated", - "value" : "tüm mesajları, anahtarları ve kimliği siler. bitchat/ logosuna üç kez dokunmak da aynısını anında yapar." + "value" : "tüm mesajları, anahtarları ve kimliği siler. bitchat/ logosuna üç kez dokunmak da aynısını yapar." } }, "uk" : { "stringUnit" : { "state" : "translated", - "value" : "стирає всі повідомлення, ключі та особистість. потрійний дотик до логотипа bitchat/ робить те саме, миттєво." + "value" : "стирає всі повідомлення, ключі та ідентичність. потрійне торкання логотипа bitchat/ робить те саме." } }, "ur" : { "stringUnit" : { "state" : "translated", - "value" : "تمام پیغامات، کلیدیں اور شناخت مٹا دیتا ہے۔ bitchat/ لوگو پر تین بار ٹیپ کرنے سے بھی فوراً یہی ہوتا ہے۔" + "value" : "تمام پیغامات، کلیدیں اور شناخت مٹا دیتا ہے۔ bitchat/ لوگو پر تین بار ٹیپ کرنے سے بھی یہی ہوتا ہے۔" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "xóa toàn bộ tin nhắn, khóa và danh tính. chạm ba lần vào logo bitchat/ cũng làm điều tương tự, ngay lập tức." + "value" : "xoá toàn bộ tin nhắn, khoá và danh tính. chạm ba lần vào logo bitchat/ cũng làm điều tương tự." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "抹除所有消息、密钥和身份。三击 bitchat/ 标志也会立即执行相同操作。" + "value" : "清除所有消息、密钥和身份。连点三次 bitchat/ 标志效果相同。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "抹除所有訊息、金鑰和身分。三擊 bitchat/ 標誌也會立即執行相同操作。" + "value" : "清除所有訊息、金鑰和身分。連點三次 bitchat/ 標誌效果相同。" } } } @@ -67355,6 +67541,192 @@ } } }, + "system.panic.completed" : { + "comment" : "System message confirming a successful panic wipe", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "تم مسح جميع البيانات — تم إنشاء هوية جديدة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "সব ডেটা মুছে ফেলা হয়েছে — নতুন পরিচয় তৈরি হয়েছে" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "alle daten gelöscht — neue identität erstellt" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "all data wiped — new identity created" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "todos los datos borrados — nueva identidad creada" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "همه داده‌ها پاک شد — هویت جدید ساخته شد" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "nabura na ang lahat ng data — gumawa ng bagong pagkakakilanlan" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "toutes les données effacées — nouvelle identité créée" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "כל הנתונים נמחקו — נוצרה זהות חדשה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "सारा डेटा मिटा दिया गया — नई पहचान बनाई गई" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "semua data dihapus — identitas baru dibuat" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "tutti i dati cancellati — nuova identità creata" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "すべてのデータを消去しました — 新しいアイデンティティを作成しました" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "모든 데이터 삭제됨 — 새 신원 생성됨" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "semua data dipadamkan — identiti baharu dicipta" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "सबै डेटा मेटाइयो — नयाँ परिचय सिर्जना गरियो" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "alle gegevens gewist — nieuwe identiteit aangemaakt" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "wszystkie dane wymazane — utworzono nową tożsamość" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "todos os dados apagados — nova identidade criada" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "todos os dados apagados — nova identidade criada" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "все данные стёрты — создана новая идентичность" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "all data raderad — ny identitet skapad" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "எல்லா தரவும் அழிக்கப்பட்டது — புதிய அடையாளம் உருவாக்கப்பட்டது" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ล้างข้อมูลทั้งหมดแล้ว — สร้างตัวตนใหม่แล้ว" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "tüm veriler silindi — yeni kimlik oluşturuldu" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "усі дані стерто — створено нову ідентичність" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "تمام ڈیٹا مٹا دیا گیا — نئی شناخت بنائی گئی" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "đã xoá toàn bộ dữ liệu — đã tạo danh tính mới" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已清除所有数据 — 已创建新身份" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "已清除所有資料 — 已建立新身分" + } + } + } + }, "system.chat.blocked" : { "extractionState" : "manual", "localizations" : { diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 12adfda9..7dd3dd8f 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -1771,6 +1771,13 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage panicNetworkLifecycle.restart() } + // In a duress scenario "did it work?" must not be a guess — the + // natural response to uncertainty is to trigger the wipe again. + // The failure case surfaces separately via `panicRecoveryBlocked`. + addMeshOnlySystemMessage( + String(localized: "system.panic.completed", defaultValue: "all data wiped — new identity created", comment: "System message confirming a successful panic wipe") + ) + return true } diff --git a/bitchat/Views/Components/PanicWipeBlockedBanner.swift b/bitchat/Views/Components/PanicWipeBlockedBanner.swift new file mode 100644 index 00000000..9039caf0 --- /dev/null +++ b/bitchat/Views/Components/PanicWipeBlockedBanner.swift @@ -0,0 +1,42 @@ +// +// PanicWipeBlockedBanner.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import SwiftUI + +/// Shown under the header while a panic wipe has not committed +/// (`ChatViewModel.panicRecoveryBlocked`). A wipe that fails must be as loud +/// as the wipe itself: the person who triggered it may be relying on the +/// device being clean, and networking stays disabled until a relaunch retries +/// the transaction — without this banner the app just looks silently dead. +struct PanicWipeBlockedBanner: View { + @ThemedPalette private var palette + + private var message: String { + String( + localized: "content.banner.panic_blocked", + defaultValue: "wipe incomplete — some data may remain. quit and reopen bitchat to retry.", + comment: "Banner shown when a panic wipe did not fully commit; relaunching the app retries the wipe" + ) + } + + var body: some View { + HStack(alignment: .firstTextBaseline, spacing: 6) { + Image(systemName: "exclamationmark.triangle.fill") + .bitchatFont(size: 11, weight: .semibold) + Text(message) + .bitchatFont(size: 11) + .fixedSize(horizontal: false, vertical: true) + } + .foregroundColor(palette.alertRed) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color.red.opacity(0.12)) + .accessibilityElement(children: .combine) + } +} diff --git a/bitchat/Views/ContentHeaderView.swift b/bitchat/Views/ContentHeaderView.swift index bd3af7f2..f857ba38 100644 --- a/bitchat/Views/ContentHeaderView.swift +++ b/bitchat/Views/ContentHeaderView.swift @@ -51,11 +51,20 @@ struct ContentHeaderView: View { // cluster at priority 3 never gives up width. .layoutPriority(2) .onTapGesture(count: 3) { - appChromeModel.panicClearAllData() + // Confirm before destroying: the same logo is the single-tap + // App Info entry point and sits beside the nickname field, so + // a fumbled tap must never be able to wipe the device. The + // dialog matches the Settings-pane panic button; under duress + // it costs one extra tap. + appChromeModel.requestPanicWipe() } .onTapGesture(count: 1) { appChromeModel.presentAppInfo() } + // The confirmation dialog itself is hosted on ContentView + // (next to the failed-wipe banner), not on this Text: a host + // that can be covered or removed could take the pending + // dialog down with it. // This is the only entry point to App Info, but it reads as // static text; surface the tap. (The triple-tap panic wipe // stays undiscoverable on purpose — it's destructive.) diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index 1fe95c75..6ef956ed 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -489,14 +489,38 @@ struct ContentView: View { } private var headerView: some View { - ContentHeaderView( - showSidebar: $showSidebar, - showVerifySheet: $showVerifySheet, - isNicknameFieldFocused: $isNicknameFieldFocused, - headerHeight: headerHeight, - headerPeerIconSize: headerPeerIconSize, - headerPeerCountFontSize: headerPeerCountFontSize - ) + VStack(spacing: 0) { + ContentHeaderView( + showSidebar: $showSidebar, + showVerifySheet: $showVerifySheet, + isNicknameFieldFocused: $isNicknameFieldFocused, + headerHeight: headerHeight, + headerPeerIconSize: headerPeerIconSize, + headerPeerCountFontSize: headerPeerCountFontSize + ) + + if appChromeModel.panicWipeBlocked { + PanicWipeBlockedBanner() + } + } + // Hosted here rather than on the logo Text so the pending dialog + // survives whatever happens to the header chrome. + .confirmationDialog( + Text( + String(localized: "app_info.settings.danger.panic_confirm_title", defaultValue: "wipe all data?", comment: "Title of the confirmation dialog before a panic wipe") + ), + isPresented: $appChromeModel.showPanicConfirmation, + titleVisibility: .visible + ) { + Button(role: .destructive) { + appChromeModel.panicClearAllData() + } label: { + Text( + String(localized: "app_info.settings.danger.panic_confirm_action", defaultValue: "wipe everything", comment: "Destructive confirmation button that performs the panic wipe") + ) + } + Button("common.cancel", role: .cancel) {} + } } private var publicMessageList: some View { diff --git a/bitchatTests/AppArchitectureTests.swift b/bitchatTests/AppArchitectureTests.swift index 360c89ab..1ec52532 100644 --- a/bitchatTests/AppArchitectureTests.swift +++ b/bitchatTests/AppArchitectureTests.swift @@ -458,6 +458,54 @@ struct AppArchitectureTests { #expect(chromeModel.showingFingerprintFor == nil) } + @Test("Triple-tap panic entry point only raises the confirmation dialog") + @MainActor + func requestPanicWipeAsksBeforeDestroying() { + let viewModel = makeArchitectureViewModel() + let privateInboxModel = PrivateInboxModel(conversations: ConversationStore()) + let chromeModel = AppChromeModel(chatViewModel: viewModel, privateInboxModel: privateInboxModel) + viewModel.seedPublicMessages([ + BitchatMessage( + id: "keep-1", + sender: "Tester", + content: "still here", + timestamp: Date(), + isRelay: false + ) + ]) + + chromeModel.requestPanicWipe() + + // The gesture must never wipe directly — a fumbled tap on the logo + // (single-tap opens App Info) cannot be allowed to destroy identity. + #expect(chromeModel.showPanicConfirmation) + #expect(viewModel.messages.map(\.id) == ["keep-1"]) + } + + @Test("Panic wipe dismisses chrome sheets so its outcome is visible") + @MainActor + func panicWipeDismissesChromePresentation() { + let viewModel = makeArchitectureViewModel() + let privateInboxModel = PrivateInboxModel(conversations: ConversationStore()) + let chromeModel = AppChromeModel(chatViewModel: viewModel, privateInboxModel: privateInboxModel) + + // The App Info danger-zone path wipes while its own sheet is up; the + // success message and the failed-wipe banner both render on the root + // timeline, so any sheet left presented would hide the one signal + // that says whether the wipe worked. + chromeModel.presentAppInfo() + chromeModel.isLocationChannelsSheetPresented = true + chromeModel.presentNotices() + chromeModel.showFingerprint(for: PeerID(str: "peer-3")) + + chromeModel.panicClearAllData() + + #expect(!chromeModel.isAppInfoPresented) + #expect(!chromeModel.isLocationChannelsSheetPresented) + #expect(!chromeModel.isNoticesSheetPresented) + #expect(chromeModel.showingFingerprintFor == nil) + } + @Test("Recent chats list direct conversations with people absent from the rosters") @MainActor func peerListModelSurfacesRecentChatsForAbsentPeers() async { diff --git a/bitchatTests/ChatViewModelTests.swift b/bitchatTests/ChatViewModelTests.swift index 0393b3ce..35ab2975 100644 --- a/bitchatTests/ChatViewModelTests.swift +++ b/bitchatTests/ChatViewModelTests.swift @@ -2263,7 +2263,9 @@ struct ChatViewModelPanicTests { // After panic, emergency disconnect should be called #expect(transport.emergencyDisconnectCallCount == 1) - #expect(viewModel.messages.isEmpty) + // Pre-panic content is gone; the only survivor is the system message + // confirming the wipe (in duress, "did it work?" must not be a guess). + #expect(viewModel.messages.map(\.sender) == ["system"]) #expect(viewModel.privateChats.isEmpty) #expect(viewModel.unreadPrivateMessages.isEmpty) #expect(viewModel.selectedPrivateChatPeer == nil) From 5b592c8bae5d692abe56fc309362470604abf25e Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Mon, 10 Aug 2026 08:47:53 +0200 Subject: [PATCH 10/11] Add a persistent connectivity banner and stop the radar lying (#1597) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add a persistent connectivity banner and stop the radar lying Two "the app looks fine while it isn't" problems from the UX audit: - Bluetooth-off had exactly one signal: a dismissible alert. Once dismissed, the app looked completely normal — empty timeline, radar sweeping "searching for people" — while the radio was off. The radar animation now requires a radio that can actually scan, and a persistent red banner under the header carries the state instead: off (tap → settings), denied (tap → settings), or unsupported (mesh unavailable; location channels still work). - A Tor bootstrap stall was only ever announced inside geohash timelines (addGeohashOnlySystemMessage), so someone sitting in #mesh or a DM whose messages route over Nostr got silence and hanging "sent" states. The stall now also drives a chrome-level torBlocked flag and the same banner: "tor can't connect — internet features are paused. mesh still works." The banner renders nothing when everything is healthy, and .unknown/ .resetting radio states deliberately stay quiet — a false "bluetooth is off" flash at launch would be the same kind of lie. Priority and quiet-start behavior are pinned by ConnectivityIssueTests. 4 new strings, all 30 locales. Co-Authored-By: Claude Fable 5 * Review fixes: banner in the sheet, real fix targets, tor-clear tests - The people/DM sheet covers the root header, so the connectivity banner vanished exactly where people sit longest. It now mirrors into the sheet via a top safe-area inset over both the people list and the DM view. - "tap to fix" for powered-off Bluetooth opened the macOS privacy permission pane, where an already-authorized person can't fix anything. New SystemSettings.bluetoothPower routes to the Bluetooth pane; denied still goes to the privacy anchor. The one-shot alert's settings button routes by state the same way. - Tests pin that torBlocked clears on tor-ready and on preference change (no stale "tor can't connect" after toggling tor off). Co-Authored-By: Claude Fable 5 --------- Co-authored-by: jack Co-authored-by: Claude Fable 5 --- bitchat/App/AppChromeModel.swift | 7 + bitchat/Localizable.xcstrings | 744 ++++++++++++++++++ bitchat/Utils/SystemSettings.swift | 16 +- bitchat/ViewModels/ChatViewModel.swift | 4 + .../Extensions/ChatViewModel+Tor.swift | 5 + .../Components/ConnectivityStatusBanner.swift | 116 +++ .../Views/Components/MeshEmptyStateView.swift | 10 +- bitchat/Views/ContentSheetViews.swift | 17 +- bitchat/Views/ContentView.swift | 14 +- bitchat/Views/MessageListView.swift | 17 +- bitchatTests/ChatViewModelTorTests.swift | 21 +- bitchatTests/ConnectivityStatusTests.swift | 31 + 12 files changed, 990 insertions(+), 12 deletions(-) create mode 100644 bitchat/Views/Components/ConnectivityStatusBanner.swift create mode 100644 bitchatTests/ConnectivityStatusTests.swift diff --git a/bitchat/App/AppChromeModel.swift b/bitchat/App/AppChromeModel.swift index 8d369c5b..51cadfb0 100644 --- a/bitchat/App/AppChromeModel.swift +++ b/bitchat/App/AppChromeModel.swift @@ -17,6 +17,9 @@ final class AppChromeModel: ObservableObject { @Published var showBluetoothAlert = false @Published var bluetoothAlertMessage = "" @Published var bluetoothState: CBManagerState = .unknown + /// Tor bootstrap has stalled (network likely blocks it); drives the + /// connectivity banner. Mirrored from `ChatViewModel.torBlocked`. + @Published private(set) var torBlocked = false @Published var showScreenshotPrivacyWarning = false /// Triple-tapping the logo asks first; the dialog lives on the header. @Published var showPanicConfirmation = false @@ -168,6 +171,10 @@ final class AppChromeModel: ObservableObject { .receive(on: DispatchQueue.main) .assign(to: &$bluetoothState) + chatViewModel.$torBlocked + .receive(on: DispatchQueue.main) + .assign(to: &$torBlocked) + chatViewModel.$panicRecoveryBlocked .receive(on: DispatchQueue.main) .assign(to: &$panicWipeBlocked) diff --git a/bitchat/Localizable.xcstrings b/bitchat/Localizable.xcstrings index 7f393629..48d05c3a 100644 --- a/bitchat/Localizable.xcstrings +++ b/bitchat/Localizable.xcstrings @@ -2419,6 +2419,750 @@ } } }, + "content.banner.bluetooth_denied" : { + "comment" : "Persistent banner while Bluetooth permission is denied; tapping opens system settings", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "تم رفض الوصول إلى البلوتوث — الشبكة غير متصلة. انقر لفتح الإعدادات." + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "ব্লুটুথ অ্যাক্সেস অস্বীকৃত — মেশ অফলাইন। সেটিংস খুলতে ট্যাপ করুন।" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "bluetooth-zugriff verweigert — mesh ist offline. tippen, um die einstellungen zu öffnen." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "bluetooth access denied — mesh is offline. tap to open settings." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "acceso a bluetooth denegado — la malla está desconectada. toca para abrir ajustes." + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "دسترسی بلوتوث رد شد — مش آفلاین است. برای باز کردن تنظیمات بزنید." + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "tinanggihan ang access sa bluetooth — offline ang mesh. i-tap para buksan ang settings." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "accès bluetooth refusé — le mesh est hors ligne. touchez pour ouvrir les réglages." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "הגישה לבלוטות' נדחתה — הרשת לא מקוונת. הקישו לפתיחת ההגדרות." + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "ब्लूटूथ की अनुमति नहीं है — मेश ऑफ़लाइन है। सेटिंग्स खोलने के लिए टैप करें।" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "akses bluetooth ditolak — mesh offline. ketuk untuk membuka pengaturan." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "accesso bluetooth negato — la mesh è offline. tocca per aprire le impostazioni." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "bluetooth へのアクセスが拒否されています — メッシュはオフラインです。タップして設定を開きます。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "블루투스 접근이 거부되었습니다 — 메시가 오프라인입니다. 탭하여 설정을 여세요." + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "akses bluetooth ditolak — mesh di luar talian. ketik untuk membuka tetapan." + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "ब्लुटुथ पहुँच अस्वीकृत — मेश अफलाइन छ। सेटिङ खोल्न ट्याप गर्नुहोस्।" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "bluetooth-toegang geweigerd — mesh is offline. tik om instellingen te openen." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "odmowa dostępu do bluetooth — sieć mesh jest offline. stuknij, aby otworzyć ustawienia." + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "acesso ao bluetooth negado — a mesh está offline. toca para abrir as definições." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "acesso ao bluetooth negado — a mesh está offline. toque para abrir os ajustes." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "доступ к bluetooth запрещён — mesh-сеть недоступна. нажмите, чтобы открыть настройки." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "bluetooth-åtkomst nekad — mesh är offline. tryck för att öppna inställningar." + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "புளூடூத் அணுகல் மறுக்கப்பட்டது — மெஷ் ஆஃப்லைனில் உள்ளது. அமைப்புகளைத் திறக்கத் தட்டவும்." + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "การเข้าถึงบลูทูธถูกปฏิเสธ — เมชออฟไลน์ แตะเพื่อเปิดการตั้งค่า" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "bluetooth erişimi reddedildi — mesh çevrimdışı. ayarları açmak için dokunun." + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "доступ до bluetooth заборонено — mesh-мережа офлайн. торкніться, щоб відкрити налаштування." + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "بلوٹوتھ رسائی مسترد — میش آف لائن ہے۔ سیٹنگز کھولنے کے لیے ٹیپ کریں۔" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "quyền truy cập bluetooth bị từ chối — mesh đang ngoại tuyến. chạm để mở cài đặt." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "蓝牙权限被拒绝 — 网状网络已离线。点按打开设置。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "藍牙權限遭拒 — 網狀網路已離線。點按開啟設定。" + } + } + } + }, + "content.banner.bluetooth_off" : { + "comment" : "Persistent banner while Bluetooth is switched off; tapping opens system settings", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "البلوتوث مطفأ — لا يمكن لمن حولك الوصول إليك. انقر للإصلاح." + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "ব্লুটুথ বন্ধ — কাছের কেউ আপনার সঙ্গে যোগাযোগ করতে পারবে না। ঠিক করতে ট্যাপ করুন।" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "bluetooth ist aus — niemand in der nähe kann dich erreichen. zum beheben tippen." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "bluetooth is off — nobody nearby can reach you. tap to fix." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "bluetooth está apagado — nadie cerca puede contactarte. toca para arreglarlo." + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "بلوتوث خاموش است — هیچ‌کس در نزدیکی نمی‌تواند به شما برسد. برای رفع مشکل بزنید." + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "naka-off ang bluetooth — walang makakaabot sa iyo na kalapit. i-tap para ayusin." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "le bluetooth est désactivé — personne à proximité ne peut vous joindre. touchez pour corriger." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "הבלוטות' כבוי — אף אחד בסביבה לא יכול להגיע אליך. הקישו לתיקון." + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "ब्लूटूथ बंद है — आस-पास का कोई आप तक नहीं पहुँच सकता। ठीक करने के लिए टैप करें।" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "bluetooth mati — tidak ada orang di sekitar yang dapat menghubungi anda. ketuk untuk memperbaiki." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "il bluetooth è spento — nessuno nelle vicinanze può raggiungerti. tocca per risolvere." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "bluetooth がオフです — 近くの人と通信できません。タップして設定へ。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "블루투스가 꺼져 있습니다 — 주변 누구와도 연결할 수 없습니다. 탭하여 해결하세요." + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "bluetooth dimatikan — tiada sesiapa berdekatan boleh menghubungi anda. ketik untuk membaiki." + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "ब्लुटुथ बन्द छ — नजिकको कोही तपाईंसम्म पुग्न सक्दैन। मिलाउन ट्याप गर्नुहोस्।" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "bluetooth staat uit — niemand in de buurt kan je bereiken. tik om dit op te lossen." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "bluetooth jest wyłączony — nikt w pobliżu nie może się z tobą połączyć. stuknij, aby naprawić." + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "o bluetooth está desligado — ninguém por perto te consegue alcançar. toca para corrigir." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "o bluetooth está desligado — ninguém por perto consegue te alcançar. toque para corrigir." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "bluetooth выключен — никто поблизости не может с вами связаться. нажмите, чтобы исправить." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "bluetooth är av — ingen i närheten kan nå dig. tryck för att åtgärda." + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "புளூடூத் அணைக்கப்பட்டுள்ளது — அருகிலுள்ள யாரும் உங்களை அடைய முடியாது. சரிசெய்யத் தட்டவும்." + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "บลูทูธปิดอยู่ — คนใกล้เคียงติดต่อคุณไม่ได้ แตะเพื่อแก้ไข" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "bluetooth kapalı — yakınınızdaki hiç kimse size ulaşamaz. düzeltmek için dokunun." + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "bluetooth вимкнено — ніхто поблизу не може з вами зв'язатися. торкніться, щоб виправити." + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "بلوٹوتھ بند ہے — قریب کا کوئی آپ تک نہیں پہنچ سکتا۔ ٹھیک کرنے کے لیے ٹیپ کریں۔" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "bluetooth đang tắt — không ai gần bạn có thể liên lạc với bạn. chạm để khắc phục." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "蓝牙已关闭 — 附近的人无法联系到你。点按以修复。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "藍牙已關閉 — 附近的人無法聯繫到你。點按以修復。" + } + } + } + }, + "content.banner.bluetooth_unsupported" : { + "comment" : "Persistent banner on devices without Bluetooth support", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "لا يوجد بلوتوث على هذا الجهاز — الشبكة غير متاحة. قنوات الموقع لا تزال تعمل عبر الإنترنت." + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "এই ডিভাইসে ব্লুটুথ নেই — মেশ অনুপলব্ধ। লোকেশন চ্যানেল এখনও ইন্টারনেটে কাজ করে।" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "kein bluetooth auf diesem gerät — mesh ist nicht verfügbar. standortkanäle funktionieren weiter übers internet." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "no bluetooth on this device — mesh is unavailable. location channels still work over the internet." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "este dispositivo no tiene bluetooth — la malla no está disponible. los canales de ubicación siguen funcionando por internet." + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "این دستگاه بلوتوث ندارد — مش در دسترس نیست. کانال‌های مکان همچنان از طریق اینترنت کار می‌کنند." + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "walang bluetooth ang device na ito — hindi available ang mesh. gumagana pa rin ang mga location channel sa internet." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "pas de bluetooth sur cet appareil — le mesh est indisponible. les canaux de localisation fonctionnent toujours via internet." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "אין בלוטות' במכשיר הזה — הרשת לא זמינה. ערוצי מיקום עדיין עובדים דרך האינטרנט." + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "इस डिवाइस में ब्लूटूथ नहीं है — मेश अनुपलब्ध है। लोकेशन चैनल अब भी इंटरनेट पर काम करते हैं।" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "perangkat ini tidak memiliki bluetooth — mesh tidak tersedia. kanal lokasi tetap berfungsi lewat internet." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "questo dispositivo non ha il bluetooth — la mesh non è disponibile. i canali di posizione funzionano comunque via internet." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "この端末に bluetooth がありません — メッシュは利用できません。ロケーションチャンネルはインターネット経由で利用できます。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 기기에는 블루투스가 없습니다 — 메시를 사용할 수 없습니다. 위치 채널은 인터넷으로 계속 사용할 수 있습니다." + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "tiada bluetooth pada peranti ini — mesh tidak tersedia. saluran lokasi masih berfungsi melalui internet." + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "यो यन्त्रमा ब्लुटुथ छैन — मेश उपलब्ध छैन। स्थान च्यानलहरू अझै इन्टरनेटमा चल्छन्।" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "geen bluetooth op dit apparaat — mesh is niet beschikbaar. locatiekanalen werken nog via internet." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "brak bluetooth w tym urządzeniu — sieć mesh jest niedostępna. kanały lokalizacji nadal działają przez internet." + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "este dispositivo não tem bluetooth — a mesh está indisponível. os canais de localização continuam a funcionar pela internet." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "este dispositivo não tem bluetooth — a mesh está indisponível. os canais de localização continuam funcionando pela internet." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "на этом устройстве нет bluetooth — mesh-сеть недоступна. каналы мест по-прежнему работают через интернет." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "ingen bluetooth på den här enheten — mesh är inte tillgängligt. platskanaler fungerar fortfarande via internet." + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "இந்தச் சாதனத்தில் புளூடூத் இல்லை — மெஷ் கிடைக்காது. இருப்பிடச் சேனல்கள் இணையம் வழியாக இயங்கும்." + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "อุปกรณ์นี้ไม่มีบลูทูธ — ใช้เมชไม่ได้ ช่องตำแหน่งยังใช้งานผ่านอินเทอร์เน็ตได้" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "bu cihazda bluetooth yok — mesh kullanılamıyor. konum kanalları internet üzerinden çalışmaya devam ediyor." + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "на цьому пристрої немає bluetooth — mesh-мережа недоступна. канали місць далі працюють через інтернет." + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "اس ڈیوائس میں بلوٹوتھ نہیں — میش دستیاب نہیں۔ لوکیشن چینلز انٹرنیٹ پر بدستور کام کرتے ہیں۔" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "thiết bị này không có bluetooth — mesh không khả dụng. các kênh vị trí vẫn hoạt động qua internet." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "此设备没有蓝牙 — 网状网络不可用。位置频道仍可通过互联网使用。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "此裝置沒有藍牙 — 網狀網路無法使用。位置頻道仍可透過網際網路使用。" + } + } + } + }, + "content.banner.tor_blocked" : { + "comment" : "Persistent banner while Tor bootstrap has stalled, likely because the network blocks it", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "يتعذر على tor الاتصال — ميزات الإنترنت متوقفة مؤقتًا. الشبكة لا تزال تعمل." + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor সংযোগ করতে পারছে না — ইন্টারনেট ফিচার স্থগিত। মেশ এখনও কাজ করছে।" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor kann sich nicht verbinden — internetfunktionen pausieren. mesh funktioniert weiter." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor can't connect — internet features are paused. mesh still works." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor no puede conectarse — las funciones de internet están en pausa. la malla sigue funcionando." + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor نمی‌تواند متصل شود — قابلیت‌های اینترنتی متوقف‌اند. مش همچنان کار می‌کند." + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "hindi makakonekta ang tor — naka-pause ang mga internet feature. gumagana pa rin ang mesh." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor ne peut pas se connecter — les fonctions internet sont en pause. le mesh fonctionne toujours." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor לא מצליח להתחבר — תכונות האינטרנט מושהות. הרשת עדיין עובדת." + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor कनेक्ट नहीं हो पा रहा — इंटरनेट सुविधाएँ रुकी हैं। मेश अब भी काम करता है।" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor tidak dapat terhubung — fitur internet dijeda. mesh tetap berfungsi." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor non riesce a connettersi — le funzioni internet sono in pausa. la mesh continua a funzionare." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor が接続できません — インターネット機能は一時停止中です。メッシュは引き続き使えます。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor가 연결되지 않습니다 — 인터넷 기능이 일시 중지되었습니다. 메시는 계속 작동합니다." + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor tidak dapat bersambung — ciri internet dijeda. mesh masih berfungsi." + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor जडान हुन सकेन — इन्टरनेट सुविधाहरू रोकिएका छन्। मेश अझै चल्छ।" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor kan geen verbinding maken — internetfuncties zijn gepauzeerd. mesh werkt nog." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor nie może się połączyć — funkcje internetowe są wstrzymane. sieć mesh nadal działa." + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "o tor não consegue ligar-se — as funções de internet estão em pausa. a mesh continua a funcionar." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "o tor não consegue se conectar — os recursos de internet estão pausados. a mesh continua funcionando." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor не может подключиться — интернет-функции приостановлены. mesh-сеть продолжает работать." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor kan inte ansluta — internetfunktioner är pausade. mesh fungerar fortfarande." + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor இணைக்க முடியவில்லை — இணைய அம்சங்கள் இடைநிறுத்தப்பட்டுள்ளன. மெஷ் தொடர்ந்து இயங்குகிறது." + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor เชื่อมต่อไม่ได้ — ฟีเจอร์อินเทอร์เน็ตหยุดชั่วคราว เมชยังใช้งานได้" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor bağlanamıyor — internet özellikleri duraklatıldı. mesh çalışmaya devam ediyor." + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor не може під'єднатися — інтернет-функції призупинено. mesh-мережа далі працює." + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor منسلک نہیں ہو پا رہا — انٹرنیٹ فیچرز رکے ہوئے ہیں۔ میش بدستور کام کرتا ہے۔" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor không thể kết nối — các tính năng internet tạm dừng. mesh vẫn hoạt động." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor 无法连接 — 互联网功能已暂停。网状网络仍可使用。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor 無法連線 — 網際網路功能已暫停。網狀網路仍可使用。" + } + } + } + }, "content.banner.panic_blocked" : { "comment" : "Banner shown when a panic wipe did not fully commit; relaunching the app retries the wipe", "extractionState" : "manual", diff --git a/bitchat/Utils/SystemSettings.swift b/bitchat/Utils/SystemSettings.swift index ab552e07..2a739ea9 100644 --- a/bitchat/Utils/SystemSettings.swift +++ b/bitchat/Utils/SystemSettings.swift @@ -14,17 +14,22 @@ import AppKit enum SystemSettings { case bluetooth + /// The radio power toggle, distinct from the `.bluetooth` privacy + /// permission anchor: an already-authorized person whose radio is + /// switched off can't fix anything from the privacy pane. + case bluetoothPower case location case microphone #if os(macOS) private static let baseURL = "x-apple.systempreferences:com.apple.preference.security" - private var macPrivacyAnchor: String { + private var macURLString: String { switch self { - case .bluetooth: "Privacy_Bluetooth" - case .location: "Privacy_LocationServices" - case .microphone: "Privacy_Microphone" + case .bluetooth: "\(Self.baseURL)?Privacy_Bluetooth" + case .bluetoothPower: "x-apple.systempreferences:com.apple.BluetoothSettings" + case .location: "\(Self.baseURL)?Privacy_LocationServices" + case .microphone: "\(Self.baseURL)?Privacy_Microphone" } } #endif @@ -35,8 +40,7 @@ enum SystemSettings { UIApplication.shared.open(url) } #elseif os(macOS) - let urlString = "\(Self.baseURL)?\(macPrivacyAnchor)" - if let url = URL(string: urlString) { + if let url = URL(string: macURLString) { NSWorkspace.shared.open(url) } #endif diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 7dd3dd8f..63fba0f8 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -367,6 +367,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage var torRestartPending: Bool = false // Announce a stalled bootstrap once per attempt, not once per poll. var torStallAnnounced: Bool = false + // Live "tor is blocked" state for the connectivity banner. The system + // message above only reaches geohash timelines; this is the chrome-level + // signal that stays up until tor actually gets through. + @Published var torBlocked: Bool = false // Ensure we set up DM subscription only once per app session var nostrHandlersSetup: Bool = false var geoChannelCoordinator: GeoChannelCoordinator? diff --git a/bitchat/ViewModels/Extensions/ChatViewModel+Tor.swift b/bitchat/ViewModels/Extensions/ChatViewModel+Tor.swift index 3bf7a5a1..3c1f9005 100644 --- a/bitchat/ViewModels/Extensions/ChatViewModel+Tor.swift +++ b/bitchat/ViewModels/Extensions/ChatViewModel+Tor.swift @@ -40,6 +40,7 @@ extension ChatViewModel { @objc func handleTorDidBecomeReady() { Task { @MainActor in self.torStallAnnounced = false + self.torBlocked = false // Only announce "restarted" if we actually restarted this session if self.torRestartPending { // Post only in geohash channels (queue if not active) @@ -68,6 +69,7 @@ extension ChatViewModel { // runtime preference is what says whether anyone is waiting on // Tor. Turning Tor off mid-bootstrap must not read as blocking. guard NetworkActivationService.persistedTorPreference() else { return } + self.torBlocked = true guard !self.torStallAnnounced else { return } self.torStallAnnounced = true self.addGeohashOnlySystemMessage( @@ -86,6 +88,9 @@ extension ChatViewModel { self.torInitialReadyAnnounced = false self.torRestartPending = false self.torStallAnnounced = false + // Turning tor off means nobody is waiting on it; turning it on + // starts a fresh attempt. Either way the stall banner resets. + self.torBlocked = false } } } diff --git a/bitchat/Views/Components/ConnectivityStatusBanner.swift b/bitchat/Views/Components/ConnectivityStatusBanner.swift new file mode 100644 index 00000000..4caedccf --- /dev/null +++ b/bitchat/Views/Components/ConnectivityStatusBanner.swift @@ -0,0 +1,116 @@ +// +// ConnectivityStatusBanner.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import CoreBluetooth +import SwiftUI + +/// The degraded state a banner should surface, in priority order. Bluetooth +/// outranks tor: without the radio the app's core promise (mesh) is dead, +/// while a tor stall only pauses internet features. +enum ConnectivityIssue: Equatable { + case bluetoothOff + case bluetoothDenied + case bluetoothUnsupported + case torBlocked + + /// Pure resolution so the banner's priority logic is testable without a + /// view. `.unknown`/`.resetting` deliberately resolve to nil — the radio + /// is still starting and a flash of "bluetooth is off" at launch would + /// be the same false signal this banner exists to prevent. + static func resolve(bluetoothState: CBManagerState, torBlocked: Bool) -> ConnectivityIssue? { + switch bluetoothState { + case .poweredOff: return .bluetoothOff + case .unauthorized: return .bluetoothDenied + case .unsupported: return .bluetoothUnsupported + case .poweredOn, .unknown, .resetting: break + @unknown default: break + } + return torBlocked ? .torBlocked : nil + } +} + +/// Persistent one-line banner under the header while connectivity is +/// degraded. The Bluetooth alert is a one-shot modal — once dismissed, the +/// app used to look completely normal (empty timeline, radar sweeping) +/// while the radio was off. Tor stalls were only ever announced inside +/// geohash timelines. This banner is the always-visible truth; it renders +/// nothing when everything is fine. +struct ConnectivityStatusBanner: View { + let issue: ConnectivityIssue + @ThemedPalette private var palette + + private var message: String { + switch issue { + case .bluetoothOff: + return String( + localized: "content.banner.bluetooth_off", + defaultValue: "bluetooth is off — nobody nearby can reach you. tap to fix.", + comment: "Persistent banner while Bluetooth is switched off; tapping opens system settings" + ) + case .bluetoothDenied: + return String( + localized: "content.banner.bluetooth_denied", + defaultValue: "bluetooth access denied — mesh is offline. tap to open settings.", + comment: "Persistent banner while Bluetooth permission is denied; tapping opens system settings" + ) + case .bluetoothUnsupported: + return String( + localized: "content.banner.bluetooth_unsupported", + defaultValue: "no bluetooth on this device — mesh is unavailable. location channels still work over the internet.", + comment: "Persistent banner on devices without Bluetooth support" + ) + case .torBlocked: + return String( + localized: "content.banner.tor_blocked", + defaultValue: "tor can't connect — internet features are paused. mesh still works.", + comment: "Persistent banner while Tor bootstrap has stalled, likely because the network blocks it" + ) + } + } + + /// Bluetooth problems deep-link to where the fix actually lives — + /// powered-off goes to the radio controls, denied to the privacy + /// permission pane. The tor stall has no in-app remedy (the network is + /// blocking it), so that banner is inert. + private var settingsDestination: SystemSettings? { + switch issue { + case .bluetoothOff: return .bluetoothPower + case .bluetoothDenied: return .bluetooth + case .bluetoothUnsupported, .torBlocked: return nil + } + } + + var body: some View { + Group { + if let destination = settingsDestination { + Button(action: { destination.open() }) { + label + } + .buttonStyle(.plain) + } else { + label + } + } + } + + private var label: some View { + HStack(alignment: .firstTextBaseline, spacing: 6) { + Image(systemName: issue == .torBlocked ? "network.slash" : "antenna.radiowaves.left.and.right.slash") + .bitchatFont(size: 11, weight: .semibold) + Text(message) + .bitchatFont(size: 11) + .fixedSize(horizontal: false, vertical: true) + } + .foregroundColor(palette.alertRed) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color.red.opacity(0.12)) + .accessibilityElement(children: .combine) + } +} diff --git a/bitchat/Views/Components/MeshEmptyStateView.swift b/bitchat/Views/Components/MeshEmptyStateView.swift index a244d5f1..b225927d 100644 --- a/bitchat/Views/Components/MeshEmptyStateView.swift +++ b/bitchat/Views/Components/MeshEmptyStateView.swift @@ -19,6 +19,10 @@ struct MeshEmptyStateView: View { /// intro/help narration (the timeline isn't empty) and shrinks the /// radar, keeping the sightings tally and the live hints visible. var compact: Bool = false + /// Whether the radio can actually scan. With Bluetooth off or denied the + /// sweep must not run — an animated "searching" over a dead radio is an + /// actively false status display. Defaults to true for previews. + var bluetoothAvailable: Bool = true @EnvironmentObject private var locationChannelsModel: LocationChannelsModel @EnvironmentObject private var peerListModel: PeerListModel @@ -69,8 +73,12 @@ struct MeshEmptyStateView: View { /// The radar means "searching for people": once anyone is connected or /// reachable on the mesh, the search is over and the sweep goes away. + /// And it only means that while the radio is actually on — the + /// connectivity banner carries the message when it isn't. private var isSearchingForPeers: Bool { - peerListModel.connectedMeshPeerCount == 0 && peerListModel.reachableMeshPeerCount == 0 + bluetoothAvailable + && peerListModel.connectedMeshPeerCount == 0 + && peerListModel.reachableMeshPeerCount == 0 } var body: some View { diff --git a/bitchat/Views/ContentSheetViews.swift b/bitchat/Views/ContentSheetViews.swift index 18c0f148..cf62debb 100644 --- a/bitchat/Views/ContentSheetViews.swift +++ b/bitchat/Views/ContentSheetViews.swift @@ -183,6 +183,18 @@ struct ContentPeopleSheetView: View { .environmentObject(verificationModel) } } + // This sheet covers the root header where the connectivity + // banner lives; a person sitting in the people list or a DM + // would otherwise get no persistent signal that the radio is + // off or tor is stalled. Mirror it here. + .safeAreaInset(edge: .top, spacing: 0) { + if let issue = ConnectivityIssue.resolve( + bluetoothState: appChromeModel.bluetoothState, + torBlocked: appChromeModel.torBlocked + ) { + ConnectivityStatusBanner(issue: issue) + } + } } .themedSheetBackground() .foregroundColor(palette.primary) @@ -285,7 +297,10 @@ struct ContentPeopleSheetView: View { isPresented: bluetoothAlertBinding ) { Button("content.alert.bluetooth_required.settings") { - SystemSettings.bluetooth.open() + // Powered-off needs the radio controls, not the privacy pane. + (appChromeModel.bluetoothState == .poweredOff + ? SystemSettings.bluetoothPower + : SystemSettings.bluetooth).open() } Button("common.ok", role: .cancel) {} } message: { diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index 6ef956ed..48d353c3 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -409,7 +409,10 @@ struct ContentView: View { }) .alert("content.alert.bluetooth_required.title", isPresented: rootBluetoothAlertBinding) { Button("content.alert.bluetooth_required.settings") { - SystemSettings.bluetooth.open() + // Powered-off needs the radio controls, not the privacy pane. + (appChromeModel.bluetoothState == .poweredOff + ? SystemSettings.bluetoothPower + : SystemSettings.bluetooth).open() } Button("common.ok", role: .cancel) {} } message: { @@ -499,9 +502,18 @@ struct ContentView: View { headerPeerCountFontSize: headerPeerCountFontSize ) + // Failed-wipe outranks connectivity: "your data may still be + // here" matters more than "the radio is off". if appChromeModel.panicWipeBlocked { PanicWipeBlockedBanner() } + + if let issue = ConnectivityIssue.resolve( + bluetoothState: appChromeModel.bluetoothState, + torBlocked: appChromeModel.torBlocked + ) { + ConnectivityStatusBanner(issue: issue) + } } // Hosted here rather than on the logo Text so the pending dialog // survives whatever happens to the header chrome. diff --git a/bitchat/Views/MessageListView.swift b/bitchat/Views/MessageListView.swift index 6e1c6b95..1894a347 100644 --- a/bitchat/Views/MessageListView.swift +++ b/bitchat/Views/MessageListView.swift @@ -6,6 +6,7 @@ // import BitFoundation +import CoreBluetooth import SwiftUI private struct MessageDisplayItem: Identifiable { @@ -176,7 +177,7 @@ struct MessageListView: View { // sightings, live hints) stays visible below it instead of // vanishing the moment echoes exist. if privatePeer == nil, showsAmbientFooter(messageItems: messageItems) { - MeshEmptyStateView(compact: true) + MeshEmptyStateView(compact: true, bluetoothAvailable: bluetoothCanScan) .padding(.horizontal, 12) .padding(.top, 20) .padding(.bottom, 8) @@ -296,6 +297,15 @@ struct MessageListView: View { } private extension MessageListView { + /// Whether the radio could be scanning at all — the empty-state radar + /// must not sweep over a switched-off or denied radio. + var bluetoothCanScan: Bool { + switch appChromeModel.bluetoothState { + case .poweredOff, .unauthorized, .unsupported: return false + default: return true + } + } + var currentContextKey: String { if let peer = privatePeer { return "dm:\(peer)" @@ -373,7 +383,10 @@ private extension MessageListView { VStack(alignment: .leading, spacing: 6) { switch locationChannelsModel.selectedChannel { case .mesh: - MeshEmptyStateView(fillHeight: max(0, fillHeight - 24)) + MeshEmptyStateView( + fillHeight: max(0, fillHeight - 24), + bluetoothAvailable: bluetoothCanScan + ) case .location(let channel): emptyStateLine( String( diff --git a/bitchatTests/ChatViewModelTorTests.swift b/bitchatTests/ChatViewModelTorTests.swift index d4697dd8..d5eba112 100644 --- a/bitchatTests/ChatViewModelTorTests.swift +++ b/bitchatTests/ChatViewModelTorTests.swift @@ -165,14 +165,33 @@ struct ChatViewModelTorTests { viewModel.torStatusAnnounced = true viewModel.torInitialReadyAnnounced = true viewModel.torRestartPending = true + viewModel.torBlocked = true // Action viewModel.handleTorPreferenceChanged(Notification(name: .init("test"))) try? await Task.sleep(nanoseconds: 100_000_000) - // Assert: all flags reset + // Assert: all flags reset — torBlocked drives the connectivity + // banner; toggling tor off must not leave a stale "tor can't + // connect" line for a network nobody is waiting on. #expect(!viewModel.torStatusAnnounced) #expect(!viewModel.torInitialReadyAnnounced) #expect(!viewModel.torRestartPending) + #expect(!viewModel.torBlocked) + } + + @Test @MainActor + func handleTorDidBecomeReady_clearsBlockedBanner() async { + let (viewModel, _) = makeTestableViewModel() + + // Setup: a stalled bootstrap raised the banner, then tor got through. + viewModel.torBlocked = true + + // Action + viewModel.handleTorDidBecomeReady() + try? await Task.sleep(nanoseconds: 100_000_000) + + // Assert + #expect(!viewModel.torBlocked) } } diff --git a/bitchatTests/ConnectivityStatusTests.swift b/bitchatTests/ConnectivityStatusTests.swift new file mode 100644 index 00000000..93ecc2fb --- /dev/null +++ b/bitchatTests/ConnectivityStatusTests.swift @@ -0,0 +1,31 @@ +// +// ConnectivityStatusTests.swift +// bitchatTests +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import CoreBluetooth +import Testing +@testable import bitchat + +struct ConnectivityIssueTests { + + @Test("Bluetooth problems outrank a tor stall; healthy state shows nothing") + func resolvePrioritizesBluetoothOverTor() { + #expect(ConnectivityIssue.resolve(bluetoothState: .poweredOff, torBlocked: true) == .bluetoothOff) + #expect(ConnectivityIssue.resolve(bluetoothState: .unauthorized, torBlocked: false) == .bluetoothDenied) + #expect(ConnectivityIssue.resolve(bluetoothState: .unsupported, torBlocked: false) == .bluetoothUnsupported) + #expect(ConnectivityIssue.resolve(bluetoothState: .poweredOn, torBlocked: true) == .torBlocked) + #expect(ConnectivityIssue.resolve(bluetoothState: .poweredOn, torBlocked: false) == nil) + } + + @Test("A starting radio must not flash a false 'bluetooth is off' banner") + func resolveStaysQuietWhileRadioIsStarting() { + #expect(ConnectivityIssue.resolve(bluetoothState: .unknown, torBlocked: false) == nil) + #expect(ConnectivityIssue.resolve(bluetoothState: .resetting, torBlocked: false) == nil) + // A tor stall still surfaces once known, even while the radio starts. + #expect(ConnectivityIssue.resolve(bluetoothState: .unknown, torBlocked: true) == .torBlocked) + } +} From dba67c1466b79855cb513ed68b07ae91c72ccfe2 Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Mon, 10 Aug 2026 09:12:50 +0200 Subject: [PATCH 11/11] Deflake AppArchitectureTests: raise the local waitUntil to settleTimeout (#1653) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Deflake AppArchitectureTests: raise the local waitUntil to settleTimeout The file-local waitUntil defaulted to 3s — below the documented minimumSettleTimeout floor — and the recent-chats geo-dedup wait missed it on a loaded runner right after merge (the Combine hop through receive(on: .main) was starved). Every caller waits for a condition to become true, so green runs return immediately and never pay the 30s deadline. Co-Authored-By: Claude Fable 5 * Fix the real geo-dedup race: re-assert tracker state on each poll The 30s deadline didn't help — the recent-chats geo-dedup wait wasn't slow, it was permanently broken under parallel swift-test load: the view model's own channel binding delivers its initial .mesh selection asynchronously and resets the active participant geohash when it lands (GeohashSubscriptionManager → setActiveParticipantGeohash(nil)), wiping the test's setup so the dedup could never happen. Reproduced locally with `swift test --parallel`; standalone runs never hit it. The wait now re-asserts setActiveGeohash + recordParticipant on every poll (both idempotent) — the same self-healing pattern the geohash timeline test already uses for singleton interference. Interference heals on the next poll; a genuine dedup failure still times out. 6× full parallel runs green. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: jack Co-authored-by: Claude Fable 5 --- bitchatTests/AppArchitectureTests.swift | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/bitchatTests/AppArchitectureTests.swift b/bitchatTests/AppArchitectureTests.swift index 1ec52532..747e737b 100644 --- a/bitchatTests/AppArchitectureTests.swift +++ b/bitchatTests/AppArchitectureTests.swift @@ -69,7 +69,11 @@ private func makeArchitectureMessage( @MainActor private func waitUntil( - timeoutNanoseconds: UInt64 = 3_000_000_000, + // Settle deadline, not a latency budget (see TestConstants.settleTimeout): + // the old 3s default flaked on CI the moment a Combine hop through + // receive(on: .main) was starved. Every caller waits for a condition to + // become true, so passing runs return immediately. + timeoutNanoseconds: UInt64 = UInt64(TestConstants.settleTimeout * 1_000_000_000), pollNanoseconds: UInt64 = 20_000_000, _ condition: @escaping @MainActor () -> Bool ) async { @@ -580,11 +584,19 @@ struct AppArchitectureTests { // While that person is visible in the geohash roster, the chat row // collapses — same absent-from-rosters contract as mesh. - viewModel.participantTracker.setActiveGeohash("u4pruy") - viewModel.participantTracker.recordParticipant(pubkeyHex: pubkeyHex, geohash: "u4pruy") - + // + // Re-assert the tracker state on every poll: the view model's own + // channel binding delivers its initial .mesh selection asynchronously + // and resets the active participant geohash when it lands + // (GeohashSubscriptionManager.setActiveParticipantGeohash(nil)) — on + // a loaded parallel runner that reset arrives AFTER this setup and + // the dedup can never happen. Both calls are idempotent, so the + // interference heals on the next poll while a genuine dedup failure + // still times out. await waitUntil { - !peerListModel.recentChatRows.contains { $0.peerID == geoDMPeer } + viewModel.participantTracker.setActiveGeohash("u4pruy") + viewModel.participantTracker.recordParticipant(pubkeyHex: pubkeyHex, geohash: "u4pruy") + return !peerListModel.recentChatRows.contains { $0.peerID == geoDMPeer } } #expect(!peerListModel.recentChatRows.contains { $0.peerID == geoDMPeer }) #expect(peerListModel.recentChatRows.map(\.peerID) == [offlinePeerID])