From 3e46829f32fa07c96127ba03e3201d8c041e7a45 Mon Sep 17 00:00:00 2001 From: jack Date: Tue, 11 Aug 2026 09:17:55 +0200 Subject: [PATCH 1/6] Add a read-receipt toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read receipts were unconditional: on chat open and on didBecomeActive, twice. A receipt is a presence oracle — it says the person is awake, holding their phone, and opened the app at that exact moment, which is precisely the metadata the stated threat model says someone should be able to withhold. Every mainstream messenger ships this switch. - ReadReceiptSettings (default ON = existing behavior), toggle in the settings privacy section, reset on panic wipe. - All four origination paths gated: mesh/nostr routing, direct mesh receipts, geohash receipts, and PrivateChatManager's read pass. Withheld receipts are still recorded locally as sent, so re-enabling the setting never fires a retroactive burst disclosing past reads. - Only outbound receipts are affected; receipts from others display. - 2 strings x 30 locales; tests pin the default, the reset, and that nothing reaches the transport while off. Co-Authored-By: Claude Fable 5 --- bitchat/Localizable.xcstrings | 372 ++++++++++++++++++ bitchat/Services/PrivateChatManager.swift | 10 + bitchat/Services/ReadReceiptSettings.swift | 47 +++ .../ChatPrivateConversationCoordinator.swift | 8 +- bitchat/ViewModels/ChatViewModel.swift | 6 + bitchat/Views/AppInfoView.swift | 17 + bitchatTests/ChatViewModelTests.swift | 53 +++ 7 files changed, 512 insertions(+), 1 deletion(-) create mode 100644 bitchat/Services/ReadReceiptSettings.swift diff --git a/bitchat/Localizable.xcstrings b/bitchat/Localizable.xcstrings index e498a20e..ffa73e1d 100644 --- a/bitchat/Localizable.xcstrings +++ b/bitchat/Localizable.xcstrings @@ -15253,6 +15253,378 @@ } } }, + "app_info.settings.read_receipts.subtitle" : { + "comment" : "Subtitle explaining what the read-receipt setting shares and what turning it off withholds", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "يتيح للآخرين معرفة متى قرأت رسائلهم الخاصة. الإيصال يكشف أيضًا أنك كنت مستيقظًا وفتحت التطبيق في تلك اللحظة — أوقف هذا الخيار لتُبقي نشاط قراءتك لنفسك. ستظل ترى الإيصالات التي يرسلها الآخرون." + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "অন্যরা দেখতে পাবে আপনি কখন তাদের ব্যক্তিগত মেসেজ পড়েছেন। রসিদটি এটাও জানিয়ে দেয় যে সেই মুহূর্তে আপনি জেগে ছিলেন এবং অ্যাপটি খুলেছিলেন — আপনার পড়ার তথ্য নিজের কাছে রাখতে এটি বন্ধ করুন। অন্যরা যে রসিদ পাঠায় তা আপনি তবুও দেখতে পাবেন।" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "lässt andere sehen, wann du ihre privaten nachrichten gelesen hast. eine bestätigung verrät auch, dass du in dem moment wach warst und die app geöffnet hast — schalte das aus, um deine leseaktivität für dich zu behalten. bestätigungen von anderen siehst du weiterhin." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "lets people see when you've read their private messages. a receipt also says you were awake and opened the app at that moment — turn this off to keep your reading activity to yourself. you'll still see receipts others send." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "permite que los demás vean cuándo has leído sus mensajes privados. una confirmación también revela que no estabas durmiendo y abriste la app en ese momento — desactívalo para guardarte tu actividad de lectura. seguirás viendo las confirmaciones que envíen los demás." + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "به دیگران اجازه می‌دهد ببینند کی پیام‌های خصوصی‌شان را خوانده‌ای. رسید همچنین لو می‌دهد که در آن لحظه بیدار بوده‌ای و برنامه را باز کرده‌ای — برای اینکه فعالیت خواندنت را برای خودت نگه داری، این را خاموش کن. رسیدهایی را که دیگران می‌فرستند همچنان می‌بینی." + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "hinahayaan ang ibang tao na makita kung kailan mo nabasa ang kanilang mga pribadong mensahe. sinasabi rin ng receipt na gising ka at binuksan mo ang app sa sandaling iyon — i-off ito para sa iyo lang ang iyong pagbabasa. makikita mo pa rin ang mga receipt na ipinapadala ng iba." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "permet aux autres de voir quand tu as lu leurs messages privés. une confirmation révèle aussi que tu ne dormais pas et que tu as ouvert l'app à ce moment-là — désactive cette option pour garder ton activité de lecture pour toi. tu verras toujours les confirmations envoyées par les autres." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "מאפשר לאחרים לראות מתי קראת את ההודעות הפרטיות שלהם. האישור גם מגלה שלא ישנת ושפתחת את האפליקציה באותו רגע — אפשר לכבות את זה כדי לשמור את פעילות הקריאה לעצמך. אישורים שאחרים שולחים עדיין יופיעו אצלך." + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "इससे लोग देख पाते हैं कि आपने उनके निजी मैसेज कब पढ़े। रसीद यह भी बता देती है कि उस पल आप जागे हुए थे और आपने ऐप खोला था — अपनी पढ़ने की गतिविधि अपने तक रखने के लिए इसे बंद करें। दूसरों की भेजी रसीदें आपको फिर भी दिखेंगी।" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "memungkinkan orang lain melihat kapan kamu membaca pesan pribadi mereka. laporan ini juga menandakan bahwa kamu sedang terjaga dan membuka aplikasi saat itu — matikan untuk menyimpan aktivitas membacamu untuk dirimu sendiri. kamu tetap akan melihat laporan dibaca yang dikirim orang lain." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "permette agli altri di vedere quando hai letto i loro messaggi privati. una conferma rivela anche che in quel momento non stavi dormendo e hai aperto l'app — disattivala per tenere per te la tua attività di lettura. continuerai a vedere le conferme inviate dagli altri." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "相手のプライベートメッセージをいつ読んだかが相手にわかるようになります。既読通知は、その瞬間に起きていてアプリを開いていたことも伝えてしまいます。オフにすれば、読んだかどうかを自分だけの秘密にできます。オフにしても、ほかの人が送る既読通知は引き続き表示されます。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "내가 개인 메시지를 언제 읽었는지 상대가 알 수 있어요. 읽음 확인은 그 순간 깨어 있었고 앱을 열었다는 것까지 알려줘요 — 읽은 기록을 나만 알고 싶다면 꺼 두세요. 꺼도 다른 사람이 보내는 읽음 확인은 계속 보여요." + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "membenarkan orang lain melihat bila kamu membaca mesej peribadi mereka. resit itu juga menunjukkan yang kamu sedang berjaga dan membuka aplikasi pada saat itu — matikannya untuk menyimpan aktiviti pembacaan untuk diri sendiri. kamu tetap akan nampak resit yang dihantar orang lain." + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "यसले अरूलाई तपाईंले उनीहरूका निजी सन्देश कहिले पढ्नुभयो भन्ने देखाउँछ। रसिदले त्यस क्षण तपाईं ब्युँझिरहनुभएको र एप खोल्नुभएको कुरा पनि बताउँछ — आफ्नो पढाइ आफैसँग राख्न यसलाई बन्द गर्नुहोस्। अरूले पठाएका रसिदहरू भने तपाईंले अझै देख्नुहुनेछ।" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "laat anderen zien wanneer je hun privéberichten hebt gelezen. een bevestiging verraadt ook dat je op dat moment wakker was en de app had geopend — zet dit uit om je leesactiviteit voor jezelf te houden. bevestigingen van anderen blijf je gewoon zien." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "pozwala innym widzieć, kiedy przeczytasz ich prywatne wiadomości. potwierdzenie zdradza też, że w danym momencie nie śpisz i masz otwartą aplikację — wyłącz to, aby zachować swoje czytanie dla siebie. potwierdzenia od innych nadal będziesz widzieć." + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "permite que as outras pessoas vejam quando leste as mensagens privadas delas. uma confirmação também revela que não estavas a dormir e que abriste a app nesse momento — desativa isto para guardares a tua atividade de leitura para ti. continuas a ver as confirmações que os outros enviam." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "permite que as pessoas vejam quando você leu as mensagens privadas delas. uma confirmação também revela que você não estava dormindo e abriu o app naquele momento — desative para manter sua atividade de leitura só com você. você continua vendo as confirmações que os outros enviam." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "другие смогут видеть, когда ты читаешь их личные сообщения. отчёт заодно выдаёт, что в этот момент ты не спишь и у тебя открыто приложение — выключи, чтобы оставить свою активность чтения при себе. отчёты от других ты всё равно будешь видеть." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "låter andra se när du har läst deras privata meddelanden. ett läskvitto avslöjar också att du var vaken och öppnade appen just då — stäng av det här för att hålla din läsaktivitet för dig själv. du ser fortfarande kvitton som andra skickar." + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "நீங்கள் அவர்களின் தனிப்பட்ட செய்திகளை எப்போது படித்தீர்கள் என்பதை மற்றவர்கள் பார்க்க முடியும். அந்த நேரத்தில் நீங்கள் விழித்திருந்து ஆப்பைத் திறந்தீர்கள் என்பதையும் ரசீது வெளிப்படுத்தும் — உங்கள் படிக்கும் செயல்பாட்டை உங்களிடமே வைத்திருக்க இதை அணைக்கவும். மற்றவர்கள் அனுப்பும் ரசீதுகளை நீங்கள் தொடர்ந்து பார்ப்பீர்கள்." + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ให้คนอื่นเห็นว่าคุณอ่านข้อความส่วนตัวของพวกเขาเมื่อไหร่ รายงานนี้ยังบอกด้วยว่าตอนนั้นคุณตื่นอยู่และเปิดแอปอยู่ — ปิดไว้ถ้าอยากเก็บเรื่องการอ่านไว้กับตัวเอง คุณจะยังเห็นรายงานการอ่านที่คนอื่นส่งมาเหมือนเดิม" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "insanların özel mesajlarını ne zaman okuduğunu görmesine izin verir. okundu bilgisi ayrıca o an uyanık olduğunu ve uygulamayı açtığını da ele verir — okuma etkinliğini kendine saklamak için bunu kapat. başkalarının gönderdiği okundu bilgilerini yine de görürsün." + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "інші бачитимуть, коли ти читаєш їхні особисті повідомлення. звіт також видає, що тієї миті ти не спиш і в тебе відкритий застосунок — вимкни, щоб залишити свою активність читання при собі. звіти від інших ти все одно бачитимеш." + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "اس سے لوگ دیکھ سکتے ہیں کہ آپ نے ان کے نجی پیغامات کب پڑھے۔ رسید یہ بھی بتا دیتی ہے کہ اس لمحے آپ جاگ رہے تھے اور ایپ کھولی تھی — اپنی پڑھنے کی سرگرمی اپنے تک رکھنے کے لیے اسے بند کر دیں۔ دوسروں کی بھیجی ہوئی رسیدیں آپ کو پھر بھی نظر آئیں گی۔" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "cho người khác thấy khi nào bạn đã đọc tin nhắn riêng của họ. xác nhận này cũng tiết lộ rằng lúc đó bạn đang thức và mở ứng dụng — tắt đi để giữ chuyện đọc tin cho riêng mình. bạn vẫn thấy xác nhận mà người khác gửi." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "让别人看到你何时读过他们的私信。回执还会透露你在那一刻醒着并打开了应用——关闭后可以把你的阅读动态留给自己。别人发来的已读回执你仍然可以看到。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "讓別人看到你何時讀過他們的私訊。回執也會透露你在那一刻醒著並開啟了應用程式——關閉後可將你的閱讀動態留給自己。別人傳來的已讀回執你仍然看得到。" + } + } + } + }, + "app_info.settings.read_receipts.title" : { + "comment" : "Title of the setting that controls whether read receipts are sent for private messages", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "إرسال إيصالات القراءة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "পঠিত রসিদ পাঠান" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "lesebestätigungen senden" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "send read receipts" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "enviar confirmaciones de lectura" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "ارسال رسید خواندن" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "magpadala ng mga read receipt" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "envoyer des confirmations de lecture" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "שליחת אישורי קריאה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "पठन रसीदें भेजें" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "kirim laporan dibaca" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "invia conferme di lettura" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "既読通知を送信" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "읽음 확인 보내기" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "hantar resit dibaca" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "पढेको रसिद पठाउनुहोस्" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "leesbevestigingen versturen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "wysyłaj potwierdzenia odczytu" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "enviar confirmações de leitura" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "enviar confirmações de leitura" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "отправлять отчёты о прочтении" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "skicka läskvitton" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "படித்ததற்கான ரசீதுகளை அனுப்பு" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ส่งรายงานการอ่าน" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "okundu bilgisi gönder" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "надсилати звіти про прочитання" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "پڑھنے کی رسیدیں بھیجیں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "gửi xác nhận đã đọc" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "发送已读回执" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "傳送已讀回執" + } + } + } + }, "content.delivery.reason.legacy_media_consent_required" : { "comment" : "Failure reason when a legacy private-media send lacks per-send consent", "extractionState" : "manual", diff --git a/bitchat/Services/PrivateChatManager.swift b/bitchat/Services/PrivateChatManager.swift index dcf4631b..3141ec0b 100644 --- a/bitchat/Services/PrivateChatManager.swift +++ b/bitchat/Services/PrivateChatManager.swift @@ -248,11 +248,21 @@ final class PrivateChatManager: ObservableObject { // MARK: - Private Methods + /// Injectable so tests assert the gated behavior without racing other + /// suites through the shared UserDefaults-backed setting. + var sendsReadReceipts: () -> Bool = { ReadReceiptSettings.sendReadReceipts } + private func sendReadReceipt(for message: BitchatMessage) { guard !sentReadReceipts.contains(message.id), let senderPeerID = message.senderPeerID else { return } + // Withheld receipts are still claimed below as sent: re-enabling the + // setting must never fire a retroactive burst disclosing past reads. + guard sendsReadReceipts() else { + sentReadReceipts.insert(message.id) + return + } // Create read receipt using the simplified method let receipt = ReadReceipt( diff --git a/bitchat/Services/ReadReceiptSettings.swift b/bitchat/Services/ReadReceiptSettings.swift new file mode 100644 index 00000000..08cee56b --- /dev/null +++ b/bitchat/Services/ReadReceiptSettings.swift @@ -0,0 +1,47 @@ +// +// ReadReceiptSettings.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation + +/// Controls whether this device tells senders when their private messages +/// were read. +/// +/// A read receipt is a presence oracle: it says the person is awake, holding +/// their phone, and opened the app at a specific moment — exactly the +/// metadata someone under observation may need to withhold. Receipts were +/// previously unconditional; this is the switch every mainstream messenger +/// ships. +/// +/// Defaults to on (the existing behavior). Turning it off only withholds +/// receipts this device SENDS — receipts from others still display. While +/// off, read messages are still recorded locally as receipted, so turning +/// the setting back on never fires a retroactive burst that would disclose +/// past reading activity. +enum ReadReceiptSettings { + private static let sendReadReceiptsKey = "privacy.sendReadReceipts" + + static var sendReadReceipts: Bool { + get { sendReadReceipts(in: .standard) } + set { setSendReadReceipts(newValue, in: .standard) } + } + + /// Store-injecting forms, so tests can assert the default and both + /// settings without touching the shared preferences other tests read. + static func sendReadReceipts(in defaults: UserDefaults) -> Bool { + defaults.object(forKey: sendReadReceiptsKey) as? Bool ?? true + } + + static func setSendReadReceipts(_ send: Bool, in defaults: UserDefaults) { + defaults.set(send, forKey: sendReadReceiptsKey) + } + + /// Panic-wipe hook: removing the key restores the default. + static func reset(in defaults: UserDefaults = .standard) { + defaults.removeObject(forKey: sendReadReceiptsKey) + } +} diff --git a/bitchat/ViewModels/ChatPrivateConversationCoordinator.swift b/bitchat/ViewModels/ChatPrivateConversationCoordinator.swift index e011c763..ccb09492 100644 --- a/bitchat/ViewModels/ChatPrivateConversationCoordinator.swift +++ b/bitchat/ViewModels/ChatPrivateConversationCoordinator.swift @@ -168,7 +168,11 @@ extension ChatViewModel: ChatPrivateConversationContext { @discardableResult func routeReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) -> Bool { - messageRouter.sendReadReceipt(receipt, to: peerID) + // Withheld receipts report success so callers record them as sent: + // re-enabling the setting must never fire a retroactive burst that + // discloses past reading activity. + guard sendsReadReceipts() else { return true } + return messageRouter.sendReadReceipt(receipt, to: peerID) } @discardableResult @@ -181,6 +185,7 @@ extension ChatViewModel: ChatPrivateConversationContext { } func sendMeshReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) { + guard sendsReadReceipts() else { return } meshService.sendReadReceipt(receipt, to: peerID) } @@ -198,6 +203,7 @@ extension ChatViewModel: ChatPrivateConversationContext { } func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) { + guard sendsReadReceipts() else { return } makeGeohashNostrTransport().sendReadReceiptGeohash(messageID, toRecipientHex: recipientHex, from: identity) } diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 63fba0f8..55a4a4bd 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -464,6 +464,11 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage return scratch } + /// Whether this device sends read receipts. Injectable so tests assert + /// the gated behavior without racing other suites through the shared + /// UserDefaults-backed setting. + var sendsReadReceipts: () -> Bool = { ReadReceiptSettings.sendReadReceipts } + // Track sent read receipts to avoid duplicates (persisted across launches) // Note: Persistence happens automatically in didSet, no lifecycle observers needed var sentReadReceipts: Set = [] { // messageID set @@ -1641,6 +1646,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage MeshSightingsTracker.shared.clear() MeshEchoSettings.reset() NotificationPrivacySettings.reset() + ReadReceiptSettings.reset() // A hand-added relay names an operator someone chose to route through, // which is the kind of trace a wipe should not leave behind. NostrRelaySettings.reset() diff --git a/bitchat/Views/AppInfoView.swift b/bitchat/Views/AppInfoView.swift index cb99fbac..3c4ed77c 100644 --- a/bitchat/Views/AppInfoView.swift +++ b/bitchat/Views/AppInfoView.swift @@ -22,6 +22,7 @@ struct AppInfoView: View { @State private var liveVoiceEnabled = PTTSettings.liveVoiceEnabled @State private var locationNotesEnabled = LocationNotesSettings.enabled @State private var hideMessagePreviews = NotificationPrivacySettings.hideMessagePreviews + @State private var sendReadReceipts = ReadReceiptSettings.sendReadReceipts @State private var customRelays = NostrRelaySettings.customRelays() @State private var relayInput = "" @State private var relayError: String? @@ -117,6 +118,8 @@ struct AppInfoView: View { static let privacyTitle = String(localized: "app_info.settings.privacy.title", defaultValue: "PRIVACY", comment: "Section header (uppercase) for privacy settings such as hiding notification previews") static let hidePreviewsTitle = String(localized: "app_info.settings.hide_previews.title", defaultValue: "hide message previews", comment: "Title of the setting that keeps message text, sender names, and geohashes out of lock-screen notifications") static let hidePreviewsSubtitle = String(localized: "app_info.settings.hide_previews.subtitle", defaultValue: "notifications say that something arrived without showing the message, who sent it, or which location channel it came from. anyone holding your locked phone learns nothing from the lock screen. on by default.", comment: "Subtitle explaining what hiding notification message previews does") + static let readReceiptsTitle = String(localized: "app_info.settings.read_receipts.title", defaultValue: "send read receipts", comment: "Title of the setting that controls whether read receipts are sent for private messages") + static let readReceiptsSubtitle = String(localized: "app_info.settings.read_receipts.subtitle", defaultValue: "lets people see when you've read their private messages. a receipt also says you were awake and opened the app at that moment — turn this off to keep your reading activity to yourself. you'll still see receipts others send.", comment: "Subtitle explaining what the read-receipt setting shares and what turning it off withholds") static let dangerTitle = String(localized: "app_info.settings.danger.title", defaultValue: "DANGER ZONE", comment: "Section header (uppercase) for destructive actions in settings") static let panicButton = String(localized: "app_info.settings.danger.panic_button", defaultValue: "panic wipe", comment: "Button in the settings danger zone that erases all local data after confirmation") @@ -541,6 +544,20 @@ struct AppInfoView: View { ) ) } + + settingsCard { + settingToggle( + title: Text(verbatim: Strings.Settings.readReceiptsTitle), + subtitle: Text(verbatim: Strings.Settings.readReceiptsSubtitle), + isOn: Binding( + get: { sendReadReceipts }, + set: { newValue in + sendReadReceipts = newValue + ReadReceiptSettings.sendReadReceipts = newValue + } + ) + ) + } } // Danger zone diff --git a/bitchatTests/ChatViewModelTests.swift b/bitchatTests/ChatViewModelTests.swift index 35ab2975..1bd66497 100644 --- a/bitchatTests/ChatViewModelTests.swift +++ b/bitchatTests/ChatViewModelTests.swift @@ -428,6 +428,59 @@ struct ChatViewModelServiceLifecycleTests { #expect(!viewModel.unreadPrivateMessages.contains(peerID)) } + @Test @MainActor + func readReceiptsWithheldWhenSettingIsOff() async { + let (viewModel, transport) = makeTestableViewModel() + viewModel.sendsReadReceipts = { false } + viewModel.privateChatManager.sendsReadReceipts = { false } + let peerID = PeerID(str: "0000000000000001") + transport.simulateConnect(peerID, nickname: "Alice") + + let message = BitchatMessage( + id: "read-2", + sender: "Alice", + content: "Hello again", + timestamp: Date(), + isRelay: false, + originalSender: nil, + isPrivate: true, + recipientNickname: viewModel.nickname, + senderPeerID: peerID, + mentions: nil + ) + + viewModel.seedPrivateChat([message], for: peerID) + viewModel.markPrivateChatUnread(peerID) + viewModel.selectedPrivateChatPeer = peerID + + viewModel.handleDidBecomeActive() + + // Negative wait: nothing must leave the device... + let sentReadReceipt = await TestHelpers.waitUntil({ + transport.sentReadReceipts.contains { $0.receipt.originalMessageID == "read-2" } + }, timeout: TestConstants.negativeWaitWindow) + #expect(!sentReadReceipt) + + // ...while the chat is still marked read locally and the receipt is + // recorded as handled, so re-enabling the setting never fires a + // retroactive burst disclosing past reading activity. + #expect(!viewModel.unreadPrivateMessages.contains(peerID)) + #expect(viewModel.sentReadReceipts.contains("read-2") || viewModel.privateChatManager.sentReadReceipts.contains("read-2")) + } + + @Test @MainActor + func readReceiptSettingDefaultsToOnAndResets() { + let suite = "ReadReceiptSettingsTests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defer { defaults.removePersistentDomain(forName: suite) } + + #expect(ReadReceiptSettings.sendReadReceipts(in: defaults)) + ReadReceiptSettings.setSendReadReceipts(false, in: defaults) + #expect(!ReadReceiptSettings.sendReadReceipts(in: defaults)) + ReadReceiptSettings.reset(in: defaults) + #expect(ReadReceiptSettings.sendReadReceipts(in: defaults)) + } + @Test @MainActor func handleScreenshotCaptured_privateChatStaysSilentWithoutSession() async { let (viewModel, transport) = makeTestableViewModel() From 0dacb3629cc44628678d8f9d5f4e904067fdaff3 Mon Sep 17 00:00:00 2001 From: jack Date: Tue, 11 Aug 2026 09:23:19 +0200 Subject: [PATCH 2/6] PTT consent: live voice off by default; slide-away-to-cancel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live push-to-talk defaulted ON in both directions: holding the mic in a public channel streamed your voice to everyone in radio range before you released, and inbound bursts auto-played out of the speaker. A single buried toggle defaulting on was not consent — both behaviors are now opt-in (PTTSettings.liveVoiceEnabled defaults false), and the settings copy says exactly what turning it on does. Recording could also not be aborted one-handed: release always sent, and the HUD's cancel button required lifting the finger — which sent. Sliding the finger off the mic button (>60pt) now arms cancel — the HUD flips from the timer to "release to cancel" — and sliding back re-arms send. The armed flag resets on every exit path (cancel, finish, panic), pinned by a new test. 2 new strings + 1 updated, all 30 locales. Co-Authored-By: Claude Fable 5 --- bitchat/Features/voice/PTTSettings.swift | 7 +- bitchat/Localizable.xcstrings | 432 ++++++++++++++++-- .../ViewModels/VoiceRecordingViewModel.swift | 15 + bitchat/Views/ContentComposerView.swift | 27 +- bitchatTests/VoiceRecorderTests.swift | 32 ++ 5 files changed, 479 insertions(+), 34 deletions(-) diff --git a/bitchat/Features/voice/PTTSettings.swift b/bitchat/Features/voice/PTTSettings.swift index c3b56510..0fe75a80 100644 --- a/bitchat/Features/voice/PTTSettings.swift +++ b/bitchat/Features/voice/PTTSettings.swift @@ -16,11 +16,16 @@ import AppKit /// User preference for live push-to-talk voice. One switch controls both /// directions: streaming your holds live, and auto-playing inbound bursts. /// Off means voice messages behave exactly like classic voice notes. +/// +/// Off by default: live mode sends audio to other people BEFORE the hold is +/// released and plays strangers' voices out of the speaker unprompted — +/// both are consequences someone must opt into, not discover. A single +/// buried toggle defaulting on was not consent. enum PTTSettings { private static let liveVoiceEnabledKey = "ptt.liveVoiceEnabled" static var liveVoiceEnabled: Bool { - get { UserDefaults.standard.object(forKey: liveVoiceEnabledKey) as? Bool ?? true } + get { UserDefaults.standard.object(forKey: liveVoiceEnabledKey) as? Bool ?? false } set { UserDefaults.standard.set(newValue, forKey: liveVoiceEnabledKey) } } diff --git a/bitchat/Localizable.xcstrings b/bitchat/Localizable.xcstrings index ffa73e1d..e0eff933 100644 --- a/bitchat/Localizable.xcstrings +++ b/bitchat/Localizable.xcstrings @@ -15625,6 +15625,378 @@ } } }, + "voice.hud.release_to_cancel" : { + "comment" : "Recording HUD label while the finger has slid off the mic button; releasing now discards the recording", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "ارفع إصبعك للإلغاء" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "বাতিল করতে ছেড়ে দাও" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "loslassen zum abbrechen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "release to cancel" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "suelta para cancelar" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "برای لغو رها کن" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "bitawan para kanselahin" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "relâche pour annuler" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "שחרור לביטול" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "रद्द करने के लिए छोड़ दो" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "lepas untuk membatalkan" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "rilascia per annullare" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "離すとキャンセル" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "손을 떼면 취소" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "lepaskan untuk batal" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "रद्द गर्न छोड" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "laat los om te annuleren" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "puść, aby anulować" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "solta para cancelar" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "solte para cancelar" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "отпусти, чтобы отменить" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "släpp för att avbryta" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "ரத்து செய்ய விடு" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ปล่อยเพื่อยกเลิก" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "iptal için bırak" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "відпусти, щоб скасувати" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "منسوخ کرنے کے لیے چھوڑ دو" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "thả ra để hủy" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "松开取消" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "放開取消" + } + } + } + }, + "voice.hud.slide_to_cancel" : { + "comment" : "Recording HUD hint that sliding the finger off the mic button cancels instead of sending", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "اسحب بعيدًا للإلغاء" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "বাতিল করতে আঙুল সরাও" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "zum abbrechen wegziehen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "slide away to cancel" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "desliza para cancelar" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "برای لغو انگشتت را بکش" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "i-slide palayo para kanselahin" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "glisse pour annuler" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "החלקה הצידה לביטול" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "रद्द करने के लिए दूर खिसकाओ" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "geser menjauh untuk membatalkan" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "scorri via per annullare" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "スライドでキャンセル" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "밀어서 취소" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "leret menjauh untuk batal" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "रद्द गर्न टाढा सार" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "veeg weg om te annuleren" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "przesuń, aby anulować" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "desliza para cancelar" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "deslize para cancelar" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "сдвинь, чтобы отменить" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "dra bort för att avbryta" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "ரத்து செய்ய விலக்கி நகர்த்து" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "เลื่อนออกเพื่อยกเลิก" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "iptal için kaydır" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "посунь, щоб скасувати" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "منسوخ کرنے کے لیے دور سرکاؤ" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "trượt ra để hủy" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "滑开取消" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "滑開取消" + } + } + } + }, "content.delivery.reason.legacy_media_consent_required" : { "comment" : "Failure reason when a legacy private-media send lacks per-send consent", "extractionState" : "manual", @@ -33116,181 +33488,181 @@ "ar" : { "stringUnit" : { "state" : "translated", - "value" : "يُبث أثناء التحدث؛ الصوت المباشر الوارد يُشغَّل تلقائيًا" + "value" : "متوقف افتراضيًا. عند تفعيله، يُبث صوتك إلى الآخرين أثناء كلامك — قبل أن ترفع إصبعك — ويُشغَّل الصوت المباشر الوارد بصوت مسموع تلقائيًا." } }, "bn" : { "stringUnit" : { "state" : "translated", - "value" : "কথা বলার সাথে সাথে স্ট্রিম হয়; আগত লাইভ ভয়েস স্বয়ংক্রিয়ভাবে চলে" + "value" : "ডিফল্টভাবে বন্ধ। চালু থাকলে তোমার কণ্ঠ কথা বলার সঙ্গে সঙ্গেই অন্যদের কাছে স্ট্রিম হয় — বোতাম ছাড়ার আগেই — আর আসা লাইভ ভয়েস স্বয়ংক্রিয়ভাবে জোরে বাজে।" } }, "de" : { "stringUnit" : { "state" : "translated", - "value" : "überträgt live beim sprechen; eingehende live-stimme wird automatisch abgespielt" + "value" : "standardmäßig aus. wenn aktiviert, wird deine stimme schon beim sprechen an andere gestreamt — noch bevor du loslässt — und eingehende live-stimmen werden automatisch laut abgespielt." } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "streams as you speak; incoming live voice plays automatically" + "value" : "off by default. when on, your voice streams to people as you speak — before you release — and incoming live voice plays out loud automatically." } }, "es" : { "stringUnit" : { "state" : "translated", - "value" : "transmite mientras hablas; la voz en vivo entrante se reproduce automáticamente" + "value" : "desactivado por defecto. si lo activas, tu voz se transmite a la gente mientras hablas — antes de soltar — y la voz en directo entrante se reproduce en voz alta automáticamente." } }, "fa" : { "stringUnit" : { "state" : "translated", - "value" : "همان‌طور که حرف می‌زنی پخش می‌شود؛ صدای زندهٔ دریافتی خودکار پخش می‌شود" + "value" : "به‌طور پیش‌فرض خاموش است. وقتی روشن باشد، صدایت همان لحظه که حرف می‌زنی — پیش از رها کردن — برای دیگران پخش می‌شود و صدای زنده‌ی دریافتی هم به‌طور خودکار با صدای بلند پخش می‌شود." } }, "fil" : { "stringUnit" : { "state" : "translated", - "value" : "nag-i-stream habang nagsasalita ka; awtomatikong tumutugtog ang papasok na live na boses" + "value" : "naka-off bilang default. kapag naka-on, naist-stream ang boses mo sa iba habang nagsasalita ka — bago mo pa bitawan — at awtomatikong tumutugtog nang malakas ang papasok na live na boses." } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "diffuse pendant que vous parlez ; la voix en direct entrante est lue automatiquement" + "value" : "désactivé par défaut. une fois activé, ta voix est diffusée aux autres pendant que tu parles — avant même de relâcher — et la voix en direct reçue est lue à voix haute automatiquement." } }, "he" : { "stringUnit" : { "state" : "translated", - "value" : "משדר בזמן שאתה מדבר; קול חי נכנס מושמע אוטומטית" + "value" : "כבוי כברירת מחדל. כשהוא פועל, הקול שלך משודר לאחרים תוך כדי דיבור — עוד לפני שחרור הכפתור — וקול חי נכנס מושמע בקול רם אוטומטית." } }, "hi" : { "stringUnit" : { "state" : "translated", - "value" : "बोलते समय स्ट्रीम होता है; आने वाली लाइव आवाज़ अपने आप चलती है" + "value" : "डिफ़ॉल्ट रूप से बंद। चालू होने पर तुम्हारी आवाज़ बोलते-बोलते ही लोगों तक स्ट्रीम होती है — बटन छोड़ने से पहले ही — और आने वाली लाइव आवाज़ अपने आप ज़ोर से बजती है।" } }, "id" : { "stringUnit" : { "state" : "translated", - "value" : "streaming saat Anda berbicara; suara langsung yang masuk diputar otomatis" + "value" : "nonaktif secara default. saat aktif, suaramu langsung dialirkan ke orang lain saat kamu bicara — sebelum tombol dilepas — dan suara langsung yang masuk otomatis diputar dengan keras." } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "trasmette mentre parli; la voce in diretta in arrivo viene riprodotta automaticamente" + "value" : "disattivato per impostazione predefinita. quando è attivo, la tua voce viene trasmessa agli altri mentre parli — prima ancora di rilasciare — e la voce dal vivo in arrivo viene riprodotta ad alta voce automaticamente." } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "話しながらライブ配信。受信したライブ音声は自動再生されます" + "value" : "デフォルトはオフ。オンにすると、ボタンを離す前から話している声がそのまま相手に配信され、受信したライブ音声は自動的にスピーカーで再生されます。" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "말하는 동안 실시간 전송됩니다. 수신된 라이브 음성은 자동 재생됩니다" + "value" : "기본은 꺼짐. 켜면 버튼에서 손을 떼기 전부터 말하는 동안 목소리가 상대에게 실시간으로 전송되고, 수신된 라이브 음성은 자동으로 소리 내어 재생돼요." } }, "ms" : { "stringUnit" : { "state" : "translated", - "value" : "strim semasa anda bercakap; suara langsung masuk dimainkan secara automatik" + "value" : "dimatikan secara lalai. apabila dihidupkan, suara kamu distrim kepada orang lain semasa kamu bercakap — sebelum kamu lepaskan — dan suara langsung yang masuk dimainkan dengan kuat secara automatik." } }, "ne" : { "stringUnit" : { "state" : "translated", - "value" : "बोल्दै गर्दा स्ट्रिम हुन्छ; आगमन लाइभ आवाज स्वतः बज्छ" + "value" : "पूर्वनिर्धारित रूपमा बन्द। खुला हुँदा तिम्रो आवाज बोल्दै गर्दा नै — बटन छोड्नुअघि नै — अरूसम्म स्ट्रिम हुन्छ, र आएको लाइभ आवाज आफैँ ठूलो स्वरमा बज्छ।" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "streamt terwijl je praat; inkomende live spraak wordt automatisch afgespeeld" + "value" : "standaard uit. indien aan wordt je stem al tijdens het praten naar anderen gestreamd — nog vóór je loslaat — en binnenkomende live spraak wordt automatisch hardop afgespeeld." } }, "pl" : { "stringUnit" : { "state" : "translated", - "value" : "przesyła na żywo podczas mówienia; przychodzący głos na żywo odtwarza się automatycznie" + "value" : "domyślnie wyłączone. po włączeniu twój głos jest przesyłany innym już w trakcie mówienia — zanim puścisz przycisk — a przychodzący głos na żywo jest automatycznie odtwarzany na głos." } }, "pt" : { "stringUnit" : { "state" : "translated", - "value" : "transmite enquanto fala; a voz ao vivo recebida é reproduzida automaticamente" + "value" : "desligado por predefinição. quando ativado, a tua voz é transmitida às outras pessoas enquanto falas — antes de soltares — e a voz em direto recebida é reproduzida em voz alta automaticamente." } }, "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "transmite enquanto você fala; a voz ao vivo recebida toca automaticamente" + "value" : "desativado por padrão. quando ativado, sua voz é transmitida às pessoas enquanto você fala — antes de soltar — e a voz ao vivo recebida toca em voz alta automaticamente." } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "передаёт, пока вы говорите; входящий живой голос воспроизводится автоматически" + "value" : "по умолчанию выключено. когда включено, твой голос передаётся другим прямо во время разговора — ещё до того, как ты отпустишь кнопку, — а входящий живой голос автоматически проигрывается вслух." } }, "sv" : { "stringUnit" : { "state" : "translated", - "value" : "strömmar medan du pratar; inkommande live-röst spelas upp automatiskt" + "value" : "av som standard. när det är på strömmas din röst till andra medan du pratar — innan du släpper — och inkommande live-röst spelas automatiskt upp högt." } }, "ta" : { "stringUnit" : { "state" : "translated", - "value" : "நீங்கள் பேசும்போதே நேரலையாக அனுப்பப்படும்; உள்வரும் நேரலை குரல் தானாக ஒலிக்கும்" + "value" : "இயல்பாக முடக்கப்பட்டுள்ளது. இயக்கினால், நீ பேசும்போதே — பொத்தானை விடுவதற்கு முன்பே — உன் குரல் மற்றவர்களுக்கு ஸ்ட்ரீம் ஆகும்; வரும் நேரடி குரல் தானாக சத்தமாக ஒலிக்கும்." } }, "th" : { "stringUnit" : { "state" : "translated", - "value" : "สตรีมขณะพูด เสียงสดขาเข้าจะเล่นอัตโนมัติ" + "value" : "ปิดไว้เป็นค่าเริ่มต้น เมื่อเปิด เสียงของคุณจะสตรีมถึงคนอื่นระหว่างที่พูด — ก่อนปล่อยปุ่ม — และเสียงสดที่เข้ามาจะเล่นออกลำโพงโดยอัตโนมัติ" } }, "tr" : { "stringUnit" : { "state" : "translated", - "value" : "siz konuşurken canlı iletilir; gelen canlı ses otomatik çalınır" + "value" : "varsayılan olarak kapalı. açıkken sesin, sen konuşurken — daha bırakmadan — başkalarına aktarılır ve gelen canlı ses otomatik olarak sesli çalınır." } }, "uk" : { "stringUnit" : { "state" : "translated", - "value" : "передає, поки ви говорите; вхідний живий голос відтворюється автоматично" + "value" : "типово вимкнено. коли ввімкнено, твій голос транслюється іншим просто під час розмови — ще до того, як ти відпустиш кнопку, — а вхідний живий голос автоматично відтворюється вголос." } }, "ur" : { "stringUnit" : { "state" : "translated", - "value" : "بولتے وقت لائیو نشر ہوتا ہے؛ موصول ہونے والی لائیو آواز خود بخود چلتی ہے" + "value" : "طے شدہ طور پر بند۔ آن ہونے پر تمہاری آواز بولتے ہی — بٹن چھوڑنے سے پہلے — لوگوں تک اسٹریم ہوتی ہے، اور آنے والی لائیو آواز خودبخود بلند آواز میں چلتی ہے۔" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "phát trực tiếp khi bạn nói; giọng nói trực tiếp đến sẽ tự phát" + "value" : "mặc định tắt. khi bật, giọng của bạn được truyền trực tiếp tới mọi người ngay lúc bạn nói — trước cả khi thả tay — và giọng nói trực tiếp nhận được sẽ tự động phát ra loa." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "边说边实时传输;收到的实时语音会自动播放" + "value" : "默认关闭。开启后,你说话的同时——还没松开按钮——声音就会实时传给对方,收到的实时语音也会自动外放播放。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "邊說邊即時傳輸;收到的即時語音會自動播放" + "value" : "預設關閉。開啟後,你說話的同時——還沒放開按鈕——聲音就會即時傳給對方,收到的即時語音也會自動以擴音播放。" } } } diff --git a/bitchat/ViewModels/VoiceRecordingViewModel.swift b/bitchat/ViewModels/VoiceRecordingViewModel.swift index adb7d92a..08535b41 100644 --- a/bitchat/ViewModels/VoiceRecordingViewModel.swift +++ b/bitchat/ViewModels/VoiceRecordingViewModel.swift @@ -59,6 +59,12 @@ final class VoiceRecordingViewModel: ObservableObject { /// composer switches its recording HUD to the LIVE treatment. @Published private(set) var isLiveStreaming = false + /// Armed when the finger slides off the mic button mid-hold: releasing + /// then cancels instead of sending. Before this existed, release ALWAYS + /// sent — the HUD's cancel button required lifting the finger, which + /// sent. A recording (or live stream) could not be aborted one-handed. + @Published private(set) var isCancelArmed = false + /// Supplies the capture backend per press. `ChatViewModel` swaps in a /// live push-to-talk session when the current DM peer can hear it now. var sessionProvider: () -> VoiceCaptureSession = { VoiceNoteCaptureSession() } @@ -77,8 +83,14 @@ final class VoiceRecordingViewModel: ObservableObject { return String(format: "%02d:%02d.%02d", minutes, seconds, centiseconds) } + func setCancelArmed(_ armed: Bool) { + guard isCancelArmed != armed else { return } + isCancelArmed = armed + } + func start(shouldShow: Bool) { guard shouldShow, state == .idle else { return } + isCancelArmed = false holdGeneration &+= 1 let generation = holdGeneration let session = sessionProvider() @@ -162,6 +174,7 @@ final class VoiceRecordingViewModel: ObservableObject { } func finish(completion: ((URL) -> Void)?) { + isCancelArmed = false let previousState = state switch previousState { @@ -220,6 +233,7 @@ final class VoiceRecordingViewModel: ObservableObject { } func cancel() { + isCancelArmed = false finish(completion: nil) } @@ -231,6 +245,7 @@ final class VoiceRecordingViewModel: ObservableObject { activeSession = nil state = .idle isLiveStreaming = false + isCancelArmed = false session?.panicCancelSynchronously() } diff --git a/bitchat/Views/ContentComposerView.swift b/bitchat/Views/ContentComposerView.swift index b7deab21..d41b31e5 100644 --- a/bitchat/Views/ContentComposerView.swift +++ b/bitchat/Views/ContentComposerView.swift @@ -220,7 +220,11 @@ private extension ContentComposerView { TimelineView(.periodic(from: .now, by: 0.05)) { context in // Live streaming means audio is heard as you speak — the HUD // must make that unmistakable, not just show a timer. - if voiceRecordingVM.isLiveStreaming { + if voiceRecordingVM.isCancelArmed { + Text(String(localized: "voice.hud.release_to_cancel", defaultValue: "release to cancel", comment: "Recording HUD label while the finger has slid off the mic button; releasing now discards the recording")) + .bitchatFont(size: 13, weight: .bold) + .foregroundColor(.secondary) + } else if voiceRecordingVM.isLiveStreaming { Text( "live \(voiceRecordingVM.formattedDuration(for: context.date))", comment: "Recording HUD label while a voice message streams live to the recipient" @@ -236,6 +240,12 @@ private extension ContentComposerView { .foregroundColor(.red) } } + if !voiceRecordingVM.isCancelArmed { + Text(String(localized: "voice.hud.slide_to_cancel", defaultValue: "slide away to cancel", comment: "Recording HUD hint that sliding the finger off the mic button cancels instead of sending")) + .bitchatFont(size: 11) + .foregroundColor(.secondary) + .lineLimit(1) + } Spacer() Button(action: voiceRecordingVM.cancel) { Label(String(localized: "common.cancel", comment: "Cancel action in the voice recording HUD"), systemImage: "xmark.circle") @@ -346,11 +356,22 @@ private extension ContentComposerView { .contentShape(Circle()) .gesture( DragGesture(minimumDistance: 0) - .onChanged { _ in + .onChanged { value in voiceRecordingVM.start(shouldShow: conversationUIModel.canSendMediaInCurrentContext) + // Slide-away-to-cancel: release always used to + // send, and the HUD cancel button required + // lifting the finger — which sent. Sliding off + // the button arms cancel; sliding back re-arms + // send. + let distance = hypot(value.translation.width, value.translation.height) + voiceRecordingVM.setCancelArmed(distance > 60) } .onEnded { _ in - voiceRecordingVM.finish(completion: conversationUIModel.sendVoiceNote) + if voiceRecordingVM.isCancelArmed { + voiceRecordingVM.cancel() + } else { + voiceRecordingVM.finish(completion: conversationUIModel.sendVoiceNote) + } } ) ) diff --git a/bitchatTests/VoiceRecorderTests.swift b/bitchatTests/VoiceRecorderTests.swift index 68069f11..32725988 100644 --- a/bitchatTests/VoiceRecorderTests.swift +++ b/bitchatTests/VoiceRecorderTests.swift @@ -453,3 +453,35 @@ struct VoiceRecorderTests { #expect(session.activationCalls == [true, false, true, false]) } } + +// MARK: - Slide-to-cancel state + +/// Pins the slide-away-to-cancel arming semantics on the recording view +/// model: release used to ALWAYS send (the HUD cancel button required +/// lifting the finger — which sent), so the armed flag must reset on every +/// exit path or a stale value could discard a wanted recording. +struct VoiceRecordingCancelArmingTests { + + @Test @MainActor + func cancelArmingTogglesAndResetsOnEveryExitPath() { + let vm = VoiceRecordingViewModel() + #expect(!vm.isCancelArmed) + + vm.setCancelArmed(true) + #expect(vm.isCancelArmed) + vm.setCancelArmed(false) + #expect(!vm.isCancelArmed) + + vm.setCancelArmed(true) + vm.cancel() + #expect(!vm.isCancelArmed) + + vm.setCancelArmed(true) + vm.finish(completion: nil) + #expect(!vm.isCancelArmed) + + vm.setCancelArmed(true) + vm.panicWipe() + #expect(!vm.isCancelArmed) + } +} From 359139fc6b80cede8cd09e36b214ba082cfe7ad3 Mon Sep 17 00:00:00 2001 From: jack Date: Tue, 11 Aug 2026 09:28:11 +0200 Subject: [PATCH 3/6] Warn in the chat when a peer's identity key changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit migrateNoiseKeyUpdate silently merged the DM thread onto the new key: a peer who panic-wiped (or was replaced by whoever holds the nickname) inherited the conversation's earned trust with no visible sign beyond a debug log, and any earlier verification — bound to the OLD fingerprint — vanished without a word. Signal treats this as a full-width banner; bitchat treated it as bookkeeping. The migration now appends a system line to the affected conversation: "'s identity key changed — this can mean a new device or a reset. earlier verification no longer applies; verify them again before trusting this chat." Only when there is a conversation to protect (selected chat or migrated messages) — a favorite never chatted with doesn't spawn a warning-only thread. This is also the prerequisite UX for the peer-ID rotation project: once rotation ships, identity changes become routine and MUST be visible. 1 string x 30 locales; behavior pinned both ways by new coordinator tests. Co-Authored-By: Claude Fable 5 --- bitchat/Localizable.xcstrings | 186 ++++++++++++++++++ .../ChatPeerIdentityCoordinator.swift | 32 +++ ...tPeerIdentityCoordinatorContextTests.swift | 47 +++++ 3 files changed, 265 insertions(+) diff --git a/bitchat/Localizable.xcstrings b/bitchat/Localizable.xcstrings index e0eff933..06b653d3 100644 --- a/bitchat/Localizable.xcstrings +++ b/bitchat/Localizable.xcstrings @@ -15997,6 +15997,192 @@ } } }, + "system.identity.key_changed" : { + "comment" : "Private-chat system warning after a peer's Noise identity key changed; placeholder is the peer's name", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "تغيّر مفتاح هوية %@ — قد يعني هذا جهازًا جديدًا أو إعادة ضبط. لم يعد التحقق السابق ساريًا؛ تحقّق من هويته مجددًا قبل الوثوق بهذه المحادثة." + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@-এর পরিচয় চাবি বদলে গেছে — এর মানে নতুন ডিভাইস বা রিসেট হতে পারে। আগের যাচাই আর প্রযোজ্য নয়; এই চ্যাটে ভরসা করার আগে তাকে আবার যাচাই করে নাও।" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "der identitätsschlüssel von %@ hat sich geändert — das kann ein neues gerät oder ein reset bedeuten. frühere verifizierung gilt nicht mehr; verifiziere die person erneut, bevor du diesem chat vertraust." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@'s identity key changed — this can mean a new device or a reset. earlier verification no longer applies; verify them again before trusting this chat." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "la clave de identidad de %@ cambió — puede significar un dispositivo nuevo o un restablecimiento. la verificación anterior ya no vale; verifica de nuevo su identidad antes de confiar en este chat." + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "کلید هویت %@ تغییر کرده — این می‌تواند به معنی دستگاه جدید یا بازنشانی باشد. تأیید قبلی دیگر معتبر نیست؛ پیش از اعتماد به این گفتگو دوباره هویتش را تأیید کن." + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "nagbago ang identity key ni %@ — puwedeng ibig sabihin nito ay bagong device o reset. hindi na umiiral ang dating beripikasyon; i-verify siya ulit bago pagkatiwalaan ang chat na ito." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "la clé d'identité de %@ a changé — cela peut vouloir dire un nouvel appareil ou une réinitialisation. la vérification précédente ne vaut plus ; vérifie à nouveau son identité avant de faire confiance à cette conversation." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "מפתח הזהות של %@ השתנה — זה יכול להעיד על מכשיר חדש או איפוס. האימות הקודם כבר לא תקף; כדאי לאמת שוב לפני שסומכים על הצ'אט הזה." + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ की पहचान कुंजी बदल गई है — इसका मतलब नया डिवाइस या रीसेट हो सकता है। पहले किया गया सत्यापन अब मान्य नहीं; इस चैट पर भरोसा करने से पहले दोबारा सत्यापित कर लो।" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "kunci identitas %@ berubah — bisa berarti perangkat baru atau reset. verifikasi sebelumnya tidak berlaku lagi; verifikasi dia lagi sebelum memercayai chat ini." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "la chiave d'identità di %@ è cambiata — può voler dire un nuovo dispositivo o un ripristino. la verifica precedente non vale più; verifica di nuovo la sua identità prima di fidarti di questa chat." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@のアイデンティティキーが変わりました — 新しい端末かリセットの可能性があります。以前の確認はもう有効ではありません。このチャットを信頼する前に、もう一度相手を確認してください。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@의 신원 키가 변경되었어요 — 새 기기나 초기화일 수 있어요. 이전 확인은 더 이상 유효하지 않으니, 이 채팅을 신뢰하기 전에 상대를 다시 확인해 주세요." + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "kunci identiti %@ berubah — ini mungkin bermaksud peranti baharu atau tetapan semula. pengesahan sebelum ini tidak lagi terpakai; sahkan dia semula sebelum mempercayai sembang ini." + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ को पहिचान कुञ्जी परिवर्तन भयो — यसको अर्थ नयाँ डिभाइस वा रिसेट हुन सक्छ। पहिलेको प्रमाणीकरण अब लागू हुँदैन; यो च्याटमा भरोसा गर्नुअघि फेरि प्रमाणित गर।" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "de identiteitssleutel van %@ is veranderd — dat kan een nieuw apparaat of een reset betekenen. eerdere verificatie geldt niet meer; verifieer deze persoon opnieuw voordat je deze chat vertrouwt." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "klucz tożsamości %@ się zmienił — to może oznaczać nowe urządzenie albo reset. wcześniejsza weryfikacja już nie obowiązuje; zweryfikuj tę osobę ponownie, zanim zaufasz temu czatowi." + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "a chave de identidade de %@ mudou — pode significar um dispositivo novo ou uma reposição. a verificação anterior já não se aplica; verifica outra vez a identidade antes de confiares neste chat." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "a chave de identidade de %@ mudou — pode significar um aparelho novo ou uma redefinição. a verificação anterior não vale mais; verifique de novo antes de confiar neste chat." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "ключ идентификации %@ изменился — это может означать новое устройство или сброс. прежняя проверка больше не действует; проверь собеседника заново, прежде чем доверять этому чату." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "identitetsnyckeln för %@ har ändrats — det kan betyda en ny enhet eller en återställning. tidigare verifiering gäller inte längre; verifiera personen igen innan du litar på den här chatten." + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ இன் அடையாளச் சாவி மாறிவிட்டது — இது புதிய சாதனம் அல்லது மீட்டமைப்பைக் குறிக்கலாம். முந்தைய சரிபார்ப்பு இனி செல்லாது; இந்த அரட்டையை நம்புவதற்கு முன் அவரை மீண்டும் சரிபார்த்துக்கொள்." + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "กุญแจยืนยันตัวตนของ %@ เปลี่ยนไป — อาจหมายถึงอุปกรณ์ใหม่หรือการรีเซ็ต การยืนยันก่อนหน้านี้ใช้ไม่ได้อีกต่อไป ยืนยันตัวตนอีกครั้งก่อนไว้ใจแชทนี้" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ adlı kişinin kimlik anahtarı değişti — bu yeni bir cihaz ya da sıfırlama anlamına gelebilir. önceki doğrulama artık geçerli değil; bu sohbete güvenmeden önce onu yeniden doğrula." + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "ключ ідентичності %@ змінився — це може означати новий пристрій або скидання. попередня перевірка більше не діє; перевір співрозмовника ще раз, перш ніж довіряти цьому чату." + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ کی شناختی کلید بدل گئی ہے — اس کا مطلب نیا آلہ یا ری سیٹ ہو سکتا ہے۔ پہلے کی تصدیق اب لاگو نہیں؛ اس چیٹ پر بھروسا کرنے سے پہلے دوبارہ تصدیق کرو۔" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "khóa định danh của %@ đã thay đổi — có thể là thiết bị mới hoặc do đặt lại. xác minh trước đây không còn hiệu lực; hãy xác minh lại trước khi tin tưởng cuộc trò chuyện này." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ 的身份密钥已更改 — 可能是换了新设备或进行了重置。之前的验证不再有效;在信任此聊天之前请重新验证对方。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ 的身分金鑰已變更 — 可能是換了新裝置或進行了重設。先前的驗證已不再有效;信任此聊天前請重新驗證對方。" + } + } + } + }, "content.delivery.reason.legacy_media_consent_required" : { "comment" : "Failure reason when a legacy private-media send lacks per-send consent", "extractionState" : "manual", diff --git a/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift b/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift index e2d0fae8..2f6f7270 100644 --- a/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift +++ b/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift @@ -27,6 +27,10 @@ protocol ChatPeerIdentityContext: AnyObject { /// Moves all messages from `oldPeerID`'s chat into `newPeerID`'s chat /// (dedup by ID, order preserved, unread carried, old chat removed). func migratePrivateChat(from oldPeerID: PeerID, to newPeerID: PeerID) + /// Appends a private message via the single-writer store intent + /// (shared requirement with `ChatLifecycleContext`). + @discardableResult + func appendPrivateMessage(_ message: BitchatMessage, to peerID: PeerID) -> Bool var selectedPrivateChatPeer: PeerID? { get set } var selectedPrivateChatFingerprint: String? { get set } var myPeerID: PeerID { get } @@ -578,7 +582,11 @@ private extension ChatPeerIdentityCoordinator { // order, carries the unread flag, and removes the old chat. context.migratePrivateChat(from: oldPeerID, to: newPeerID) } +} +extension ChatPeerIdentityCoordinator { + // Internal (not in the private extension): the identity-changed warning + // below is a security behavior the context tests pin directly. @MainActor func migrateNoiseKeyUpdate(oldPeerID: PeerID, newPeerID: PeerID) { // Capture before the migration: the store hands its selection off to @@ -606,6 +614,30 @@ private extension ChatPeerIdentityCoordinator { context.selectedPrivateChatFingerprint = fingerprint } } + + // An identity-key change is a security event, not bookkeeping: any + // earlier verification is bound to the OLD fingerprint and no longer + // applies, and saying nothing would let whoever holds the new key + // inherit the thread's earned trust under the same nickname. Only + // warn when there is a conversation to protect. + if wasSelected || !context.privateMessages(for: newPeerID).isEmpty { + let notice = BitchatMessage( + sender: "system", + content: String( + format: String(localized: "system.identity.key_changed", defaultValue: "%@'s identity key changed — this can mean a new device or a reset. earlier verification no longer applies; verify them again before trusting this chat.", comment: "Private-chat system warning after a peer's Noise identity key changed; placeholder is the peer's name"), + locale: .current, + resolveNickname(for: newPeerID) + ), + timestamp: Date(), + isRelay: false, + originalSender: nil, + isPrivate: true, + recipientNickname: context.peerNickname(for: newPeerID), + senderPeerID: context.myPeerID + ) + context.appendPrivateMessage(notice, to: newPeerID) + context.notifyUIChanged() + } } @MainActor diff --git a/bitchatTests/ChatPeerIdentityCoordinatorContextTests.swift b/bitchatTests/ChatPeerIdentityCoordinatorContextTests.swift index 0bfcd306..d14f766a 100644 --- a/bitchatTests/ChatPeerIdentityCoordinatorContextTests.swift +++ b/bitchatTests/ChatPeerIdentityCoordinatorContextTests.swift @@ -46,6 +46,15 @@ private final class MockChatPeerIdentityContext: ChatPeerIdentityContext { unreadPrivateMessages.remove(peerID) } + @discardableResult + func appendPrivateMessage(_ message: BitchatMessage, to peerID: PeerID) -> Bool { + var chat = privateChats[peerID] ?? [] + guard !chat.contains(where: { $0.id == message.id }) else { return false } + chat.append(message) + privateChats[peerID] = chat + return true + } + func migratePrivateChat(from oldPeerID: PeerID, to newPeerID: PeerID) { migratedChats.append((oldPeerID, newPeerID)) guard oldPeerID != newPeerID, let source = privateChats[oldPeerID] else { return } @@ -360,6 +369,44 @@ struct ChatPeerIdentityCoordinatorContextTests { #expect(context.cachedEncryptionStatuses[peerID] == nil) } + @Test @MainActor + func migrateNoiseKeyUpdate_warnsThatIdentityChanged() async { + let context = MockChatPeerIdentityContext() + let coordinator = ChatPeerIdentityCoordinator(context: context) + let oldPeerID = PeerID(str: "1111111111111111") + let newPeerID = PeerID(str: "2222222222222222") + context.nicknamesByPeerID[newPeerID] = "alice" + context.privateChats[oldPeerID] = [makePrivateMessage(id: "m1", timestamp: Date(timeIntervalSince1970: 1))] + + coordinator.migrateNoiseKeyUpdate(oldPeerID: oldPeerID, newPeerID: newPeerID) + + // The thread migrated AND says out loud that the identity changed: + // earlier verification is bound to the old fingerprint, and silence + // would let whoever holds the new key inherit the earned trust. + let migrated = context.privateChats[newPeerID] ?? [] + #expect(migrated.contains { $0.id == "m1" }) + let notice = migrated.last + #expect(notice?.sender == "system") + #expect(notice?.content.contains("identity key changed") == true) + #expect(notice?.content.contains("alice") == true) + #expect(context.privateChats[oldPeerID] == nil) + } + + @Test @MainActor + func migrateNoiseKeyUpdate_staysQuietWithNoConversationToProtect() async { + let context = MockChatPeerIdentityContext() + let coordinator = ChatPeerIdentityCoordinator(context: context) + + coordinator.migrateNoiseKeyUpdate( + oldPeerID: PeerID(str: "3333333333333333"), + newPeerID: PeerID(str: "4444444444444444") + ) + + // No selected chat and no messages: a warning would create a + // conversation out of nothing for a favorite never chatted with. + #expect(context.privateChats.isEmpty) + } + @Test @MainActor func getEncryptionStatus_reflectsLiveSessionNotHistory() async { let context = MockChatPeerIdentityContext() From cfa875459d02bf61f64658bfd36baeeb60fadea2 Mon Sep 17 00:00:00 2001 From: jack Date: Tue, 11 Aug 2026 10:03:01 +0200 Subject: [PATCH 4/6] Review fix: record withheld receipts in both tracking sets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex P1: the manager-path withheld claim landed only in PrivateChatManager.sentReadReceipts, while the lifecycle read pass dedups against ChatViewModel's persisted set — enabling receipts before the next lifecycle pass could send a receipt for a message read while the setting was off. The withheld branch now records into the owner's persisted set too (markReceiptHandled, wired in the bootstrapper); test strengthened to require both sets. Co-Authored-By: Claude Fable 5 --- bitchat/Services/PrivateChatManager.swift | 12 ++++++++++-- bitchat/ViewModels/ChatViewModelBootstrapper.swift | 3 +++ bitchatTests/ChatViewModelTests.swift | 9 ++++++--- 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/bitchat/Services/PrivateChatManager.swift b/bitchat/Services/PrivateChatManager.swift index 3141ec0b..a243d570 100644 --- a/bitchat/Services/PrivateChatManager.swift +++ b/bitchat/Services/PrivateChatManager.swift @@ -252,15 +252,23 @@ final class PrivateChatManager: ObservableObject { /// suites through the shared UserDefaults-backed setting. var sendsReadReceipts: () -> Bool = { ReadReceiptSettings.sendReadReceipts } + /// Records a withheld receipt in the owner's persisted set too: the + /// lifecycle read pass dedups against ChatViewModel.sentReadReceipts, + /// not this manager's set, so claiming only locally would let a receipt + /// for a message read while the setting was OFF fire after re-enabling. + var markReceiptHandled: ((String) -> Void)? + private func sendReadReceipt(for message: BitchatMessage) { guard !sentReadReceipts.contains(message.id), let senderPeerID = message.senderPeerID else { return } - // Withheld receipts are still claimed below as sent: re-enabling the - // setting must never fire a retroactive burst disclosing past reads. + // Withheld receipts are still claimed as sent — in BOTH tracking + // sets: re-enabling the setting must never fire a retroactive burst + // disclosing past reads, from this manager or the lifecycle pass. guard sendsReadReceipts() else { sentReadReceipts.insert(message.id) + markReceiptHandled?(message.id) return } diff --git a/bitchat/ViewModels/ChatViewModelBootstrapper.swift b/bitchat/ViewModels/ChatViewModelBootstrapper.swift index 3ccb7d12..ca769b05 100644 --- a/bitchat/ViewModels/ChatViewModelBootstrapper.swift +++ b/bitchat/ViewModels/ChatViewModelBootstrapper.swift @@ -90,6 +90,9 @@ private extension ChatViewModelBootstrapper { viewModel.privateChatManager.conversationStore = viewModel.conversations viewModel.privateChatManager.messageRouter = viewModel.messageRouter viewModel.privateChatManager.unifiedPeerService = viewModel.unifiedPeerService + viewModel.privateChatManager.markReceiptHandled = { [weak viewModel] messageID in + viewModel?.markReadReceiptSent(messageID) + } viewModel.unifiedPeerService.messageRouter = viewModel.messageRouter // Surface silent outbox drops (attempt cap, TTL expiry, overflow // eviction) as a visible failure. The store's no-downgrade rule does diff --git a/bitchatTests/ChatViewModelTests.swift b/bitchatTests/ChatViewModelTests.swift index 1bd66497..b4e9109e 100644 --- a/bitchatTests/ChatViewModelTests.swift +++ b/bitchatTests/ChatViewModelTests.swift @@ -462,10 +462,13 @@ struct ChatViewModelServiceLifecycleTests { #expect(!sentReadReceipt) // ...while the chat is still marked read locally and the receipt is - // recorded as handled, so re-enabling the setting never fires a - // retroactive burst disclosing past reading activity. + // recorded as handled in BOTH tracking sets (the lifecycle pass + // dedups against the owner's persisted set, the manager against its + // own), so re-enabling the setting never fires a retroactive burst + // disclosing past reading activity from either path. #expect(!viewModel.unreadPrivateMessages.contains(peerID)) - #expect(viewModel.sentReadReceipts.contains("read-2") || viewModel.privateChatManager.sentReadReceipts.contains("read-2")) + #expect(viewModel.sentReadReceipts.contains("read-2")) + #expect(viewModel.privateChatManager.sentReadReceipts.contains("read-2")) } @Test @MainActor From 6dd14961c6c26f8490c7fad5741c07e70b1924f9 Mon Sep 17 00:00:00 2001 From: jack Date: Tue, 11 Aug 2026 10:06:45 +0200 Subject: [PATCH 5/6] Review fix: opt live-voice tests in via injectable provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex P1: the fixtures assumed the old default-ON preference — on a clean CI process the coordinator guard drops their frames. Instead of mutating the shared UserDefaults per test (which races parallel suites), the live-voice reads are now injectable (ChatLiveVoiceCoordinator.liveVoiceEnabled), the fixtures opt in per instance, and the toggle-off test drives the provider directly. Co-Authored-By: Claude Fable 5 --- .../ViewModels/ChatLiveVoiceCoordinator.swift | 9 ++++++-- .../ChatViewModel+PrivateChat.swift | 2 +- .../ChatLiveVoiceCoordinatorTests.swift | 22 +++++++++++++++---- bitchatTests/ChatViewModelTests.swift | 3 +++ 4 files changed, 29 insertions(+), 7 deletions(-) diff --git a/bitchat/ViewModels/ChatLiveVoiceCoordinator.swift b/bitchat/ViewModels/ChatLiveVoiceCoordinator.swift index 78fa83e1..acc4a298 100644 --- a/bitchat/ViewModels/ChatLiveVoiceCoordinator.swift +++ b/bitchat/ViewModels/ChatLiveVoiceCoordinator.swift @@ -79,6 +79,11 @@ enum VoiceBurstScope: Hashable { /// bubble so nobody sees a duplicate. @MainActor final class ChatLiveVoiceCoordinator { + /// Injectable so tests exercise the live path without racing other + /// suites through the shared UserDefaults-backed preference (which now + /// defaults OFF). + var liveVoiceEnabled: () -> Bool = { PTTSettings.liveVoiceEnabled } + /// Burst IDs are sender-chosen, so they only identify a burst *within* /// an authenticated (peer, scope) pair: keying assemblies by the full /// triple stops an attacker who observed a public burst ID from racing @@ -187,7 +192,7 @@ final class ChatLiveVoiceCoordinator { // Live voice off means classic-notes-only in both directions: no live // bubble, no partial file, no early notification — the finalized // voice note still arrives through the normal pipeline. - guard PTTSettings.liveVoiceEnabled else { + guard liveVoiceEnabled() else { SecureLogger.debug("PTT: dropping inbound voice frame — live voice is toggled off", category: .session) return } @@ -403,7 +408,7 @@ final class ChatLiveVoiceCoordinator { case .directMessage: context.selectedPrivateChatPeer == peerID case .publicMesh: context.isViewingPublicMeshTimeline } - if PTTSettings.liveVoiceEnabled, PTTSettings.isAppActive, isViewing { + if liveVoiceEnabled(), PTTSettings.isAppActive, isViewing { assembly.player = PTTBurstPlayer() } diff --git a/bitchat/ViewModels/Extensions/ChatViewModel+PrivateChat.swift b/bitchat/ViewModels/Extensions/ChatViewModel+PrivateChat.swift index 9dad8030..968da1a9 100644 --- a/bitchat/ViewModels/Extensions/ChatViewModel+PrivateChat.swift +++ b/bitchat/ViewModels/Extensions/ChatViewModel+PrivateChat.swift @@ -69,7 +69,7 @@ extension ChatViewModel { @MainActor private func liveVoiceTarget() -> LiveVoiceTarget? { - guard PTTSettings.liveVoiceEnabled else { return nil } + guard liveVoiceCoordinator.liveVoiceEnabled() else { return nil } if let selectedPeer = selectedPrivateChatPeer { guard !selectedPeer.isGeoDM, !selectedPeer.isGeoChat, !selectedPeer.isGroup else { return nil } diff --git a/bitchatTests/ChatLiveVoiceCoordinatorTests.swift b/bitchatTests/ChatLiveVoiceCoordinatorTests.swift index 2e5ca317..67b509c7 100644 --- a/bitchatTests/ChatLiveVoiceCoordinatorTests.swift +++ b/bitchatTests/ChatLiveVoiceCoordinatorTests.swift @@ -127,6 +127,7 @@ struct ChatLiveVoiceCoordinatorTests { @Test func burstCreatesBubbleAndPersistsFramesInOrder() throws { let context = MockChatLiveVoiceContext() let coordinator = ChatLiveVoiceCoordinator(context: context, sweepsOnInit: false) + coordinator.liveVoiceEnabled = { true } let burstID = makeBurstID(0xA1) defer { fallbackFileURL(burstID: burstID, peerID: peer).map { try? FileManager.default.removeItem(at: $0) } } @@ -168,6 +169,7 @@ struct ChatLiveVoiceCoordinatorTests { let context = MockChatLiveVoiceContext() context.selectedPrivateChatPeer = peer let coordinator = ChatLiveVoiceCoordinator(context: context, sweepsOnInit: false) + coordinator.liveVoiceEnabled = { true } let burstID = makeBurstID(0xB2) let hex = burstID.hexEncodedString() let fileName = "voice_\(hex).m4a" @@ -223,6 +225,7 @@ struct ChatLiveVoiceCoordinatorTests { @Test func absorbIgnoresUnrelatedVoiceNotes() throws { let context = MockChatLiveVoiceContext() let coordinator = ChatLiveVoiceCoordinator(context: context, sweepsOnInit: false) + coordinator.liveVoiceEnabled = { true } // A classic voice note (date-stamped name) and a live-capture name // must both pass through untouched. @@ -247,6 +250,7 @@ struct ChatLiveVoiceCoordinatorTests { @Test func canceledBurstRemovesBubbleAndFile() throws { let context = MockChatLiveVoiceContext() let coordinator = ChatLiveVoiceCoordinator(context: context, sweepsOnInit: false) + coordinator.liveVoiceEnabled = { true } let burstID = makeBurstID(0xC3) send(try #require(VoiceBurstPacket(burstID: burstID, seq: 1, kind: .frames([Data(repeating: 9, count: 40)]))), to: coordinator, from: peer) @@ -262,6 +266,7 @@ struct ChatLiveVoiceCoordinatorTests { @Test func emptyBurstLeavesNoBubble() throws { let context = MockChatLiveVoiceContext() let coordinator = ChatLiveVoiceCoordinator(context: context, sweepsOnInit: false) + coordinator.liveVoiceEnabled = { true } let burstID = makeBurstID(0xD4) send(try #require(VoiceBurstPacket(burstID: burstID, seq: 0, kind: .start(codec: .aacLC16kMono))), to: coordinator, from: peer) @@ -275,6 +280,7 @@ struct ChatLiveVoiceCoordinatorTests { @Test func ignoresBlockedPeersAndUnknownControlPackets() throws { let context = MockChatLiveVoiceContext() let coordinator = ChatLiveVoiceCoordinator(context: context, sweepsOnInit: false) + coordinator.liveVoiceEnabled = { true } context.blockedPeers = [peer] send(try #require(VoiceBurstPacket(burstID: makeBurstID(0xE5), seq: 0, kind: .start(codec: .aacLC16kMono))), to: coordinator, from: peer) @@ -290,6 +296,7 @@ struct ChatLiveVoiceCoordinatorTests { @Test func concurrentAssemblyCapDropsExtraBursts() throws { let context = MockChatLiveVoiceContext() let coordinator = ChatLiveVoiceCoordinator(context: context, sweepsOnInit: false) + coordinator.liveVoiceEnabled = { true } var cleanup: [Data] = [] defer { @@ -309,12 +316,10 @@ struct ChatLiveVoiceCoordinatorTests { } @Test func liveVoiceToggleOffDropsInboundFrames() throws { - let previous = PTTSettings.liveVoiceEnabled - PTTSettings.liveVoiceEnabled = false - defer { PTTSettings.liveVoiceEnabled = previous } - let context = MockChatLiveVoiceContext() let coordinator = ChatLiveVoiceCoordinator(context: context, sweepsOnInit: false) + coordinator.liveVoiceEnabled = { true } + coordinator.liveVoiceEnabled = { false } let burstID = makeBurstID(0xE8) // Off means classic-notes-only: no live bubble, no partial file. @@ -328,6 +333,7 @@ struct ChatLiveVoiceCoordinatorTests { @Test func publicBurstCreatesMeshBubbleAndTracksTalker() throws { let context = MockChatLiveVoiceContext() let coordinator = ChatLiveVoiceCoordinator(context: context, sweepsOnInit: false) + coordinator.liveVoiceEnabled = { true } let burstID = makeBurstID(0x71) defer { incomingFileURL(burstID: burstID, peerID: peer, scope: .publicMesh).map { try? FileManager.default.removeItem(at: $0) } @@ -369,6 +375,7 @@ struct ChatLiveVoiceCoordinatorTests { @Test func absorbEnforcesScopeBinding() throws { let context = MockChatLiveVoiceContext() let coordinator = ChatLiveVoiceCoordinator(context: context, sweepsOnInit: false) + coordinator.liveVoiceEnabled = { true } let burstID = makeBurstID(0x72) defer { fallbackFileURL(burstID: burstID, peerID: peer).map { try? FileManager.default.removeItem(at: $0) } } @@ -400,6 +407,7 @@ struct ChatLiveVoiceCoordinatorTests { @Test func collidingBurstIDFromAnotherPeerCannotHijackAssembly() throws { let context = MockChatLiveVoiceContext() let coordinator = ChatLiveVoiceCoordinator(context: context, sweepsOnInit: false) + coordinator.liveVoiceEnabled = { true } let burstID = makeBurstID(0x73) let attacker = PeerID(str: "ddddeeeeffff0002") defer { @@ -435,6 +443,7 @@ struct ChatLiveVoiceCoordinatorTests { @Test func sameBurstIDCoexistsAcrossScopes() throws { let context = MockChatLiveVoiceContext() let coordinator = ChatLiveVoiceCoordinator(context: context, sweepsOnInit: false) + coordinator.liveVoiceEnabled = { true } let burstID = makeBurstID(0x74) defer { fallbackFileURL(burstID: burstID, peerID: peer).map { try? FileManager.default.removeItem(at: $0) } @@ -492,6 +501,7 @@ struct ChatLiveVoiceCoordinatorTests { @Test func finalizedNoteBindsToItsAuthenticatedSender() throws { let context = MockChatLiveVoiceContext() let coordinator = ChatLiveVoiceCoordinator(context: context, sweepsOnInit: false) + coordinator.liveVoiceEnabled = { true } let burstID = makeBurstID(0x75) let hex = burstID.hexEncodedString() let attacker = PeerID(str: "ddddeeeeffff0002") @@ -550,6 +560,7 @@ struct ChatLiveVoiceCoordinatorTests { let context = MockChatLiveVoiceContext() let coordinator = ChatLiveVoiceCoordinator(context: context, fileStore: store) + coordinator.liveVoiceEnabled = { true } let burstID = makeBurstID(0x76) send(try #require(VoiceBurstPacket(burstID: burstID, seq: 0, kind: .start(codec: .aacLC16kMono))), to: coordinator, from: peer) @@ -571,6 +582,7 @@ struct ChatLiveVoiceCoordinatorTests { let context = MockChatLiveVoiceContext() let coordinator = ChatLiveVoiceCoordinator(context: context, fileStore: store) + coordinator.liveVoiceEnabled = { true } let burstID = makeBurstID(0x77) send(try #require(VoiceBurstPacket(burstID: burstID, seq: 0, kind: .start(codec: .aacLC16kMono))), to: coordinator, from: peer) send(try #require(VoiceBurstPacket(burstID: burstID, seq: 1, kind: .frames([Data(repeating: 8, count: 60)]))), to: coordinator, from: peer) @@ -618,6 +630,7 @@ struct ChatLiveVoiceCoordinatorTests { // sender out of range): the capture is the row's only audio. let context = MockChatLiveVoiceContext() let coordinator = ChatLiveVoiceCoordinator(context: context, fileStore: store) + coordinator.liveVoiceEnabled = { true } let burstID = makeBurstID(0x79) let frame = Data(repeating: 0x0B, count: 60) send(try #require(VoiceBurstPacket(burstID: burstID, seq: 1, kind: .frames([frame]))), to: coordinator, from: peer) @@ -642,6 +655,7 @@ struct ChatLiveVoiceCoordinatorTests { let context = MockChatLiveVoiceContext() let coordinator = ChatLiveVoiceCoordinator(context: context, fileStore: store) + coordinator.liveVoiceEnabled = { true } let burstID = makeBurstID(0x7A) send(try #require(VoiceBurstPacket(burstID: burstID, seq: 1, kind: .frames([Data(repeating: 0x0C, count: 60)]))), to: coordinator, from: peer) send(try #require(VoiceBurstPacket(burstID: burstID, seq: 2, kind: .end(totalDataPackets: 1, durationMs: 64))), to: coordinator, from: peer) diff --git a/bitchatTests/ChatViewModelTests.swift b/bitchatTests/ChatViewModelTests.swift index b4e9109e..b7684aa3 100644 --- a/bitchatTests/ChatViewModelTests.swift +++ b/bitchatTests/ChatViewModelTests.swift @@ -1591,6 +1591,9 @@ struct ChatViewModelPrivateMediaDeletionTests { kind: .canceled )) let coordinator = viewModel.liveVoiceCoordinator + // The live-voice preference defaults OFF now; this fixture exercises + // the opted-in live path. + coordinator.liveVoiceEnabled = { true } defer { coordinator.handleVoiceFramePayload( from: peerID, From c58ad9af1090463fdb4bb5ee9b6d154b7d16fc7e Mon Sep 17 00:00:00 2001 From: jack Date: Tue, 11 Aug 2026 10:09:57 +0200 Subject: [PATCH 6/6] Review fixes: unread flag + favorites-aware naming for the warning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Codex P1: a background identity change appended the warning without marking the chat unread — a security event nobody was looking at stayed invisible until the conversation was manually opened. It now sets the unread flag unless the chat is open. - Codex P2: offline key rotation is the common case here, and resolveNickname falls back to an anon prefix precisely then (no mesh nickname, no social identity for the unverified new fingerprint). The persisted favorite relationship's nickname is preferred. Both pinned by tests. Co-Authored-By: Claude Fable 5 --- .../ChatPeerIdentityCoordinator.swift | 19 ++++++++++- ...tPeerIdentityCoordinatorContextTests.swift | 33 +++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift b/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift index 2f6f7270..b67fae5f 100644 --- a/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift +++ b/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift @@ -24,6 +24,9 @@ protocol ChatPeerIdentityContext: AnyObject { var unreadPrivateMessages: Set { get } /// Clears the peer's unread flag (single-writer store intent). func markPrivateChatRead(_ peerID: PeerID) + /// Sets the peer's unread flag (shared requirement with the inbound DM + /// paths; witness on `ChatViewModel`). + func markPrivateChatUnread(_ peerID: PeerID) /// Moves all messages from `oldPeerID`'s chat into `newPeerID`'s chat /// (dedup by ID, order preserved, unread carried, old chat removed). func migratePrivateChat(from oldPeerID: PeerID, to newPeerID: PeerID) @@ -621,12 +624,20 @@ extension ChatPeerIdentityCoordinator { // inherit the thread's earned trust under the same nickname. Only // warn when there is a conversation to protect. if wasSelected || !context.privateMessages(for: newPeerID).isEmpty { + // Offline key rotation is the common case here (a favorite came + // back with new keys), and resolveNickname has no mesh nickname + // and no social identity for a fingerprint nobody verified yet — + // the persisted favorite relationship still knows who this is. + let favoriteNickname = newPeerID.noiseKey + .flatMap { context.favoriteRelationship(forNoiseKey: $0)?.peerNickname } + .flatMap { $0.isEmpty ? nil : $0 } + let displayName = favoriteNickname ?? resolveNickname(for: newPeerID) let notice = BitchatMessage( sender: "system", content: String( format: String(localized: "system.identity.key_changed", defaultValue: "%@'s identity key changed — this can mean a new device or a reset. earlier verification no longer applies; verify them again before trusting this chat.", comment: "Private-chat system warning after a peer's Noise identity key changed; placeholder is the peer's name"), locale: .current, - resolveNickname(for: newPeerID) + displayName ), timestamp: Date(), isRelay: false, @@ -636,6 +647,12 @@ extension ChatPeerIdentityCoordinator { senderPeerID: context.myPeerID ) context.appendPrivateMessage(notice, to: newPeerID) + // A security event nobody is looking at must not stay silent: + // surface it through the unread indicator unless the chat is + // open right now. + if !wasSelected { + context.markPrivateChatUnread(newPeerID) + } context.notifyUIChanged() } } diff --git a/bitchatTests/ChatPeerIdentityCoordinatorContextTests.swift b/bitchatTests/ChatPeerIdentityCoordinatorContextTests.swift index d14f766a..0def7e2c 100644 --- a/bitchatTests/ChatPeerIdentityCoordinatorContextTests.swift +++ b/bitchatTests/ChatPeerIdentityCoordinatorContextTests.swift @@ -46,6 +46,10 @@ private final class MockChatPeerIdentityContext: ChatPeerIdentityContext { unreadPrivateMessages.remove(peerID) } + func markPrivateChatUnread(_ peerID: PeerID) { + unreadPrivateMessages.insert(peerID) + } + @discardableResult func appendPrivateMessage(_ message: BitchatMessage, to peerID: PeerID) -> Bool { var chat = privateChats[peerID] ?? [] @@ -390,6 +394,35 @@ struct ChatPeerIdentityCoordinatorContextTests { #expect(notice?.content.contains("identity key changed") == true) #expect(notice?.content.contains("alice") == true) #expect(context.privateChats[oldPeerID] == nil) + // A background security event must surface via the unread indicator. + #expect(context.unreadPrivateMessages.contains(newPeerID)) + } + + @Test @MainActor + func migrateNoiseKeyUpdate_namesOfflineFavoritesFromTheRelationship() async { + let context = MockChatPeerIdentityContext() + let coordinator = ChatPeerIdentityCoordinator(context: context) + let oldPeerID = PeerID(str: "5555555555555555") + let newKey = Data(repeating: 0xCD, count: 32) + let newPeerID = PeerID(hexData: newKey) + // Offline key rotation: no mesh nickname, no social identity for the + // unverified new fingerprint — only the favorite relationship knows + // who this is. + context.favoriteRelationshipsByNoiseKey[newKey] = FavoritesPersistenceService.FavoriteRelationship( + peerNoisePublicKey: newKey, + peerNostrPublicKey: nil, + peerNickname: "carol", + isFavorite: true, + theyFavoritedUs: true, + favoritedAt: Date(timeIntervalSince1970: 0), + lastUpdated: Date(timeIntervalSince1970: 0) + ) + context.privateChats[oldPeerID] = [makePrivateMessage(id: "m2", timestamp: Date(timeIntervalSince1970: 1))] + + coordinator.migrateNoiseKeyUpdate(oldPeerID: oldPeerID, newPeerID: newPeerID) + + let notice = (context.privateChats[newPeerID] ?? []).last + #expect(notice?.content.contains("carol") == true) } @Test @MainActor