From 0152344554e05c4a6f542a37c3ebc7b34b265239 Mon Sep 17 00:00:00 2001 From: Taksh Kothari Date: Sat, 1 Aug 2026 18:21:28 +0530 Subject: [PATCH 01/10] feat: verification seal on private message sender rows (#1573) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: show verification seal on private message sender rows DM-only filled seal next to verified peers' names closes the remaining UI gap from #1439 without dressing public mesh timelines. Co-authored-by: Cursor * fix: skip in-string verified ✓ on private rows with SF Symbol seal Co-authored-by: Cursor * Add verified_sender catalog entry (30 locales) + live seal repaint on verify The accessibility label existed only in code, so VoiceOver read english in 29 locales; the coverage test can't see source-only keys. Also forward peerIdentityStore.$verifiedFingerprints into objectWillChange so toggling verification repaints rows in an open DM instead of waiting for the next unrelated invalidation. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Cursor Co-authored-by: jack Co-authored-by: Claude Fable 5 --- bitchat/App/ConversationUIModel.swift | 21 ++ bitchat/Localizable.xcstrings | 185 ++++++++++++++++++ bitchat/ViewModels/ChatMessageFormatter.swift | 7 +- .../Views/Components/TextMessageView.swift | 9 + bitchat/Views/Media/MediaMessageView.swift | 9 + 5 files changed, 229 insertions(+), 2 deletions(-) diff --git a/bitchat/App/ConversationUIModel.swift b/bitchat/App/ConversationUIModel.swift index 91f0afef..53062d45 100644 --- a/bitchat/App/ConversationUIModel.swift +++ b/bitchat/App/ConversationUIModel.swift @@ -128,6 +128,18 @@ final class ConversationUIModel: ObservableObject { message.sender == currentNickname || message.senderPeerID == chatViewModel.meshService.myPeerID } + /// Whether a private-message row should show the filled verification seal + /// next to the sender name (#1439). Scoped to DMs only — public timelines + /// have different trust semantics and stay undressed. + func showsVerifiedSeal(for message: BitchatMessage) -> Bool { + guard message.isPrivate, + message.sender != "system", + !isSentByCurrentUser(message), + let peerID = message.senderPeerID else { return false } + guard let fingerprint = chatViewModel.getFingerprint(for: peerID) else { return false } + return chatViewModel.peerIdentityStore.isVerified(fingerprint) + } + func senderDisplayName(for peerID: PeerID, fallbackMessages: [BitchatMessage]) -> String? { if peerID.isGeoDM || peerID.isGeoChat { return chatViewModel.geohashDisplayName(for: peerID) @@ -219,6 +231,15 @@ final class ConversationUIModel: ObservableObject { self?.refreshComputedState() } .store(in: &cancellables) + + // Verify/unverify while a DM is open must repaint existing rows — + // showsVerifiedSeal is computed per render, so forward the store change. + chatViewModel.peerIdentityStore.$verifiedFingerprints + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.objectWillChange.send() + } + .store(in: &cancellables) } private func refreshComputedState() { diff --git a/bitchat/Localizable.xcstrings b/bitchat/Localizable.xcstrings index c43da7a2..f869ab85 100644 --- a/bitchat/Localizable.xcstrings +++ b/bitchat/Localizable.xcstrings @@ -26973,6 +26973,191 @@ } } }, + "content.accessibility.verified_sender" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "مرسل موثق" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "যাচাইকৃত প্রেরক" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verifizierter absender" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verified sender" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Remitente verificado" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "فرستنده تأییدشده" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "Beripikadong nagpadala" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Expéditeur vérifié" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "שולח מאומת" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "सत्यापित प्रेषक" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pengirim terverifikasi" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mittente verificato" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "確認済みの送信者" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "확인된 보낸 사람" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pengirim yang disahkan" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "प्रमाणित प्रेषक" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Geverifieerde afzender" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zweryfikowany nadawca" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Remetente verificado" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Remetente verificado" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Подтверждённый отправитель" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verifierad avsändare" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "சரிபார்க்கப்பட்ட அனுப்புநர்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ผู้ส่งที่ยืนยันแล้ว" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Doğrulanmış gönderen" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Підтверджений відправник" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "تصدیق شدہ مرسل" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Người gửi đã xác minh" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已验证的发送者" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "已驗證的傳送者" + } + } + } + }, "content.accessibility.view_fingerprint_hint" : { "extractionState" : "manual", "localizations" : { diff --git a/bitchat/ViewModels/ChatMessageFormatter.swift b/bitchat/ViewModels/ChatMessageFormatter.swift index ae663f84..1a00315b 100644 --- a/bitchat/ViewModels/ChatMessageFormatter.swift +++ b/bitchat/ViewModels/ChatMessageFormatter.swift @@ -68,7 +68,10 @@ final class ChatMessageFormatter { suffixStyle.foregroundColor = baseColor.opacity(0.6) result.append(AttributedString(suffix).mergingAttributes(suffixStyle)) } - if isVerifiedSender { + // Private rows render a filled SF Symbol seal beside the lock + // (TextMessageView / MediaMessageView); skip the in-string ✓ there + // so verified DMs don't show two markers. + if isVerifiedSender, !message.isPrivate { appendVerifiedSeal(to: &result, baseColor: baseColor, design: design) } result.append(AttributedString("> ").mergingAttributes(senderStyle)) @@ -388,7 +391,7 @@ final class ChatMessageFormatter { suffixStyle.foregroundColor = baseColor.opacity(0.6) result.append(AttributedString(suffix).mergingAttributes(suffixStyle)) } - if isVerifiedSender { + if isVerifiedSender, !message.isPrivate { appendVerifiedSeal(to: &result, baseColor: baseColor, design: design) } result.append(AttributedString("> ").mergingAttributes(senderStyle)) diff --git a/bitchat/Views/Components/TextMessageView.swift b/bitchat/Views/Components/TextMessageView.swift index a16e3ac6..ed376acf 100644 --- a/bitchat/Views/Components/TextMessageView.swift +++ b/bitchat/Views/Components/TextMessageView.swift @@ -50,6 +50,15 @@ struct TextMessageView: View { .padding(.trailing, 4) .accessibilityHidden(true) } + if conversationUIModel.showsVerifiedSeal(for: message) { + Image(systemName: "checkmark.seal.fill") + .font(.bitchatSystem(size: 8)) + .foregroundColor(Color.green.opacity(0.85)) + .padding(.trailing, 4) + .accessibilityLabel( + String(localized: "content.accessibility.verified_sender", defaultValue: "Verified sender", comment: "Accessibility label for the seal next to a verified peer's name on a private message") + ) + } if message.isBridged { Image(systemName: "network") .font(.bitchatSystem(size: 8)) diff --git a/bitchat/Views/Media/MediaMessageView.swift b/bitchat/Views/Media/MediaMessageView.swift index 2e2d1429..29711f38 100644 --- a/bitchat/Views/Media/MediaMessageView.swift +++ b/bitchat/Views/Media/MediaMessageView.swift @@ -48,6 +48,15 @@ struct MediaMessageView: View { .padding(.trailing, 4) .accessibilityHidden(true) } + if conversationUIModel.showsVerifiedSeal(for: message) { + Image(systemName: "checkmark.seal.fill") + .font(.bitchatSystem(size: 8)) + .foregroundColor(Color.green.opacity(0.85)) + .padding(.trailing, 4) + .accessibilityLabel( + String(localized: "content.accessibility.verified_sender", defaultValue: "Verified sender", comment: "Accessibility label for the seal next to a verified peer's name on a private message") + ) + } VStack(alignment: .leading, spacing: 2) { HStack(alignment: .center, spacing: 4) { Text(conversationUIModel.formatMessageHeader(message, colorScheme: colorScheme, theme: theme)) From 1f59e814f90c3f489f48d68262cb1bf640bf6181 Mon Sep 17 00:00:00 2001 From: Taksh Kothari Date: Sat, 1 Aug 2026 18:21:31 +0530 Subject: [PATCH 02/10] Keyboard navigation for @-mention suggestions (#1542) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Tab and arrow keys for mention autocomplete Highlight the selected suggestion and let Tab accept it. Up/down move the selection when the mention panel is open; Tab otherwise still cycles focus. Co-authored-by: Cursor * Retrigger CI after flaky GeoRelayDirectory iOS test Co-authored-by: Cursor * fix: navigate mention suggestions with the same macOS key monitor as commands Arrow keys never reach SwiftUI while the composer field editor has focus, so adopt the NSEvent local monitor from #1504. Align accept keys (Return or Tab), add Escape to dismiss, and match highlight opacity. Co-authored-by: Cursor * Fix dead key monitor: gate on live state, not a value-captured Bool A synthetic-event harness against the extracted modifier showed the realistic path broken: the panel is hidden when the composer appears, so onChange(of: isActive) ran on the previous render's modifier value and installed a monitor whose closure had captured isActive == false — it passed every key through forever. Arrows/Tab/Escape never worked on a real Mac. Install the monitor once for the view's lifetime and gate each event on an isActive closure that reads the reference-typed model live. Same fix applies to the iOS onKeyPress guards for consistency. Harness now passes all paths including deactivate/reactivate. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Cursor Co-authored-by: jack Co-authored-by: Claude Fable 5 --- bitchat/App/ConversationUIModel.swift | 31 ++++++ bitchat/Views/ContentComposerView.swift | 132 +++++++++++++++++++++++- 2 files changed, 162 insertions(+), 1 deletion(-) diff --git a/bitchat/App/ConversationUIModel.swift b/bitchat/App/ConversationUIModel.swift index 53062d45..4883a04c 100644 --- a/bitchat/App/ConversationUIModel.swift +++ b/bitchat/App/ConversationUIModel.swift @@ -9,6 +9,7 @@ import UIKit final class ConversationUIModel: ObservableObject { @Published private(set) var showAutocomplete = false @Published private(set) var autocompleteSuggestions: [String] = [] + @Published private(set) var selectedAutocompleteIndex = 0 @Published private(set) var currentNickname: String @Published private(set) var isBatchingPublic = false @Published private(set) var canSendMediaInCurrentContext = true @@ -36,6 +37,7 @@ final class ConversationUIModel: ObservableObject { self.isBatchingPublic = chatViewModel.isBatchingPublic self.showAutocomplete = chatViewModel.showAutocomplete self.autocompleteSuggestions = chatViewModel.autocompleteSuggestions + self.selectedAutocompleteIndex = chatViewModel.selectedAutocompleteIndex self.canSendMediaInCurrentContext = chatViewModel.canSendMediaInCurrentContext bind() @@ -104,6 +106,31 @@ final class ConversationUIModel: ObservableObject { chatViewModel.completeNickname(nickname, in: &text) } + /// Accept the currently highlighted mention suggestion, if any. + func completeSelectedSuggestion(in text: inout String) -> Bool { + guard showAutocomplete, + autocompleteSuggestions.indices.contains(selectedAutocompleteIndex) + else { return false } + _ = completeNickname(autocompleteSuggestions[selectedAutocompleteIndex], in: &text) + return true + } + + /// Dismiss the mention suggestion panel without inserting (Escape). + func dismissAutocomplete() { + guard showAutocomplete else { return } + chatViewModel.showAutocomplete = false + chatViewModel.autocompleteSuggestions = [] + chatViewModel.autocompleteRange = nil + chatViewModel.selectedAutocompleteIndex = 0 + } + + func moveAutocompleteSelection(by delta: Int) { + guard showAutocomplete, !autocompleteSuggestions.isEmpty else { return } + let count = min(4, autocompleteSuggestions.count) + let next = (selectedAutocompleteIndex + delta + count) % count + chatViewModel.selectedAutocompleteIndex = next + } + func formatMessage(_ message: BitchatMessage, colorScheme: ColorScheme, theme: AppTheme? = nil) -> AttributedString { chatViewModel.formatMessageAsText(message, colorScheme: colorScheme, theme: theme) } @@ -205,6 +232,10 @@ final class ConversationUIModel: ObservableObject { .receive(on: DispatchQueue.main) .assign(to: &$autocompleteSuggestions) + chatViewModel.$selectedAutocompleteIndex + .receive(on: DispatchQueue.main) + .assign(to: &$selectedAutocompleteIndex) + chatViewModel.$isBatchingPublic .receive(on: DispatchQueue.main) .assign(to: &$isBatchingPublic) diff --git a/bitchat/Views/ContentComposerView.swift b/bitchat/Views/ContentComposerView.swift index b5020980..64021d52 100644 --- a/bitchat/Views/ContentComposerView.swift +++ b/bitchat/Views/ContentComposerView.swift @@ -2,6 +2,9 @@ import SwiftUI #if os(iOS) import UIKit #endif +#if os(macOS) +import AppKit +#endif struct ContentComposerView: View { @EnvironmentObject private var conversationUIModel: ConversationUIModel @@ -29,7 +32,7 @@ struct ContentComposerView: View { VStack(alignment: .leading, spacing: 6) { if conversationUIModel.showAutocomplete && !conversationUIModel.autocompleteSuggestions.isEmpty { VStack(alignment: .leading, spacing: 0) { - ForEach(Array(conversationUIModel.autocompleteSuggestions.prefix(4)), id: \.self) { suggestion in + ForEach(Array(conversationUIModel.autocompleteSuggestions.prefix(4).enumerated()), id: \.element) { index, suggestion in Button(action: { _ = conversationUIModel.completeNickname(suggestion, in: &messageText) }) { @@ -43,6 +46,11 @@ struct ContentComposerView: View { .padding(.horizontal, 12) .padding(.vertical, 3) .frame(maxWidth: .infinity, alignment: .leading) + .background( + index == conversationUIModel.selectedAutocompleteIndex + ? palette.secondary.opacity(0.15) + : Color.clear + ) } .buttonStyle(.plain) } @@ -73,7 +81,28 @@ struct ContentComposerView: View { .textInputAutocapitalization(.sentences) #endif .submitLabel(.send) + .modifier(AutocompleteKeyboardNavigationModifier( + isActive: { conversationUIModel.showAutocomplete + && !conversationUIModel.autocompleteSuggestions.isEmpty }, + onMove: { delta in + conversationUIModel.moveAutocompleteSelection(by: delta) + }, + onAccept: { + conversationUIModel.completeSelectedSuggestion(in: &messageText) + }, + onDismiss: { + conversationUIModel.dismissAutocomplete() + } + )) + // Return while the mention panel is open completes the + // highlight instead of sending — matches command suggestions + // (#1504) and keeps Tab/Return/Escape on one convention. .onSubmit { + if conversationUIModel.showAutocomplete, + !conversationUIModel.autocompleteSuggestions.isEmpty, + conversationUIModel.completeSelectedSuggestion(in: &messageText) { + return + } onSendMessage() // Only the return-key path: it steals focus on iOS, so // every message would cost a tap to reopen the keyboard. @@ -374,3 +403,104 @@ private extension ContentComposerView { ) } } + +/// Arrow/Tab/Return/Escape navigation for the mention suggestion list. +/// +/// Deployment targets are iOS 16 / macOS 13, so `.onKeyPress` (iOS 17 / +/// macOS 14+) is gated and unavailable on the minimum OS. Separately, on +/// macOS the single-line field editor consumes `moveUp:`/`moveDown:` itself, +/// so arrow keys never reach SwiftUI while the composer has focus — the +/// same reason command suggestions (#1504) use an `NSEvent` local monitor. +/// Mentions follow that mechanism on macOS and keep `.onKeyPress` for iOS 17+. +private struct AutocompleteKeyboardNavigationModifier: ViewModifier { + /// Live activity check, not a captured Bool. The macOS monitor closure is + /// registered once for the view's lifetime; a plain `Bool` would freeze + /// the value captured at install time (this is a value type), so a panel + /// that opens after the monitor installs would never intercept a key. + /// The provider closes over the reference-typed model and reads current + /// state on every event. + let isActive: () -> Bool + let onMove: (Int) -> Void + let onAccept: () -> Bool + let onDismiss: () -> Void + + #if os(macOS) + @State private var keyMonitor: Any? + #endif + + func body(content: Content) -> some View { + #if os(macOS) + content + .onAppear { installKeyMonitor() } + .onDisappear { removeKeyMonitor() } + #else + if #available(iOS 17.0, *) { + content + .onKeyPress(.upArrow) { + guard isActive() else { return .ignored } + onMove(-1) + return .handled + } + .onKeyPress(.downArrow) { + guard isActive() else { return .ignored } + onMove(1) + return .handled + } + .onKeyPress(.tab) { + guard isActive() else { return .ignored } + return onAccept() ? .handled : .ignored + } + .onKeyPress(.escape) { + guard isActive() else { return .ignored } + onDismiss() + return .handled + } + } else { + content + } + #endif + } + + #if os(macOS) + private func installKeyMonitor() { + guard keyMonitor == nil else { return } + keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in + handleKeyDown(event) + } + } + + private func removeKeyMonitor() { + if let keyMonitor { + NSEvent.removeMonitor(keyMonitor) + } + keyMonitor = nil + } + + /// Standard autocomplete navigation (aligned with #1504): arrows move + /// the highlight, return/tab insert, escape dismisses. Returning nil + /// consumes the event so return completes instead of sending while the + /// list is up. Inactive monitors pass everything through. + private func handleKeyDown(_ event: NSEvent) -> NSEvent? { + guard isActive(), + event.modifierFlags.intersection([.command, .option, .control]).isEmpty else { + return event + } + + switch event.keyCode { + case 126: // up arrow + onMove(-1) + return nil + case 125: // down arrow + onMove(1) + return nil + case 36, 48: // return, tab + return onAccept() ? nil : event + case 53: // escape + onDismiss() + return nil + default: + return event + } + } + #endif +} 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 03/10] 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 04/10] 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 05/10] 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 06/10] 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 07/10] 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 08/10] 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 09/10] 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 10/10] 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 {