From 3e46829f32fa07c96127ba03e3201d8c041e7a45 Mon Sep 17 00:00:00 2001 From: jack Date: Tue, 11 Aug 2026 09:17:55 +0200 Subject: [PATCH 01/13] 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 02/13] 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 03/13] 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 a05c67252fce5278530bbe2a03cee2618359f373 Mon Sep 17 00:00:00 2001 From: jack Date: Tue, 11 Aug 2026 09:31:39 +0200 Subject: [PATCH 04/13] Close the system-message spoofing hole in the action parser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit processActionMessage rewrote the sender to "system" — the formatter's trusted styling — for ANY `* … *` content containing 🫂, 🐟, or the substring "took a screenshot". A hostile peer could render arbitrary text as a system-authored line: `* SECURITY: your session key expired, re-verify at evil.example — bob took a screenshot *`. Only the exact locally-generatable action shapes qualify now, and the actor slot must equal the actual wire sender: a peer can hug, slap, or screenshot only as themselves, and the target slot is bounded, single-line, and nickname-shaped. Same templates iOS and Android emit, so legit actions render unchanged; anything else falls through as an ordinary peer message under the sender's real name. Pinned by tests covering the legit shapes, the original spoof payload, actor-mismatch, and free-text-in-target-slot cases. No new strings. Co-Authored-By: Claude Fable 5 --- .../ChatPrivateConversationCoordinator.swift | 29 ++++++++++++--- ...eConversationCoordinatorContextTests.swift | 37 +++++++++++++++++++ 2 files changed, 61 insertions(+), 5 deletions(-) diff --git a/bitchat/ViewModels/ChatPrivateConversationCoordinator.swift b/bitchat/ViewModels/ChatPrivateConversationCoordinator.swift index ccb09492..877280d5 100644 --- a/bitchat/ViewModels/ChatPrivateConversationCoordinator.swift +++ b/bitchat/ViewModels/ChatPrivateConversationCoordinator.swift @@ -823,11 +823,21 @@ final class ChatPrivateConversationCoordinator { } func processActionMessage(_ message: BitchatMessage) -> BitchatMessage { - let isActionMessage = message.content.hasPrefix("* ") - && message.content.hasSuffix(" *") - && (message.content.contains("🫂") - || message.content.contains("🐟") - || message.content.contains("took a screenshot")) + // This rewrite hands the message to the formatter as sender "system", + // which styles it as trusted, non-peer content. Substring sniffing + // ("contains 🫂") let ANY sender put arbitrary text in that styling: + // `* SECURITY: session expired — bob took a screenshot *` rendered as + // a system-authored line. Only the exact, locally-generatable action + // shapes qualify, and the actor slot must be the actual wire sender — + // a peer can only "hug"/"slap"/"screenshot" as themselves. + guard message.content.hasPrefix("* "), message.content.hasSuffix(" *") else { return message } + let inner = String(message.content.dropFirst(2).dropLast(2)) + let sender = message.sender + + let isActionMessage = + Self.matchesActionTemplate(inner, prefix: "🫂 \(sender) hugs ", suffix: "") + || Self.matchesActionTemplate(inner, prefix: "🐟 \(sender) slaps ", suffix: " around a bit with a large trout") + || inner == "\(sender) took a screenshot" guard isActionMessage else { return message } @@ -846,6 +856,15 @@ final class ChatPrivateConversationCoordinator { ) } + /// The target slot of an action template: bounded, single-line, and + /// non-empty — a nickname or the literal "you", never free text. + static func matchesActionTemplate(_ inner: String, prefix: String, suffix: String) -> Bool { + guard inner.hasPrefix(prefix), inner.hasSuffix(suffix), + inner.count >= prefix.count + suffix.count + 1 else { return false } + let target = inner.dropFirst(prefix.count).dropLast(suffix.count) + return !target.isEmpty && target.count <= 64 && !target.contains("\n") + } + func migratePrivateChatsIfNeeded(for peerID: PeerID, senderNickname: String) { let currentFingerprint = context.getFingerprint(for: peerID) diff --git a/bitchatTests/ChatPrivateConversationCoordinatorContextTests.swift b/bitchatTests/ChatPrivateConversationCoordinatorContextTests.swift index 54cb341e..b0353264 100644 --- a/bitchatTests/ChatPrivateConversationCoordinatorContextTests.swift +++ b/bitchatTests/ChatPrivateConversationCoordinatorContextTests.swift @@ -327,6 +327,43 @@ private func makeFavoriteRelationship( /// live singletons. struct ChatPrivateConversationCoordinatorContextTests { + @Test @MainActor + func processActionMessage_rendersOnlyExactSenderAnchoredTemplates() async { + let context = MockChatPrivateConversationContext() + let coordinator = ChatPrivateConversationCoordinator(context: context) + + func processed(_ content: String, sender: String = "bob") -> BitchatMessage { + coordinator.processActionMessage( + BitchatMessage( + id: UUID().uuidString, + sender: sender, + content: content, + timestamp: Date(), + isRelay: false + ) + ) + } + + // Exact locally-generatable shapes, actor == wire sender: system. + #expect(processed("* 🫂 bob hugs alice *").sender == "system") + #expect(processed("* 🫂 bob hugs you *").sender == "system") + #expect(processed("* 🐟 bob slaps alice around a bit with a large trout *").sender == "system") + #expect(processed("* bob took a screenshot *").sender == "system") + + // The spoof this parser used to allow: arbitrary text between the + // markers with a magic substring rendered as system-authored. + #expect(processed("* SECURITY: your session key expired, re-verify at evil.example — bob took a screenshot *").sender == "bob") + #expect(processed("* 🫂 admin hugs alice — send your keys to @admin *").sender == "bob") + // Actor slot must be the actual sender, not someone else's name. + #expect(processed("* alice took a screenshot *", sender: "bob").sender == "bob") + #expect(processed("* 🫂 alice hugs you *", sender: "bob").sender == "bob") + // Free text smuggled into the target slot: bounded, single-line only. + #expect(processed("* 🫂 bob hugs " + String(repeating: "x", count: 200) + " *").sender == "bob") + #expect(processed("* 🫂 bob hugs a\nb *").sender == "bob") + // Not an action shape at all. + #expect(processed("hello there").sender == "bob") + } + @Test @MainActor func addMessageToPrivateChats_upsertsByIdAndSanitizes() async { let context = MockChatPrivateConversationContext() From d4d78e85ebf3cc6ea10fdea85a956ad57d85169a Mon Sep 17 00:00:00 2001 From: jack Date: Tue, 11 Aug 2026 09:41:50 +0200 Subject: [PATCH 05/13] Favorites consent, visible mutuality, and honest copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The star read like a private bookmark but behaved like a friend request plus an identity handshake: tapping it immediately notified the peer ("alice favorited you") and transmitted the durable Nostr public key — while the tapper saw nothing. And the load-bearing state, mutuality (one-sided favorites do NOT enable offline delivery), was invisible at the point of decision. - One-time consent: the first favorite ever asks, naming the person and disclosing the notification + key sharing (both star surfaces: peer list and DM header). Acknowledged once; reset by panic wipe. /fav (an explicit typed command) proceeds without a dialog. - Mutuality visible where the decision happens: half star until reciprocated, filled star when mutual, with tooltips and VoiceOver state labels for both. The DM header state now carries isMutualFavorite. - FEATURES copy described favorites as notifications; it now leads with the actual mechanism (offline messaging via nostr when mutual). - Geohash block copy no longer implies a global block: identities are derived per-geohash, so the same person appears as someone new in other channels — the message says so now (both the context-menu and /block paths). 7 new strings + 3 updated values, all 30 locales. Co-Authored-By: Claude Fable 5 --- bitchat/App/PrivateConversationModels.swift | 5 + bitchat/Localizable.xcstrings | 1482 ++++++++++++++++- bitchat/Services/CommandProcessor.swift | 2 +- bitchat/Services/FavoriteConsent.swift | 38 + .../ChatPublicConversationCoordinator.swift | 3 +- bitchat/ViewModels/ChatViewModel.swift | 1 + bitchat/Views/ContentSheetViews.swift | 36 +- bitchat/Views/MeshPeerList.swift | 57 +- bitchatTests/ChatViewModelTests.swift | 15 + 9 files changed, 1542 insertions(+), 97 deletions(-) create mode 100644 bitchat/Services/FavoriteConsent.swift diff --git a/bitchat/App/PrivateConversationModels.swift b/bitchat/App/PrivateConversationModels.swift index 80d5cf29..dcdd99eb 100644 --- a/bitchat/App/PrivateConversationModels.swift +++ b/bitchat/App/PrivateConversationModels.swift @@ -105,6 +105,9 @@ struct PrivateConversationHeaderState: Equatable { let displayName: String let availability: PrivateConversationAvailability let isFavorite: Bool + /// Whether the favorite is reciprocated — the load-bearing state: + /// one-sided favorites do NOT enable offline delivery. + let isMutualFavorite: Bool let encryptionStatus: EncryptionStatus? var supportsFavoriteToggle: Bool { @@ -258,6 +261,7 @@ final class PrivateConversationModel: ObservableObject { displayName: displayName, availability: .meshReachable, isFavorite: false, + isMutualFavorite: false, encryptionStatus: nil ) } @@ -282,6 +286,7 @@ final class PrivateConversationModel: ObservableObject { displayName: displayName, availability: availability, isFavorite: chatViewModel.isFavorite(peerID: headerPeerID), + isMutualFavorite: peer?.isMutualFavorite ?? false, encryptionStatus: encryptionStatus ) } diff --git a/bitchat/Localizable.xcstrings b/bitchat/Localizable.xcstrings index 06b653d3..0a526b01 100644 --- a/bitchat/Localizable.xcstrings +++ b/bitchat/Localizable.xcstrings @@ -5216,181 +5216,181 @@ "ar" : { "stringUnit" : { "state" : "translated", - "value" : "تم حظر %@ في محادثات geohash" + "value" : "تم حظر %@ في قناة geohash هذه — في قنوات geohash الأخرى يظهر كشخص جديد، فاحظره هناك أيضًا إذا لزم الأمر" } }, "bn" : { "stringUnit" : { "state" : "translated", - "value" : "geohash চ্যাটে %@-কে ব্লক করা হলো" + "value" : "এই geohash চ্যানেলে %@ কে ব্লক করা হয়েছে — অন্য geohash চ্যানেলে সে নতুন কেউ হিসেবে দেখা দেবে, তাই প্রয়োজনে সেখানেও ব্লক করুন" } }, "de" : { "stringUnit" : { "state" : "translated", - "value" : "%@ in geohash-chats blockiert" + "value" : "%@ in diesem geohash-kanal blockiert — in anderen geohash-kanälen erscheint diese person als jemand neues, blockiere sie dort bei bedarf ebenfalls" } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "blocked %@ in geohash chats" + "value" : "blocked %@ in this geohash channel — in other geohash channels they appear as someone new, so block them there too if needed" } }, "es" : { "stringUnit" : { "state" : "translated", - "value" : "%@ bloqueado en los chats de geohash" + "value" : "has bloqueado a %@ en este canal geohash — en otros canales geohash aparece como alguien nuevo, así que bloquéalo también allí si hace falta" } }, "fa" : { "stringUnit" : { "state" : "translated", - "value" : "%@ در چت‌های geohash مسدود شد" + "value" : "%@ در این کانال geohash مسدود شد — در کانال‌های geohash دیگر به‌عنوان فردی جدید ظاهر می‌شود، پس در صورت نیاز آنجا هم او را مسدود کنید" } }, "fil" : { "stringUnit" : { "state" : "translated", - "value" : "na-block si %@ sa mga geohash chat" + "value" : "na-block si %@ sa geohash channel na ito — sa ibang geohash channel, lilitaw siya bilang bagong tao, kaya i-block din siya roon kung kailangan" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "%@ bloqué dans les chats geohash" + "value" : "%@ a été bloqué dans ce canal geohash — dans les autres canaux geohash, cette personne apparaît comme quelqu'un de nouveau, alors bloque-la aussi là-bas si besoin" } }, "he" : { "stringUnit" : { "state" : "translated", - "value" : "%@ נחסם בצ'אטים של geohash" + "value" : "%@ נחסם בערוץ ה‑geohash הזה — בערוצי geohash אחרים הוא יופיע כמישהו חדש, אז אם צריך, כדאי לחסום אותו גם שם" } }, "hi" : { "stringUnit" : { "state" : "translated", - "value" : "geohash चैट में %@ को ब्लॉक किया गया" + "value" : "इस geohash चैनल में %@ को ब्लॉक किया गया — दूसरे geohash चैनलों में वे किसी नए व्यक्ति की तरह दिखेंगे, इसलिए ज़रूरत हो तो वहाँ भी ब्लॉक करें" } }, "id" : { "stringUnit" : { "state" : "translated", - "value" : "%@ diblokir di chat geohash" + "value" : "%@ diblokir di channel geohash ini — di channel geohash lain dia muncul sebagai orang baru, jadi blokir juga di sana kalau perlu" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "%@ bloccato nelle chat geohash" + "value" : "%@ bloccato in questo canale geohash — negli altri canali geohash appare come una persona nuova, quindi bloccalo anche lì se serve" } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "geohash チャットで %@ をブロックした" + "value" : "このgeohashチャンネルで%@をブロックしました — 他のgeohashチャンネルでは別人として表示されるため、必要ならそちらでもブロックしてください" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "geohash 채팅에서 %@을(를) 차단했어요" + "value" : "이 geohash 채널에서 %@을(를) 차단했어요 — 다른 geohash 채널에서는 새로운 사람으로 표시되니 필요하면 거기서도 차단하세요" } }, "ms" : { "stringUnit" : { "state" : "translated", - "value" : "%@ disekat dalam sembang geohash" + "value" : "%@ disekat dalam saluran geohash ini — dalam saluran geohash lain dia muncul sebagai orang baharu, jadi sekat juga di sana jika perlu" } }, "ne" : { "stringUnit" : { "state" : "translated", - "value" : "geohash च्याटहरूमा %@ ब्लक गरियो" + "value" : "यस geohash च्यानलमा %@ लाई ब्लक गरियो — अन्य geohash च्यानलहरूमा उनी नयाँ व्यक्तिजस्तै देखिन्छन्, त्यसैले आवश्यक परे त्यहाँ पनि ब्लक गर्नुहोस्" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "%@ geblokkeerd in geohash-chats" + "value" : "%@ geblokkeerd in dit geohash-kanaal — in andere geohash-kanalen verschijnt diegene als iemand nieuws, dus blokkeer daar ook als dat nodig is" } }, "pl" : { "stringUnit" : { "state" : "translated", - "value" : "zablokowano %@ w czatach geohash" + "value" : "zablokowano %@ w tym kanale geohash — w innych kanałach geohash ta osoba pojawia się jako ktoś nowy, więc w razie potrzeby zablokuj ją też tam" } }, "pt" : { "stringUnit" : { "state" : "translated", - "value" : "%@ bloqueado nas conversas geohash" + "value" : "%@ bloqueado neste canal geohash — noutros canais geohash aparece como alguém novo, por isso bloqueia-o também aí se for preciso" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "%@ bloqueado nos chats geohash" + "value" : "%@ bloqueado neste canal geohash — em outros canais geohash essa pessoa aparece como alguém novo, então bloqueie lá também se precisar" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "%@ заблокирован в geohash-чатах" + "value" : "%@ заблокирован в этом geohash-канале — в других geohash-каналах этот человек выглядит как кто-то новый, так что при необходимости заблокируй его и там" } }, "sv" : { "stringUnit" : { "state" : "translated", - "value" : "blockerade %@ i geohash-chattar" + "value" : "%@ blockerad i den här geohash-kanalen — i andra geohash-kanaler dyker personen upp som någon ny, så blockera hen där också om det behövs" } }, "ta" : { "stringUnit" : { "state" : "translated", - "value" : "geohash அரட்டைகளில் %@ தடுக்கப்பட்டார்" + "value" : "இந்த geohash சேனலில் %@ தடுக்கப்பட்டார் — மற்ற geohash சேனல்களில் அவர் புதிய நபராகத் தோன்றுவார், தேவைப்பட்டால் அங்கும் தடுக்கவும்" } }, "th" : { "stringUnit" : { "state" : "translated", - "value" : "บล็อก %@ ในแชท geohash แล้ว" + "value" : "บล็อก %@ ในช่อง geohash นี้แล้ว — ในช่อง geohash อื่นเขาจะปรากฏเป็นคนใหม่ ดังนั้นถ้าจำเป็นก็บล็อกที่นั่นด้วย" } }, "tr" : { "stringUnit" : { "state" : "translated", - "value" : "%@ geohash sohbetlerinde engellendi" + "value" : "%@ bu geohash kanalında engellendi — diğer geohash kanallarında yeni biri olarak görünür, gerekirse orada da engelle" } }, "uk" : { "stringUnit" : { "state" : "translated", - "value" : "%@ заблоковано в geohash-чатах" + "value" : "%@ заблоковано в цьому geohash-каналі — в інших geohash-каналах ця людина виглядає як хтось новий, тож за потреби заблокуй її й там" } }, "ur" : { "stringUnit" : { "state" : "translated", - "value" : "geohash چیٹس میں %@ کو بلاک کر دیا گیا" + "value" : "اس geohash چینل میں %@ کو بلاک کر دیا گیا — دوسرے geohash چینلز میں وہ کسی نئے فرد کے طور پر نظر آئیں گے، اس لیے ضرورت ہو تو وہاں بھی بلاک کریں" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "đã chặn %@ trong các kênh trò chuyện geohash" + "value" : "đã chặn %@ trong kênh geohash này — ở các kênh geohash khác, người đó xuất hiện như một người mới, nên hãy chặn ở đó nữa nếu cần" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "已在 geohash 聊天中屏蔽 %@" + "value" : "已在此 geohash 频道屏蔽 %@ — 在其他 geohash 频道中对方会显示为新面孔,如有需要请在那里也进行屏蔽" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "已在 geohash 聊天中封鎖 %@" + "value" : "已在此 geohash 頻道封鎖 %@ — 在其他 geohash 頻道中對方會顯示為新的人,如有需要請在那裡也一併封鎖" } } } @@ -16183,6 +16183,1308 @@ } } }, + "favorites.consent.confirm" : { + "comment" : "Confirm button of the one-time favorite consent dialog", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "إضافة مفضل" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "প্রিয় যোগ করুন" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "favorit hinzufügen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "add favorite" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "añadir favorito" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "افزودن موردعلاقه" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "idagdag" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "ajouter en favori" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "הוספה למועדפים" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "पसंदीदा जोड़ें" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "tambahkan favorit" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "aggiungi preferito" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "お気に入りに追加" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "즐겨찾기 추가" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "tambah kegemaran" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "मनपर्ने थप्नुहोस्" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "favoriet toevoegen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "dodaj do ulubionych" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "adicionar favorito" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "adicionar favorito" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "добавить в избранное" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "lägg till favorit" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "பிடித்தவராகச் சேர்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "เพิ่มรายการโปรด" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "favorilere ekle" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "додати в обране" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "پسندیدہ شامل کریں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "thêm yêu thích" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "添加星标" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "加入星標" + } + } + } + }, + "favorites.consent.message" : { + "comment" : "Body of the one-time favorite consent dialog; placeholder is the person's name", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "سيُعلم هذا %@ فورًا ويشارك مفتاح nostr الخاص بك معه. إذا أضافك إلى مفضلته أيضًا، يمكنكما مراسلة بعضكما عبر الإنترنت عندما تكونان خارج نطاق شبكة mesh." + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "এতে %@ কে সাথে সাথে জানানো হবে এবং আপনার nostr কী তার সাথে শেয়ার করা হবে। সে-ও যদি আপনাকে প্রিয় করে, তাহলে mesh-এর সীমার বাইরে থাকলে আপনারা ইন্টারনেটের মাধ্যমে একে অপরকে বার্তা পাঠাতে পারবেন।" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "das benachrichtigt %@ sofort und teilt deinen nostr-schlüssel mit dieser person. bei gegenseitigem favorisieren könnt ihr einander über das internet schreiben, wenn ihr außer mesh-reichweite seid." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "this tells %@ right away and shares your nostr key with them. if they favorite you back, you can message each other over the internet when out of mesh range." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "esto avisa a %@ de inmediato y comparte tu clave nostr con esa persona. si también te añade como favorito, podrán enviarse mensajes por internet cuando estén fuera del alcance de la red mesh." + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "این کار بلافاصله به %@ اطلاع می‌دهد و کلید nostr شما را با او به اشتراک می‌گذارد. اگر او هم شما را موردعلاقه کند، وقتی خارج از برد شبکه mesh باشید می‌توانید از طریق اینترنت به هم پیام بدهید." + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "aabisuhan agad si %@ at ibabahagi ang iyong nostr key sa kanya. kapag ginawa ka rin niyang paborito, makakapagpadala kayo ng mensahe sa isa't isa sa internet kapag wala sa saklaw ng mesh." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "cela prévient %@ immédiatement et partage ta clé nostr avec cette personne. si elle t'ajoute aussi en favori, vous pourrez vous écrire par internet quand vous serez hors de portée du mesh." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "הפעולה תודיע ל‑%@ מיד ותשתף איתם את מפתח ה‑nostr שלך. אם יוסיפו אותך למועדפים בחזרה, תוכלו לשלוח הודעות דרך האינטרנט כשאתם מחוץ לטווח ה‑mesh." + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "इससे %@ को तुरंत पता चल जाएगा और आपकी nostr कुंजी उनके साथ साझा होगी। अगर वे भी आपको पसंदीदा बनाते हैं, तो mesh की रेंज से बाहर होने पर आप इंटरनेट के ज़रिए एक-दूसरे को संदेश भेज सकते हैं।" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "ini langsung memberi tahu %@ dan membagikan kunci nostr-mu kepadanya. kalau dia juga menjadikanmu favorit, kalian bisa saling berkirim pesan lewat internet saat di luar jangkauan mesh." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "questo avvisa subito %@ e condivide la tua chiave nostr con questa persona. se ti aggiunge anche lei ai preferiti, potrete scrivervi via internet quando siete fuori dalla portata della mesh." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "追加すると%@にすぐ通知され、あなたのnostr鍵が相手に共有されます。相手もあなたをお気に入りにすると、meshの範囲外でもインターネット経由でメッセージをやり取りできます。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "추가하면 %@에게 바로 알림이 가고 내 nostr 키가 상대와 공유돼요. 상대도 나를 즐겨찾기에 추가하면 mesh 범위 밖에서도 인터넷으로 서로 메시지를 주고받을 수 있어요." + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "ini terus memberitahu %@ dan berkongsi kunci nostr anda dengannya. jika dia turut menjadikan anda kegemaran, anda berdua boleh berutus mesej melalui internet apabila di luar liputan mesh." + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "यसले %@ लाई तुरुन्तै थाहा दिन्छ र तपाईंको nostr कुञ्जी उनीसँग साझा गर्छ। उनले पनि तपाईंलाई मनपर्ने बनाए भने, mesh को दायराबाहिर हुँदा तपाईंहरू इन्टरनेटमार्फत एकअर्कालाई सन्देश पठाउन सक्नुहुन्छ।" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "dit laat het %@ meteen weten en deelt je nostr-sleutel met diegene. als diegene jou ook als favoriet toevoegt, kunnen jullie elkaar via internet berichten sturen wanneer jullie buiten bereik van het mesh zijn." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "to od razu powiadomi %@ i udostępni tej osobie twój klucz nostr. jeśli ona też doda cię do ulubionych, będziecie mogli pisać do siebie przez internet poza zasięgiem sieci mesh." + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "isto avisa %@ de imediato e partilha a tua chave nostr com essa pessoa. se ela também te marcar como favorito, podem trocar mensagens pela internet quando estiverem fora do alcance da mesh." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "isso avisa %@ na hora e compartilha sua chave nostr com essa pessoa. se ela também favoritar você, vocês podem trocar mensagens pela internet quando estiverem fora do alcance da mesh." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "это сразу уведомит %@ и передаст этому человеку твой ключ nostr. если он добавит тебя в избранное в ответ, вы сможете переписываться через интернет вне зоны действия mesh-сети." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "det här meddelar %@ direkt och delar din nostr-nyckel med personen. om personen också favoritmarkerar dig kan ni skicka meddelanden till varandra via internet när ni är utom räckhåll för mesh-nätet." + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "இது %@-க்கு உடனே தெரிவிக்கும், உங்கள் nostr சாவியையும் அவருடன் பகிரும். அவரும் உங்களைப் பிடித்தவராகச் சேர்த்தால், mesh எல்லைக்கு வெளியே இருக்கும்போது இணையம் வழியாக ஒருவருக்கொருவர் செய்தி அனுப்பலாம்." + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "การเพิ่มจะแจ้งให้ %@ ทราบทันทีและแชร์คีย์ nostr ของคุณให้เขา ถ้าเขาเพิ่มคุณเป็นรายการโปรดกลับ คุณทั้งคู่จะส่งข้อความหากันผ่านอินเทอร์เน็ตได้เมื่ออยู่นอกระยะ mesh" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "bu, %@ kişisine hemen haber verir ve nostr anahtarını onunla paylaşır. o da seni favorilerine eklerse, mesh menzili dışındayken internet üzerinden birbirinize mesaj gönderebilirsiniz." + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ одразу дізнається про це, і ця людина отримає твій ключ nostr. якщо вона додасть тебе в обране у відповідь, ви зможете листуватися через інтернет поза зоною дії mesh-мережі." + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "اس سے %@ کو فوراً اطلاع ملے گی اور آپ کی nostr کلید اس کے ساتھ شیئر ہوگی۔ اگر وہ بھی آپ کو پسندیدہ بنائیں تو mesh کی حد سے باہر ہونے پر آپ دونوں انٹرنیٹ کے ذریعے ایک دوسرے کو پیغام بھیج سکتے ہیں۔" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "việc này sẽ báo cho %@ ngay lập tức và chia sẻ khóa nostr của bạn với người đó. nếu người đó cũng thêm bạn vào yêu thích, hai bạn có thể nhắn tin cho nhau qua internet khi ngoài tầm mesh." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "这会立即通知 %@ 并与对方共享你的 nostr 密钥。如果对方也给你加星标,你们就能在超出 mesh 范围时通过互联网互发消息。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "這會立即通知 %@ 並與對方分享你的 nostr 金鑰。如果對方也把你加為星標,你們就能在超出 mesh 範圍時透過網際網路互傳訊息。" + } + } + } + }, + "favorites.consent.title" : { + "comment" : "Title of the one-time confirmation before the first favorite", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "إضافة إلى المفضلة؟" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "প্রিয় হিসেবে যোগ করবেন?" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "favorit hinzufügen?" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "add favorite?" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "¿añadir favorito?" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "افزودن به موردعلاقه‌ها؟" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "idagdag bilang paborito?" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "ajouter en favori ?" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "להוסיף למועדפים?" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "पसंदीदा में जोड़ें?" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "tambahkan favorit?" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "aggiungere ai preferiti?" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "お気に入りに追加しますか?" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "즐겨찾기에 추가할까요?" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "tambah kegemaran?" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "मनपर्नेमा थप्ने?" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "favoriet toevoegen?" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "dodać do ulubionych?" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "adicionar favorito?" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "adicionar favorito?" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "добавить в избранное?" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "lägga till favorit?" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "பிடித்தவராகச் சேர்க்கவா?" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "เพิ่มเป็นรายการโปรด?" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "favorilere eklensin mi?" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "додати в обране?" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "پسندیدہ میں شامل کریں؟" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "thêm vào yêu thích?" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "添加星标?" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "加入星標?" + } + } + } + }, + "mesh_peers.state.favorite_mutual" : { + "comment" : "State label for a reciprocated favorite", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "مفضلة متبادلة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "পারস্পরিক প্রিয়" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "gegenseitiger favorit" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "mutual favorite" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "favorito mutuo" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "موردعلاقه دوطرفه" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "paborito ng isa't isa" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "favori mutuel" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "מועדף הדדי" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "आपसी पसंदीदा" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "saling favorit" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "preferito reciproco" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "相互お気に入り" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "상호 즐겨찾기" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "kegemaran dua hala" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "आपसी मनपर्ने" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "wederzijdse favoriet" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "wzajemnie w ulubionych" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "favorito mútuo" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "favorito mútuo" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "взаимно в избранном" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "ömsesidig favorit" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "பரஸ்பரம் பிடித்தவர்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "รายการโปรดร่วมกัน" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "karşılıklı favori" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "взаємно в обраному" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "باہمی پسندیدہ" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "yêu thích lẫn nhau" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "互相星标" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "互相星標" + } + } + } + }, + "mesh_peers.state.favorite_pending" : { + "comment" : "State label for a favorite the peer has not reciprocated", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "مفضل، ليس متبادلًا بعد" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "প্রিয়, এখনও পারস্পরিক নয়" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "favorisiert, noch nicht gegenseitig" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "favorited, not mutual yet" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "favorito, aún no mutuo" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "موردعلاقه، هنوز دوطرفه نیست" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "naka-paborito, hindi pa mutual" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "en favori, pas encore mutuel" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "מועדף, עדיין לא הדדי" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "पसंदीदा, अभी आपसी नहीं" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "difavoritkan, belum saling" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "nei preferiti, non ancora reciproco" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "お気に入り済み・まだ相互ではありません" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "즐겨찾기함, 아직 상호 아님" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "kegemaran, belum dua hala" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "मनपर्ने, अझै आपसी छैन" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "favoriet, nog niet wederzijds" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "w ulubionych, jeszcze nie wzajemnie" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "favorito, ainda não mútuo" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "favoritado, ainda não mútuo" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "в избранном, пока не взаимно" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "favoritmarkerad, inte ömsesidig än" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "பிடித்தவர், இன்னும் பரஸ்பரம் இல்லை" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "รายการโปรด ยังไม่ร่วมกัน" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "favori, henüz karşılıklı değil" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "в обраному, ще не взаємно" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "پسندیدہ، ابھی باہمی نہیں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "đã yêu thích, chưa được đáp lại" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已加星标,尚未互相" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "已加星標,尚未互相" + } + } + } + }, + "mesh_peers.tooltip.favorite_mutual" : { + "comment" : "Tooltip for the filled star on a reciprocated favorite", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "مفضلة متبادلة — الرسائل دون اتصال عبر nostr تعمل في الاتجاهين" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "পারস্পরিক প্রিয় — nostr-এর মাধ্যমে অফলাইন বার্তা দুই দিকেই কাজ করে" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "gegenseitiger favorit — offline-nachrichten über nostr funktionieren in beide richtungen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "mutual favorite — offline messages via nostr work both ways" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "favorito mutuo — los mensajes sin conexión por nostr funcionan en ambos sentidos" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "موردعلاقه دوطرفه — پیام‌های آفلاین از طریق nostr در هر دو جهت کار می‌کند" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "paborito ng isa't isa — gumagana ang offline na mensahe sa nostr sa magkabilang direksyon" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "favori mutuel — les messages hors ligne via nostr fonctionnent dans les deux sens" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "מועדף הדדי — הודעות לא מקוונות דרך nostr עובדות בשני הכיוונים" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "आपसी पसंदीदा — nostr के ज़रिए ऑफ़लाइन संदेश दोनों तरफ़ काम करते हैं" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "saling favorit — pesan offline lewat nostr berfungsi dua arah" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "preferito reciproco — i messaggi offline via nostr funzionano in entrambe le direzioni" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "相互お気に入り — nostr経由のオフラインメッセージが双方向で使えます" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "상호 즐겨찾기 — nostr를 통한 오프라인 메시지가 양방향으로 작동해요" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "kegemaran dua hala — mesej luar talian melalui nostr berfungsi dua arah" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "आपसी मनपर्ने — nostr मार्फत अफलाइन सन्देश दुवैतर्फ काम गर्छ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "wederzijdse favoriet — offline berichten via nostr werken in beide richtingen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "wzajemnie w ulubionych — wiadomości offline przez nostr działają w obie strony" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "favorito mútuo — as mensagens offline via nostr funcionam nos dois sentidos" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "favorito mútuo — mensagens offline via nostr funcionam nos dois sentidos" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "взаимно в избранном — офлайн-сообщения через nostr работают в обе стороны" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "ömsesidig favorit — offlinemeddelanden via nostr fungerar åt båda hållen" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "பரஸ்பரம் பிடித்தவர் — nostr வழியான ஆஃப்லைன் செய்திகள் இரு திசைகளிலும் வேலை செய்யும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "รายการโปรดร่วมกัน — ข้อความออฟไลน์ผ่าน nostr ใช้ได้ทั้งสองทาง" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "karşılıklı favori — nostr üzerinden çevrimdışı mesajlar iki yönde de çalışır" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "взаємно в обраному — офлайн-повідомлення через nostr працюють в обидва боки" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "باہمی پسندیدہ — nostr کے ذریعے آف لائن پیغامات دونوں طرف کام کرتے ہیں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "yêu thích lẫn nhau — tin nhắn ngoại tuyến qua nostr hoạt động hai chiều" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "互相星标 — 通过 nostr 的离线消息双向可用" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "互相星標 — 透過 nostr 的離線訊息雙向可用" + } + } + } + }, + "mesh_peers.tooltip.favorite_pending" : { + "comment" : "Tooltip for the half star on an unreciprocated favorite", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "تمت الإضافة إلى المفضلة — تبدأ المراسلة دون اتصال عندما يضيفك إلى مفضلته أيضًا" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "প্রিয় করা হয়েছে — সে-ও আপনাকে প্রিয় করলে অফলাইন বার্তা চালু হবে" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "favorisiert — offline-nachrichten starten, sobald du zurück favorisiert wirst" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "favorited — offline messaging starts when they favorite you back" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "en favoritos — los mensajes sin conexión empiezan cuando te añada también" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "موردعلاقه شد — وقتی او هم شما را موردعلاقه کند، پیام‌رسانی آفلاین شروع می‌شود" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "naka-paborito — magsisimula ang offline na pagmemensahe kapag ginawa ka rin niyang paborito" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "en favori — la messagerie hors ligne démarre quand cette personne t'ajoute aussi" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "נוסף למועדפים — שליחת הודעות לא מקוונות תתחיל כשיוסיפו אותך בחזרה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "पसंदीदा बनाया — जब वे आपको वापस पसंदीदा बनाएंगे, तब ऑफ़लाइन संदेश शुरू होंगे" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "sudah difavoritkan — pesan offline mulai berfungsi saat dia memfavoritkanmu balik" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "nei preferiti — la messaggistica offline inizia quando ti aggiunge a sua volta" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "お気に入り済み — 相手もあなたをお気に入りにするとオフラインメッセージが使えるようになります" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "즐겨찾기함 — 상대도 나를 즐겨찾기에 추가하면 오프라인 메시지가 시작돼요" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "sudah dijadikan kegemaran — pemesejan luar talian bermula apabila dia turut menjadikan anda kegemaran" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "मनपर्ने बनाइयो — उनले पनि तपाईंलाई मनपर्ने बनाएपछि अफलाइन सन्देश सुरु हुन्छ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "als favoriet toegevoegd — offline berichten starten zodra diegene jou ook toevoegt" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "dodano do ulubionych — wiadomości offline ruszą, gdy ta osoba też cię doda" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "nos favoritos — as mensagens offline começam quando essa pessoa também te marcar" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "favoritado — as mensagens offline começam quando essa pessoa favoritar você de volta" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "в избранном — офлайн-переписка начнётся, когда тебя добавят в ответ" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "favoritmarkerad — offlinemeddelanden börjar fungera när personen favoritmarkerar dig tillbaka" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "பிடித்தவராகச் சேர்க்கப்பட்டார் — அவரும் உங்களைச் சேர்த்ததும் ஆஃப்லைன் செய்தி தொடங்கும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "เพิ่มเป็นรายการโปรดแล้ว — การส่งข้อความออฟไลน์จะเริ่มเมื่อเขาเพิ่มคุณกลับ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "favorilere eklendi — o da seni eklediğinde çevrimdışı mesajlaşma başlar" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "в обраному — офлайн-листування почнеться, коли тебе додадуть у відповідь" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "پسندیدہ بنایا گیا — جب وہ بھی آپ کو پسندیدہ بنائیں گے تو آف لائن پیغام رسانی شروع ہو جائے گی" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "đã thêm yêu thích — nhắn tin ngoại tuyến sẽ bắt đầu khi người đó thêm lại bạn" + } + }, + "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", @@ -22552,181 +23854,181 @@ "ar" : { "stringUnit" : { "state" : "translated", - "value" : "تلقَّ تنبيهات عندما ينضم أحباؤك" + "value" : "يمكن للمفضلات المتبادلة مراسلة بعضها عبر الإنترنت (nostr) خارج نطاق شبكة mesh — وسيصلك إشعار عندما يعودون إلى الاتصال" } }, "bn" : { "stringUnit" : { "state" : "translated", - "value" : "আপনার প্রিয় মানুষ যোগ দিলে বিজ্ঞপ্তি পান" + "value" : "পারস্পরিক প্রিয়রা mesh-এর সীমার বাইরে থাকলে ইন্টারনেটের (nostr) মাধ্যমে একে অপরকে বার্তা পাঠাতে পারে — আর তারা অনলাইনে এলে আপনি বিজ্ঞপ্তি পাবেন" } }, "de" : { "stringUnit" : { "state" : "translated", - "value" : "erhalte hinweise, wenn deine lieblingsmenschen online kommen" + "value" : "gegenseitige favoriten können einander über das internet (nostr) schreiben, wenn sie außer mesh-reichweite sind — und du wirst benachrichtigt, sobald sie online kommen" } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "get notified when your favorite people join" + "value" : "mutual favorites can message each other over the internet (nostr) when out of mesh range — and you're notified when they come online" } }, "es" : { "stringUnit" : { "state" : "translated", - "value" : "recibe avisos cuando tus personas favoritas se conecten" + "value" : "los favoritos mutuos pueden enviarse mensajes por internet (nostr) fuera del alcance de la red mesh — y recibes un aviso cuando se conectan" } }, "fa" : { "stringUnit" : { "state" : "translated", - "value" : "وقتی افراد موردعلاقه‌ات می‌پیوندند باخبر شو" + "value" : "موردعلاقه‌های دوطرفه می‌توانند خارج از برد mesh از طریق اینترنت (nostr) به هم پیام بدهند — و وقتی آنلاین شوند به شما اطلاع داده می‌شود" } }, "fil" : { "stringUnit" : { "state" : "translated", - "value" : "tumanggap ng abiso kapag sumali ang paborito mong mga tao" + "value" : "ang mga paborito ng isa't isa ay maaaring magpadalahan ng mensahe sa internet (nostr) kapag wala sa saklaw ng mesh — at aabisuhan ka kapag nag-online sila" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "reçois une alerte quand tes personnes favorites arrivent" + "value" : "les favoris mutuels peuvent s'écrire par internet (nostr) hors de portée du mesh — et tu reçois une notification quand ils reviennent en ligne" } }, "he" : { "stringUnit" : { "state" : "translated", - "value" : "קבל התראות כשהאנשים המועדפים שלך מצטרפים" + "value" : "מועדפים הדדיים יכולים לשלוח הודעות דרך האינטרנט (nostr) מחוץ לטווח ה‑mesh — ומתקבלת התראה כשהם חוזרים להיות מקוונים" } }, "hi" : { "stringUnit" : { "state" : "translated", - "value" : "जब आपके पसंदीदा लोग जुड़ें तो सूचना पाएं" + "value" : "आपसी पसंदीदा mesh की रेंज से बाहर होने पर इंटरनेट (nostr) के ज़रिए एक-दूसरे को संदेश भेज सकते हैं — और जब वे ऑनलाइन आते हैं तो आपको सूचना मिलती है" } }, "id" : { "stringUnit" : { "state" : "translated", - "value" : "dapatkan notifikasi saat orang favoritmu bergabung" + "value" : "yang saling favorit bisa berkirim pesan lewat internet (nostr) saat di luar jangkauan mesh — dan kamu diberi tahu saat mereka online" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "ricevi avvisi quando entrano le tue persone preferite" + "value" : "i preferiti reciproci possono scriversi via internet (nostr) fuori dalla portata della mesh — e ricevi una notifica quando tornano online" } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "お気に入りの人が参加したら通知を受け取れます" + "value" : "相互お気に入り同士は、meshの範囲外でもインターネット(nostr)経由でメッセージを送り合えます — 相手がオンラインになると通知されます" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "즐겨찾는 사용자가 참여하면 알림을 받습니다" + "value" : "상호 즐겨찾기끼리는 mesh 범위 밖에서도 인터넷(nostr)으로 메시지를 주고받을 수 있어요 — 상대가 온라인이 되면 알림을 받아요" } }, "ms" : { "stringUnit" : { "state" : "translated", - "value" : "dapatkan notifikasi saat orang favoritmu bergabung" + "value" : "kegemaran dua hala boleh berutus mesej melalui internet (nostr) apabila di luar liputan mesh — dan anda akan diberitahu apabila mereka kembali dalam talian" } }, "ne" : { "stringUnit" : { "state" : "translated", - "value" : "तिम्रा मनपर्ने मानिस जोडिएपछि सूचनाहरू पाऊ" + "value" : "आपसी मनपर्नेहरूले mesh को दायराबाहिर हुँदा इन्टरनेट (nostr) मार्फत एकअर्कालाई सन्देश पठाउन सक्छन् — र उनीहरू अनलाइन आउँदा तपाईंलाई सूचना आउँछ" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "ontvang meldingen wanneer je favoriete mensen meedoen" + "value" : "wederzijdse favorieten kunnen elkaar via internet (nostr) berichten sturen buiten bereik van het mesh — en je krijgt een melding wanneer ze online komen" } }, "pl" : { "stringUnit" : { "state" : "translated", - "value" : "otrzymuj powiadomienia, gdy dołączają twoje ulubione osoby" + "value" : "osoby wzajemnie w ulubionych mogą pisać do siebie przez internet (nostr) poza zasięgiem sieci mesh — a gdy pojawią się online, dostaniesz powiadomienie" } }, "pt" : { "stringUnit" : { "state" : "translated", - "value" : "recebe notificações quando as tuas pessoas favoritas entram" + "value" : "os favoritos mútuos podem trocar mensagens pela internet (nostr) fora do alcance da mesh — e recebes uma notificação quando ficam online" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "receba avisos quando suas pessoas favoritas entrarem" + "value" : "favoritos mútuos podem trocar mensagens pela internet (nostr) fora do alcance da mesh — e você recebe uma notificação quando eles ficam online" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "получай уведомления, когда подключаются любимые люди" + "value" : "взаимно избранные могут переписываться через интернет (nostr) вне зоны действия mesh-сети — а когда они появляются в сети, приходит уведомление" } }, "sv" : { "stringUnit" : { "state" : "translated", - "value" : "få aviseringar när dina favoriter ansluter" + "value" : "ömsesidiga favoriter kan skicka meddelanden till varandra via internet (nostr) utom räckhåll för mesh-nätet — och du får en avisering när de kommer online" } }, "ta" : { "stringUnit" : { "state" : "translated", - "value" : "உங்களுக்குப் பிரியமானவர்கள் சேர்ந்தவுடன் அறிவிப்பு பெறுங்கள்" + "value" : "பரஸ்பரம் பிடித்தவர்கள் mesh எல்லைக்கு வெளியே இருக்கும்போது இணையம் (nostr) வழியாக ஒருவருக்கொருவர் செய்தி அனுப்பலாம் — அவர்கள் ஆன்லைனுக்கு வரும்போது உங்களுக்கு அறிவிப்பு வரும்" } }, "th" : { "stringUnit" : { "state" : "translated", - "value" : "รับการแจ้งเตือนเมื่อคนโปรดของคุณเข้าร่วม" + "value" : "รายการโปรดร่วมกันส่งข้อความหากันผ่านอินเทอร์เน็ต (nostr) ได้เมื่ออยู่นอกระยะ mesh — และคุณจะได้รับแจ้งเตือนเมื่อพวกเขาออนไลน์" } }, "tr" : { "stringUnit" : { "state" : "translated", - "value" : "favori kişileriniz katıldığında bildirim alın" + "value" : "karşılıklı favoriler mesh menzili dışındayken internet (nostr) üzerinden birbirlerine mesaj gönderebilir — ve çevrimiçi olduklarında bildirim alırsın" } }, "uk" : { "stringUnit" : { "state" : "translated", - "value" : "отримуй сповіщення, коли підключаються улюблені люди" + "value" : "взаємно обрані можуть листуватися через інтернет (nostr) поза зоною дії mesh-мережі — а коли вони з'являються онлайн, надходить сповіщення" } }, "ur" : { "stringUnit" : { "state" : "translated", - "value" : "جب آپ کے پسندیدہ لوگ شامل ہوں تو اطلاع پائیں" + "value" : "باہمی پسندیدہ mesh کی حد سے باہر انٹرنیٹ (nostr) کے ذریعے ایک دوسرے کو پیغام بھیج سکتے ہیں — اور جب وہ آن لائن آئیں تو آپ کو اطلاع ملتی ہے" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "nhận thông báo khi những người yêu thích của bạn tham gia" + "value" : "những người yêu thích lẫn nhau có thể nhắn tin cho nhau qua internet (nostr) khi ngoài tầm mesh — và bạn sẽ được thông báo khi họ trực tuyến" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "你喜欢的人加入时立刻提醒" + "value" : "互相星标的双方在超出 mesh 范围时可通过互联网(nostr)互发消息 — 对方上线时你会收到通知" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "你喜歡的人加入時立刻提醒" + "value" : "互相星標的雙方在超出 mesh 範圍時可透過網際網路(nostr)互傳訊息 — 對方上線時你會收到通知" } } } @@ -85958,181 +87260,181 @@ "ar" : { "stringUnit" : { "state" : "translated", - "value" : "تم حظر %@ في محادثات geohash" + "value" : "تم حظر %@ في قناة geohash هذه — في قنوات geohash الأخرى يظهر كشخص جديد، فاحظره هناك أيضًا إذا لزم الأمر" } }, "bn" : { "stringUnit" : { "state" : "translated", - "value" : "জিওহ্যাশ চ্যাটে %@ ব্লক করা হয়েছে" + "value" : "এই geohash চ্যানেলে %@ কে ব্লক করা হয়েছে — অন্য geohash চ্যানেলে সে নতুন কেউ হিসেবে দেখা দেবে, তাই প্রয়োজনে সেখানেও ব্লক করুন" } }, "de" : { "stringUnit" : { "state" : "translated", - "value" : "%@ wurde in geohash-chats blockiert" + "value" : "%@ in diesem geohash-kanal blockiert — in anderen geohash-kanälen erscheint diese person als jemand neues, blockiere sie dort bei bedarf ebenfalls" } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "blocked %@ in geohash chats" + "value" : "blocked %@ in this geohash channel — in other geohash channels they appear as someone new, so block them there too if needed" } }, "es" : { "stringUnit" : { "state" : "translated", - "value" : "se bloqueó a %@ en los chats geohash" + "value" : "has bloqueado a %@ en este canal geohash — en otros canales geohash aparece como alguien nuevo, así que bloquéalo también allí si hace falta" } }, "fa" : { "stringUnit" : { "state" : "translated", - "value" : "%@ در گفتگوهای ژئوهش مسدود شد" + "value" : "%@ در این کانال geohash مسدود شد — در کانال‌های geohash دیگر به‌عنوان فردی جدید ظاهر می‌شود، پس در صورت نیاز آنجا هم او را مسدود کنید" } }, "fil" : { "stringUnit" : { "state" : "translated", - "value" : "na-block si %@ sa geohash chats" + "value" : "na-block si %@ sa geohash channel na ito — sa ibang geohash channel, lilitaw siya bilang bagong tao, kaya i-block din siya roon kung kailangan" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "%@ a été bloqué dans les chats geohash" + "value" : "%@ a été bloqué dans ce canal geohash — dans les autres canaux geohash, cette personne apparaît comme quelqu'un de nouveau, alors bloque-la aussi là-bas si besoin" } }, "he" : { "stringUnit" : { "state" : "translated", - "value" : "%@ נחסם בצ'אטי geohash" + "value" : "%@ נחסם בערוץ ה‑geohash הזה — בערוצי geohash אחרים הוא יופיע כמישהו חדש, אז אם צריך, כדאי לחסום אותו גם שם" } }, "hi" : { "stringUnit" : { "state" : "translated", - "value" : "जियोहैश चैट में %@ ब्लॉक किया गया" + "value" : "इस geohash चैनल में %@ को ब्लॉक किया गया — दूसरे geohash चैनलों में वे किसी नए व्यक्ति की तरह दिखेंगे, इसलिए ज़रूरत हो तो वहाँ भी ब्लॉक करें" } }, "id" : { "stringUnit" : { "state" : "translated", - "value" : "%@ diblokir di chat geohash" + "value" : "%@ diblokir di channel geohash ini — di channel geohash lain dia muncul sebagai orang baru, jadi blokir juga di sana kalau perlu" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "%@ è stato bloccato nei chat geohash" + "value" : "%@ bloccato in questo canale geohash — negli altri canali geohash appare come una persona nuova, quindi bloccalo anche lì se serve" } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "%@をgeohashチャットでブロックしました" + "value" : "このgeohashチャンネルで%@をブロックしました — 他のgeohashチャンネルでは別人として表示されるため、必要ならそちらでもブロックしてください" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "geohash 채팅에서 %@을(를) 차단했습니다" + "value" : "이 geohash 채널에서 %@을(를) 차단했어요 — 다른 geohash 채널에서는 새로운 사람으로 표시되니 필요하면 거기서도 차단하세요" } }, "ms" : { "stringUnit" : { "state" : "translated", - "value" : "%@ diblokir di chat geohash" + "value" : "%@ disekat dalam saluran geohash ini — dalam saluran geohash lain dia muncul sebagai orang baharu, jadi sekat juga di sana jika perlu" } }, "ne" : { "stringUnit" : { "state" : "translated", - "value" : "%@ लाई geohash च्याटमा ब्लक गरियो" + "value" : "यस geohash च्यानलमा %@ लाई ब्लक गरियो — अन्य geohash च्यानलहरूमा उनी नयाँ व्यक्तिजस्तै देखिन्छन्, त्यसैले आवश्यक परे त्यहाँ पनि ब्लक गर्नुहोस्" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "%@ geblokkeerd in geohash-chats" + "value" : "%@ geblokkeerd in dit geohash-kanaal — in andere geohash-kanalen verschijnt diegene als iemand nieuws, dus blokkeer daar ook als dat nodig is" } }, "pl" : { "stringUnit" : { "state" : "translated", - "value" : "zablokowano %@ w czatach geohash" + "value" : "zablokowano %@ w tym kanale geohash — w innych kanałach geohash ta osoba pojawia się jako ktoś nowy, więc w razie potrzeby zablokuj ją też tam" } }, "pt" : { "stringUnit" : { "state" : "translated", - "value" : "%@ bloqueado nos chats geohash" + "value" : "%@ bloqueado neste canal geohash — noutros canais geohash aparece como alguém novo, por isso bloqueia-o também aí se for preciso" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "%@ foi bloqueado nos chats geohash" + "value" : "%@ bloqueado neste canal geohash — em outros canais geohash essa pessoa aparece como alguém novo, então bloqueie lá também se precisar" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "%@ заблокирован в geohash-чатах" + "value" : "%@ заблокирован в этом geohash-канале — в других geohash-каналах этот человек выглядит как кто-то новый, так что при необходимости заблокируй его и там" } }, "sv" : { "stringUnit" : { "state" : "translated", - "value" : "blockerade %@ i geohash-chattar" + "value" : "%@ blockerad i den här geohash-kanalen — i andra geohash-kanaler dyker personen upp som någon ny, så blockera hen där också om det behövs" } }, "ta" : { "stringUnit" : { "state" : "translated", - "value" : "geohash உரையாடல்களில் %@ தடுக்கப்பட்டார்" + "value" : "இந்த geohash சேனலில் %@ தடுக்கப்பட்டார் — மற்ற geohash சேனல்களில் அவர் புதிய நபராகத் தோன்றுவார், தேவைப்பட்டால் அங்கும் தடுக்கவும்" } }, "th" : { "stringUnit" : { "state" : "translated", - "value" : "บล็อก %@ ในแชท geohash" + "value" : "บล็อก %@ ในช่อง geohash นี้แล้ว — ในช่อง geohash อื่นเขาจะปรากฏเป็นคนใหม่ ดังนั้นถ้าจำเป็นก็บล็อกที่นั่นด้วย" } }, "tr" : { "stringUnit" : { "state" : "translated", - "value" : "geohash sohbetlerinde %@ engellendi" + "value" : "%@ bu geohash kanalında engellendi — diğer geohash kanallarında yeni biri olarak görünür, gerekirse orada da engelle" } }, "uk" : { "stringUnit" : { "state" : "translated", - "value" : "%@ заблоковано в geohash-чатах" + "value" : "%@ заблоковано в цьому geohash-каналі — в інших geohash-каналах ця людина виглядає як хтось новий, тож за потреби заблокуй її й там" } }, "ur" : { "stringUnit" : { "state" : "translated", - "value" : "geohash چیٹس میں %@ کو بلاک کیا" + "value" : "اس geohash چینل میں %@ کو بلاک کر دیا گیا — دوسرے geohash چینلز میں وہ کسی نئے فرد کے طور پر نظر آئیں گے، اس لیے ضرورت ہو تو وہاں بھی بلاک کریں" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "đã chặn %@ trong chat geohash" + "value" : "đã chặn %@ trong kênh geohash này — ở các kênh geohash khác, người đó xuất hiện như một người mới, nên hãy chặn ở đó nữa nếu cần" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "已在 geohash 聊天中屏蔽 %@" + "value" : "已在此 geohash 频道屏蔽 %@ — 在其他 geohash 频道中对方会显示为新面孔,如有需要请在那里也进行屏蔽" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "已在 geohash 聊天中屏蔽 %@" + "value" : "已在此 geohash 頻道封鎖 %@ — 在其他 geohash 頻道中對方會顯示為新的人,如有需要請在那裡也一併封鎖" } } } diff --git a/bitchat/Services/CommandProcessor.swift b/bitchat/Services/CommandProcessor.swift index c37aa3dc..e2ccc6a7 100644 --- a/bitchat/Services/CommandProcessor.swift +++ b/bitchat/Services/CommandProcessor.swift @@ -368,7 +368,7 @@ final class CommandProcessor { return .success(message: String(format: String(localized: "command.block.already", defaultValue: "%@ is already blocked", comment: "Reply when /block targets an already-blocked nickname"), locale: .current, nickname)) } identityManager.setNostrBlocked(pub, isBlocked: true) - return .success(message: String(format: String(localized: "command.block.done_geo", defaultValue: "blocked %@ in geohash chats", comment: "Confirmation after blocking a geohash participant"), locale: .current, nickname)) + return .success(message: String(format: String(localized: "command.block.done_geo", defaultValue: "blocked %@ in this geohash channel — in other geohash channels they appear as someone new, so block them there too if needed", comment: "Confirmation after blocking a geohash participant; says the block is per-channel because identities differ per geohash"), locale: .current, nickname)) } return .error(message: String(format: String(localized: "command.block.failed", defaultValue: "cannot block %@: not found or unable to verify identity", comment: "Error when /block can't resolve or verify the target"), locale: .current, nickname)) diff --git a/bitchat/Services/FavoriteConsent.swift b/bitchat/Services/FavoriteConsent.swift new file mode 100644 index 00000000..19e1e06d --- /dev/null +++ b/bitchat/Services/FavoriteConsent.swift @@ -0,0 +1,38 @@ +// +// FavoriteConsent.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation + +/// One-time acknowledgement before the first favorite. +/// +/// The star reads like a private bookmark but behaves like a friend request +/// plus an identity handshake: it notifies the other person immediately +/// ("alice favorited you") and transmits your durable Nostr public key so +/// mutual favorites can message over the internet. Disclosing a durable +/// identifier must not happen from a tap that looks local — the first star +/// asks once, then never again. +enum FavoriteConsent { + private static let acknowledgedKey = "favorites.consentAcknowledged" + + static var isAcknowledged: Bool { + isAcknowledged(in: .standard) + } + + static func isAcknowledged(in defaults: UserDefaults) -> Bool { + defaults.bool(forKey: acknowledgedKey) + } + + static func acknowledge(in defaults: UserDefaults = .standard) { + defaults.set(true, forKey: acknowledgedKey) + } + + /// Panic-wipe hook: a wiped device asks again. + static func reset(in defaults: UserDefaults = .standard) { + defaults.removeObject(forKey: acknowledgedKey) + } +} diff --git a/bitchat/ViewModels/ChatPublicConversationCoordinator.swift b/bitchat/ViewModels/ChatPublicConversationCoordinator.swift index d12e2c1d..c4bccdcb 100644 --- a/bitchat/ViewModels/ChatPublicConversationCoordinator.swift +++ b/bitchat/ViewModels/ChatPublicConversationCoordinator.swift @@ -227,7 +227,8 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate { String( format: String( localized: "system.geohash.blocked", - comment: "System message shown when a user is blocked in geohash chats" + defaultValue: "blocked %@ in this geohash channel — in other geohash channels they appear as someone new, so block them there too if needed", + comment: "System message after blocking a geohash participant; says the block is per-channel because identities differ per geohash" ), locale: .current, displayName diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 55a4a4bd..5c811635 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -1647,6 +1647,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage MeshEchoSettings.reset() NotificationPrivacySettings.reset() ReadReceiptSettings.reset() + FavoriteConsent.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/ContentSheetViews.swift b/bitchat/Views/ContentSheetViews.swift index 88be040f..321788c7 100644 --- a/bitchat/Views/ContentSheetViews.swift +++ b/bitchat/Views/ContentSheetViews.swift @@ -463,6 +463,9 @@ private extension ContentPeopleListView { private struct ContentPrivateChatSheetView: View { @EnvironmentObject private var privateConversationModel: PrivateConversationModel + /// First-ever favorite waiting on the one-time consent dialog. + @State private var showFavoriteConsent = false + @Binding var showSidebar: Bool @Binding var messageText: String @Binding var selectedMessageSender: String? @@ -517,9 +520,19 @@ private struct ContentPrivateChatSheetView: View { if headerState.supportsFavoriteToggle { Button(action: { - privateConversationModel.toggleFavoriteForSelectedConversation() + // The first favorite ever asks once: the star + // notifies the peer and shares the nostr key. + if !headerState.isFavorite, !FavoriteConsent.isAcknowledged { + showFavoriteConsent = true + } else { + privateConversationModel.toggleFavoriteForSelectedConversation() + } }) { - Image(systemName: headerState.isFavorite ? "star.fill" : "star") + // Half star until reciprocated: one-sided + // favorites don't enable offline delivery. + Image(systemName: headerState.isFavorite + ? (headerState.isMutualFavorite ? "star.fill" : "star.leadinghalf.filled") + : "star") .font(.bitchatSystem(size: 14)) .foregroundColor(headerState.isFavorite ? Color.yellow : palette.primary) // Same visual box + 44pt hit target as SheetCloseButton. @@ -532,6 +545,25 @@ private struct ContentPrivateChatSheetView: View { ? String(localized: "content.accessibility.remove_favorite", comment: "Accessibility label to remove a favorite") : String(localized: "content.accessibility.add_favorite", comment: "Accessibility label to add a favorite") ) + .confirmationDialog( + Text(String(localized: "favorites.consent.title", defaultValue: "add favorite?", comment: "Title of the one-time confirmation before the first favorite")), + isPresented: $showFavoriteConsent, + titleVisibility: .visible + ) { + Button(String(localized: "favorites.consent.confirm", defaultValue: "add favorite", comment: "Confirm button of the one-time favorite consent dialog")) { + FavoriteConsent.acknowledge() + privateConversationModel.toggleFavoriteForSelectedConversation() + } + Button("common.cancel", role: .cancel) {} + } message: { + Text( + String( + format: String(localized: "favorites.consent.message", defaultValue: "this tells %@ right away and shares your nostr key with them. if they favorite you back, you can message each other over the internet when out of mesh range.", comment: "Body of the one-time favorite consent dialog; placeholder is the person's name"), + locale: .current, + headerState.displayName + ) + ) + } } } .frame(maxWidth: .infinity) diff --git a/bitchat/Views/MeshPeerList.swift b/bitchat/Views/MeshPeerList.swift index 1d94af5f..16b67d42 100644 --- a/bitchat/Views/MeshPeerList.swift +++ b/bitchat/Views/MeshPeerList.swift @@ -13,6 +13,8 @@ struct MeshPeerList: View { @Environment(\.colorScheme) var colorScheme @State private var orderedIDs: [String] = [] + /// First-ever favorite waiting on the one-time consent dialog. + @State private var pendingFavorite: MeshPeerRow? private enum Strings { static let noneNearby: LocalizedStringKey = "geohash_people.none_nearby" @@ -26,6 +28,19 @@ struct MeshPeerList: View { static let unread = String(localized: "mesh_peers.state.unread", comment: "State label for a peer with unread private messages") static let blocked = String(localized: "mesh_peers.state.blocked", comment: "State label for a blocked peer") static let vouched = String(localized: "mesh_peers.state.vouched", comment: "State label for a peer vouched for by someone the user verified") + static let favoriteMutual = String(localized: "mesh_peers.state.favorite_mutual", defaultValue: "mutual favorite", comment: "State label for a reciprocated favorite") + static let favoritePending = String(localized: "mesh_peers.state.favorite_pending", defaultValue: "favorited, not mutual yet", comment: "State label for a favorite the peer has not reciprocated") + static let favoriteMutualTooltip = String(localized: "mesh_peers.tooltip.favorite_mutual", defaultValue: "mutual favorite — offline messages via nostr work both ways", comment: "Tooltip for the filled star on a reciprocated favorite") + static let favoritePendingTooltip = String(localized: "mesh_peers.tooltip.favorite_pending", defaultValue: "favorited — offline messaging starts when they favorite you back", comment: "Tooltip for the half star on an unreciprocated favorite") + static let consentTitle = String(localized: "favorites.consent.title", defaultValue: "add favorite?", comment: "Title of the one-time confirmation before the first favorite") + static let consentConfirm = String(localized: "favorites.consent.confirm", defaultValue: "add favorite", comment: "Confirm button of the one-time favorite consent dialog") + static func consentMessage(_ name: String) -> String { + String( + format: String(localized: "favorites.consent.message", defaultValue: "this tells %@ right away and shares your nostr key with them. if they favorite you back, you can message each other over the internet when out of mesh range.", comment: "Body of the one-time favorite consent dialog; placeholder is the person's name"), + locale: .current, + name + ) + } static let vouchedTooltip = String(localized: "mesh_peers.tooltip.vouched", comment: "Tooltip for the vouched (unfilled seal) badge next to a peer") static let addFavorite = String(localized: "content.accessibility.add_favorite", comment: "Accessibility label to add a favorite") static let removeFavorite = String(localized: "content.accessibility.remove_favorite", comment: "Accessibility label to remove a favorite") @@ -43,6 +58,7 @@ struct MeshPeerList: View { peerListModel.meshRows.first(where: { $0.id == id }) } + Group { if peerListModel.meshRows.isEmpty { // Match the section's row rhythm (same size, indent, and vertical // padding as a peer row) so the empty state reads as the list's @@ -164,8 +180,22 @@ struct MeshPeerList: View { } if !isMe { - Button(action: { onToggleFavorite(peer.peerID) }) { - Image(systemName: peer.isFavorite ? "star.fill" : "star") + Button(action: { + // The first favorite ever asks once: the star + // notifies the peer and shares the nostr key. + if !peer.isFavorite, !FavoriteConsent.isAcknowledged { + pendingFavorite = peer + } else { + onToggleFavorite(peer.peerID) + } + }) { + // Mutuality is the load-bearing state (one-sided + // favorites don't enable offline delivery), so it + // shows at the point of decision: half star until + // reciprocated. + Image(systemName: peer.isFavorite + ? (peer.isMutualFavorite ? "star.fill" : "star.leadinghalf.filled") + : "star") .font(.bitchatSystem(size: 12)) .foregroundColor(peer.isFavorite ? .yellow : palette.secondary) // Widen the tap target beyond the bare glyph; @@ -175,6 +205,7 @@ struct MeshPeerList: View { .contentShape(Rectangle()) } .buttonStyle(.plain) + .help(peer.isMutualFavorite ? Strings.favoriteMutualTooltip : Strings.favoritePendingTooltip) } } .padding(.horizontal) @@ -240,6 +271,24 @@ struct MeshPeerList: View { if newOrder != orderedIDs { orderedIDs = newOrder } } } + } + .confirmationDialog( + Text(verbatim: Strings.consentTitle), + isPresented: Binding( + get: { pendingFavorite != nil }, + set: { if !$0 { pendingFavorite = nil } } + ), + titleVisibility: .visible, + presenting: pendingFavorite + ) { peer in + Button(Strings.consentConfirm) { + FavoriteConsent.acknowledge() + onToggleFavorite(peer.peerID) + } + Button("common.cancel", role: .cancel) {} + } message: { peer in + Text(verbatim: Strings.consentMessage(peer.displayName)) + } } /// One spoken sentence per row: name, how they're reachable, and any @@ -258,7 +307,9 @@ struct MeshPeerList: View { } } if peer.showsVouchedBadge { parts.append(Strings.vouched) } - if peer.isFavorite { parts.append(Strings.favorite) } + if peer.isFavorite { + parts.append(peer.isMutualFavorite ? Strings.favoriteMutual : Strings.favoritePending) + } if peer.hasUnread { parts.append(Strings.unread) } if peer.isBlocked { parts.append(Strings.blocked) } return parts.joined(separator: ", ") diff --git a/bitchatTests/ChatViewModelTests.swift b/bitchatTests/ChatViewModelTests.swift index 1bd66497..bf6470de 100644 --- a/bitchatTests/ChatViewModelTests.swift +++ b/bitchatTests/ChatViewModelTests.swift @@ -468,6 +468,21 @@ struct ChatViewModelServiceLifecycleTests { #expect(viewModel.sentReadReceipts.contains("read-2") || viewModel.privateChatManager.sentReadReceipts.contains("read-2")) } + @Test @MainActor + func favoriteConsentDefaultsToUnacknowledgedAndResets() { + let suite = "FavoriteConsentTests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defer { defaults.removePersistentDomain(forName: suite) } + + // The first star must ask: it notifies the peer and shares the + // nostr key, and a wiped device must ask again. + #expect(!FavoriteConsent.isAcknowledged(in: defaults)) + FavoriteConsent.acknowledge(in: defaults) + #expect(FavoriteConsent.isAcknowledged(in: defaults)) + FavoriteConsent.reset(in: defaults) + #expect(!FavoriteConsent.isAcknowledged(in: defaults)) + } + @Test @MainActor func readReceiptSettingDefaultsToOnAndResets() { let suite = "ReadReceiptSettingsTests.\(UUID().uuidString)" From 0ade22c7ed45a225532e8cfa0ff31ada6eaa0e38 Mon Sep 17 00:00:00 2001 From: jack Date: Tue, 11 Aug 2026 09:47:48 +0200 Subject: [PATCH 06/13] Add an optional app lock (Face ID / Touch ID / passcode) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The keychain is AfterFirstUnlock and private chats live in memory: an unlocked, seized, or borrowed phone handed over everything, and the panic wipe only helps while the phone is still in your hand. The threat model's primary event had no defense. - AppLockModel gates the whole UI: locked from the first frame when enabled, re-locks when iOS backgrounds the app (macOS locks at launch only — its windows resign focus constantly, and PrivacyScreen already covers snapshots). The lock screen auto-triggers the system prompt and keeps a manual retry button. - Off by default; the settings toggle refuses to arm without a device passcode. Deliberate fail-open: removing the passcode requires knowing it, so a missing passcode means the owner chose that — the lock disables itself rather than locking them out (stated in the settings copy). Reset by panic wipe. - NSFaceIDUsageDescription added. 5 strings x 30 locales. - Model driven by injected providers; tests pin locked-at-launch, unlock-only-on-success, re-lock, disabled-inert, and the setting's default/reset. Co-Authored-By: Claude Fable 5 --- bitchat/App/AppLockModel.swift | 103 +++ bitchat/BitchatApp.swift | 23 +- bitchat/Info.plist | 2 + bitchat/Localizable.xcstrings | 930 +++++++++++++++++++++++++ bitchat/ViewModels/ChatViewModel.swift | 1 + bitchat/Views/AppInfoView.swift | 22 + bitchat/Views/AppLockScreen.swift | 54 ++ bitchatTests/ChatViewModelTests.swift | 58 ++ 8 files changed, 1192 insertions(+), 1 deletion(-) create mode 100644 bitchat/App/AppLockModel.swift create mode 100644 bitchat/Views/AppLockScreen.swift diff --git a/bitchat/App/AppLockModel.swift b/bitchat/App/AppLockModel.swift new file mode 100644 index 00000000..7a57f0dd --- /dev/null +++ b/bitchat/App/AppLockModel.swift @@ -0,0 +1,103 @@ +// +// AppLockModel.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Combine +import Foundation +import LocalAuthentication + +/// Optional biometric/passcode gate on the whole app. +/// +/// The keychain is AfterFirstUnlock and private chats live in memory, so an +/// unlocked, seized, or borrowed phone hands over everything — the panic +/// wipe only helps while the phone is still in your hand. This is the +/// missing complement: with the lock enabled, returning from the background +/// requires Face ID / Touch ID / the device passcode. +enum AppLockSettings { + private static let enabledKey = "privacy.appLockEnabled" + + static var isEnabled: Bool { + isEnabled(in: .standard) + } + + static func isEnabled(in defaults: UserDefaults) -> Bool { + defaults.bool(forKey: enabledKey) + } + + static func setEnabled(_ enabled: Bool, in defaults: UserDefaults = .standard) { + defaults.set(enabled, forKey: enabledKey) + } + + /// Panic-wipe hook: a wiped device behaves like a fresh install. + static func reset(in defaults: UserDefaults = .standard) { + defaults.removeObject(forKey: enabledKey) + } +} + +@MainActor +final class AppLockModel: ObservableObject { + @Published private(set) var isLocked: Bool + @Published private(set) var isAuthenticating = false + + private let isEnabledProvider: () -> Bool + private let authenticate: (@escaping @MainActor (Bool) -> Void) -> Void + + /// Providers are injectable so tests drive the lock without LAContext + /// or the shared UserDefaults. + init( + isEnabledProvider: @escaping () -> Bool = { AppLockSettings.isEnabled }, + authenticate: ((@escaping @MainActor (Bool) -> Void) -> Void)? = nil + ) { + self.isEnabledProvider = isEnabledProvider + self.authenticate = authenticate ?? Self.systemAuthenticate + // Locked from the first frame when enabled: the gate must cover + // launch, not just returns from the background. + self.isLocked = isEnabledProvider() + } + + /// Whether the device can authenticate at all (passcode set). The + /// settings toggle refuses to arm without this. + static func canAuthenticate() -> Bool { + LAContext().canEvaluatePolicy(.deviceOwnerAuthentication, error: nil) + } + + func lockIfEnabled() { + guard isEnabledProvider() else { return } + isLocked = true + } + + func requestUnlock() { + guard isLocked, !isAuthenticating else { return } + isAuthenticating = true + authenticate { [weak self] success in + guard let self else { return } + self.isAuthenticating = false + if success { + self.isLocked = false + } + } + } + + private static func systemAuthenticate(_ completion: @escaping @MainActor (Bool) -> Void) { + let context = LAContext() + var error: NSError? + guard context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) else { + // Fail OPEN, deliberately: removing the device passcode already + // requires knowing it, so this state means the owner disabled + // it — locking them out of their own chats would punish exactly + // the wrong person. The settings copy states this rule. + Task { @MainActor in completion(true) } + return + } + context.evaluatePolicy( + .deviceOwnerAuthentication, + localizedReason: String(localized: "app_lock.reason", defaultValue: "unlock bitchat", comment: "Reason line shown in the system Face ID/Touch ID/passcode prompt") + ) { success, _ in + Task { @MainActor in completion(success) } + } + } +} diff --git a/bitchat/BitchatApp.swift b/bitchat/BitchatApp.swift index cf2e1bd1..0589b8e6 100644 --- a/bitchat/BitchatApp.swift +++ b/bitchat/BitchatApp.swift @@ -23,6 +23,8 @@ struct BitchatApp: App { @NSApplicationDelegateAdaptor(MacAppDelegate.self) var appDelegate #endif + @StateObject private var appLock = AppLockModel() + init() { _runtime = StateObject(wrappedValue: AppRuntime()) UNUserNotificationCenter.current().delegate = NotificationDelegate.shared @@ -30,7 +32,8 @@ struct BitchatApp: App { var body: some Scene { WindowGroup { - ContentView() + ZStack { + ContentView() .environment(\.appTheme, AppTheme(rawValue: appThemeRawValue) ?? .matrix) .environmentObject(runtime.publicChatModel) .environmentObject(runtime.privateInboxModel) @@ -61,6 +64,24 @@ struct BitchatApp: App { runtime.handleMacDidBecomeActiveNotification() } #endif + + // The gate renders above everything: with the lock engaged + // the timelines below must be neither readable nor tappable. + // iOS re-locks on background (below); macOS locks at launch + // only — its windows resign focus constantly, and the + // existing PrivacyScreen already covers window snapshots. + if appLock.isLocked { + AppLockScreen(model: appLock) + .environment(\.appTheme, AppTheme(rawValue: appThemeRawValue) ?? .matrix) + } + } + #if os(iOS) + .onChange(of: scenePhase) { newPhase in + if newPhase == .background { + appLock.lockIfEnabled() + } + } + #endif } #if os(macOS) .windowStyle(.hiddenTitleBar) diff --git a/bitchat/Info.plist b/bitchat/Info.plist index 57ddfa11..f935a5f5 100644 --- a/bitchat/Info.plist +++ b/bitchat/Info.plist @@ -39,6 +39,8 @@ bitchat uses Bluetooth to create a secure mesh network for chatting with nearby users. NSBluetoothPeripheralUsageDescription bitchat uses Bluetooth to discover and connect with other bitchat users nearby. + NSFaceIDUsageDescription + Face ID unlocks bitchat when the app lock is turned on. NSCameraUsageDescription bitchat uses the camera to scan QR codes to verify peers. NSLocationWhenInUseUsageDescription diff --git a/bitchat/Localizable.xcstrings b/bitchat/Localizable.xcstrings index 0a526b01..7b3c451c 100644 --- a/bitchat/Localizable.xcstrings +++ b/bitchat/Localizable.xcstrings @@ -17485,6 +17485,936 @@ } } }, + "app_info.settings.app_lock.subtitle" : { + "comment" : "Subtitle explaining the app-lock setting, its passcode requirement, and the fail-open rule", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "طلب Face ID أو Touch ID أو رمز دخول الجهاز لفتح bitchat بعد بقائه في الخلفية. يتطلب وجود رمز دخول على الجهاز — وإذا أُزيل رمز الدخول يومًا، يتوقف القفل من تلقاء نفسه بدلًا من منعك من الدخول." + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "ব্যাকগ্রাউন্ডে থাকার পর bitchat খুলতে Face ID, Touch ID বা ডিভাইসের পাসকোড লাগবে। ডিভাইসে পাসকোড থাকা জরুরি — পাসকোড কখনও সরিয়ে ফেলা হলে লকটি তোমাকে বাইরে আটকে না রেখে নিজে থেকেই বন্ধ হয়ে যাবে।" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "verlange Face ID, Touch ID oder den gerätecode, um bitchat zu öffnen, nachdem es im hintergrund war. braucht einen gerätecode — wird der code jemals entfernt, schaltet sich die sperre von selbst ab, statt dich auszusperren." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "require face id, touch id, or the device passcode to open bitchat after it's been in the background. needs a device passcode — if the passcode is ever removed, the lock turns itself off instead of locking you out." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "pide Face ID, Touch ID o el código del dispositivo para abrir bitchat después de haber estado en segundo plano. necesita un código en el dispositivo: si el código se elimina en algún momento, el bloqueo se desactiva solo en vez de dejarte fuera." + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "برای باز کردن bitchat بعد از رفتن به پس‌زمینه، Face ID، Touch ID یا رمز دستگاه لازم باشد. به رمز دستگاه نیاز دارد — اگر رمز روزی حذف شود، قفل به‌جای اینکه راهت را ببندد خودش خاموش می‌شود." + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "hingin ang Face ID, Touch ID, o passcode ng device para buksan ang bitchat pagkatapos nitong mapunta sa background. kailangan ng passcode sa device — kapag tinanggal ang passcode, awtomatikong mag-o-off ang lock sa halip na ma-lock ka sa labas." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "exige Face ID, Touch ID ou le code de l'appareil pour ouvrir bitchat après un passage en arrière-plan. nécessite un code sur l'appareil — si le code est un jour supprimé, le verrouillage se désactive tout seul au lieu de te laisser à la porte." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "לדרוש Face ID, Touch ID או את קוד הגישה של המכשיר כדי לפתוח את bitchat אחרי שהיה ברקע. נדרש קוד גישה במכשיר — אם הקוד יוסר אי פעם, הנעילה תכבה מעצמה במקום לנעול אותך בחוץ." + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "बैकग्राउंड में रहने के बाद bitchat खोलने के लिए Face ID, Touch ID या डिवाइस का पासकोड ज़रूरी होगा। डिवाइस पर पासकोड होना ज़रूरी है — अगर पासकोड कभी हटा दिया जाए, तो लॉक तुम्हें बाहर रोकने के बजाय खुद ही बंद हो जाएगा।" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "wajibkan Face ID, Touch ID, atau kode sandi perangkat untuk membuka bitchat setelah berada di latar belakang. perlu kode sandi di perangkat — kalau kode sandinya suatu saat dihapus, kuncinya mati sendiri alih-alih menguncimu di luar." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "richiedi Face ID, Touch ID o il codice del dispositivo per aprire bitchat dopo che è stata in background. serve un codice sul dispositivo: se il codice viene rimosso, il blocco si disattiva da solo invece di chiuderti fuori." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "バックグラウンドに移動したあと、bitchatを開くときにFace ID、Touch ID、またはデバイスのパスコードを求めます。デバイスのパスコードが必要です。パスコードが削除された場合は、締め出されないようにロックは自動的にオフになります。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "백그라운드에 있다가 bitchat을 열 때 Face ID, Touch ID 또는 기기 암호를 요구해요. 기기에 암호가 있어야 해요 — 암호가 삭제되면 밖에 갇히는 일이 없도록 잠금이 스스로 꺼져요." + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "perlukan Face ID, Touch ID atau kod laluan peranti untuk membuka bitchat selepas berada di latar belakang. memerlukan kod laluan pada peranti — kalau kod laluan itu dibuang, kunci akan mati sendiri dan bukannya mengunci kamu di luar." + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "ब्याकग्राउन्डमा गएपछि bitchat खोल्न Face ID, Touch ID वा डिभाइसको पासकोड चाहिन्छ। डिभाइसमा पासकोड हुनुपर्छ — पासकोड कहिल्यै हटाइयो भने, तिमीलाई बाहिरै रोक्नुको सट्टा लक आफैँ बन्द हुन्छ।" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "vereis Face ID, Touch ID of de toegangscode van het apparaat om bitchat te openen nadat het op de achtergrond stond. hiervoor is een toegangscode op het apparaat nodig — wordt de code ooit verwijderd, dan schakelt de vergrendeling zichzelf uit in plaats van je buiten te sluiten." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "wymagaj Face ID, Touch ID lub kodu urządzenia, aby otworzyć bitchat po tym, jak był w tle. potrzebny jest kod na urządzeniu — jeśli kod zostanie kiedyś usunięty, blokada wyłączy się sama, zamiast odciąć ci dostęp." + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "exige Face ID, Touch ID ou o código do dispositivo para abrir o bitchat depois de estar em segundo plano. precisa de um código no dispositivo — se o código for removido, o bloqueio desativa-se sozinho em vez de te deixar de fora." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "exija Face ID, Touch ID ou o código do aparelho para abrir o bitchat depois que ele ficar em segundo plano. precisa de um código no aparelho — se o código for removido, o bloqueio se desativa sozinho em vez de deixar você de fora." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "требовать Face ID, Touch ID или код-пароль устройства, чтобы открыть bitchat после того, как он был в фоне. нужен код-пароль на устройстве — если код-пароль когда-нибудь удалят, блокировка отключится сама, вместо того чтобы запереть тебя снаружи." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "kräv Face ID, Touch ID eller enhetens lösenkod för att öppna bitchat efter att den varit i bakgrunden. kräver en lösenkod på enheten — om lösenkoden någonsin tas bort stänger låset av sig självt i stället för att låsa ute dig." + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "பின்னணியில் இருந்த பிறகு bitchat-ஐத் திறக்க Face ID, Touch ID அல்லது சாதனத்தின் கடவுக்குறியீடு தேவைப்படும். சாதனத்தில் கடவுக்குறியீடு இருக்க வேண்டும் — கடவுக்குறியீடு எப்போதாவது நீக்கப்பட்டால், உன்னை வெளியே நிறுத்துவதற்குப் பதிலாக லாக் தானாகவே அணைந்துவிடும்." + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ต้องใช้ Face ID, Touch ID หรือรหัสของเครื่องเพื่อเปิด bitchat หลังจากแอปอยู่ในพื้นหลัง จำเป็นต้องตั้งรหัสบนเครื่อง — ถ้ารหัสถูกลบออกเมื่อไร ล็อกจะปิดตัวเองแทนที่จะล็อกคุณไว้ข้างนอก" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "arka planda kaldıktan sonra bitchat'i açmak için Face ID, Touch ID veya cihaz parolası iste. cihazda parola olması gerekir — parola bir gün kaldırılırsa kilit seni dışarıda bırakmak yerine kendini kapatır." + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "вимагати Face ID, Touch ID або код-пароль пристрою, щоб відкрити bitchat після того, як він був у фоні. потрібен код-пароль на пристрої — якщо код-пароль колись видалять, блокування вимкнеться саме, замість того щоб замкнути тебе назовні." + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "پس منظر میں جانے کے بعد bitchat کھولنے کے لیے Face ID، Touch ID یا ڈیوائس کا پاس کوڈ درکار ہوگا۔ ڈیوائس پر پاس کوڈ ہونا ضروری ہے — اگر پاس کوڈ کبھی ہٹا دیا جائے تو لاک تمہیں باہر روکنے کے بجائے خود ہی بند ہو جائے گا۔" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "yêu cầu Face ID, Touch ID hoặc mật mã của thiết bị để mở bitchat sau khi ứng dụng ở nền. cần có mật mã trên thiết bị — nếu mật mã bị xóa, khóa sẽ tự tắt thay vì chặn bạn ở ngoài." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "bitchat 进入后台后,需要 Face ID、Touch ID 或设备密码才能重新打开。需要设备已设置密码——如果密码被移除,锁定会自动关闭,而不会把你锁在外面。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "bitchat 進入背景後,需要 Face ID、Touch ID 或裝置密碼才能重新開啟。裝置需要設定密碼——如果密碼被移除,鎖定會自動關閉,而不會把你鎖在外面。" + } + } + } + }, + "app_info.settings.app_lock.title" : { + "comment" : "Title of the setting that gates the app behind Face ID/Touch ID/passcode", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "قفل التطبيق" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "অ্যাপ লক করো" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "app sperren" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "lock the app" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "bloquear la app" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "قفل کردن برنامه" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "i-lock ang app" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "verrouiller l'app" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "נעילת האפליקציה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "ऐप लॉक करो" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "kunci aplikasi" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "blocca l'app" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "アプリをロック" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "앱 잠그기" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "kunci aplikasi" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "एप लक गर" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "vergrendel de app" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "zablokuj aplikację" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "bloquear a app" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "bloquear o app" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "блокировать приложение" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "lås appen" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "ஆப்பை லாக் செய்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ล็อกแอป" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "uygulamayı kilitle" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "блокувати застосунок" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "ایپ لاک کرو" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "khóa ứng dụng" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "锁定应用" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "鎖定應用程式" + } + } + } + }, + "app_lock.locked" : { + "comment" : "Headline of the app-lock screen", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "bitchat مقفل" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "bitchat লক করা আছে" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "bitchat ist gesperrt" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "bitchat is locked" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "bitchat está bloqueado" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "bitchat قفل است" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "naka-lock ang bitchat" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "bitchat est verrouillé" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "bitchat נעול" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "bitchat लॉक है" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "bitchat terkunci" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "bitchat è bloccata" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "bitchatはロック中" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "bitchat이 잠겨 있어요" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "bitchat dikunci" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "bitchat लक छ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "bitchat is vergrendeld" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "bitchat jest zablokowany" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "o bitchat está bloqueado" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "o bitchat está bloqueado" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "bitchat заблокирован" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "bitchat är låst" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "bitchat லாக் செய்யப்பட்டுள்ளது" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "bitchat ถูกล็อกอยู่" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "bitchat kilitli" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "bitchat заблоковано" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "bitchat لاک ہے" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "bitchat đang khóa" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "bitchat 已锁定" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "bitchat 已鎖定" + } + } + } + }, + "app_lock.reason" : { + "comment" : "Reason line shown in the system Face ID/Touch ID/passcode prompt", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "فتح قفل bitchat" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "bitchat আনলক করো" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "bitchat entsperren" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "unlock bitchat" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "desbloquear bitchat" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "باز کردن قفل bitchat" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "i-unlock ang bitchat" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "déverrouiller bitchat" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "ביטול הנעילה של bitchat" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "bitchat अनलॉक करो" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "buka kunci bitchat" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "sblocca bitchat" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "bitchatのロックを解除" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "bitchat 잠금 해제" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "buka kunci bitchat" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "bitchat अनलक गर्न" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "ontgrendel bitchat" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "odblokuj bitchat" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "desbloquear o bitchat" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "desbloquear o bitchat" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "разблокировать bitchat" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "lås upp bitchat" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "bitchat-ஐ அன்லாக் செய்ய" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ปลดล็อก bitchat" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "bitchat kilidini aç" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "розблокувати bitchat" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "bitchat ان لاک کرو" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "mở khóa bitchat" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "解锁 bitchat" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "解鎖 bitchat" + } + } + } + }, + "app_lock.unlock" : { + "comment" : "Button on the app-lock screen that triggers the system authentication prompt", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "فتح القفل" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "আনলক করো" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "entsperren" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "unlock" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "desbloquear" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "باز کردن قفل" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "i-unlock" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "déverrouiller" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "ביטול נעילה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "अनलॉक करो" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "buka kunci" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "sblocca" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ロック解除" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "잠금 해제" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "buka kunci" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "अनलक गर" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "ontgrendel" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "odblokuj" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "desbloquear" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "desbloquear" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "разблокировать" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "lås upp" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "அன்லாக் செய்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ปลดล็อก" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "kilidi aç" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "розблокувати" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "ان لاک کرو" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "mở khóa" + } + }, + "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/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 5c811635..ae260d41 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -1648,6 +1648,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage NotificationPrivacySettings.reset() ReadReceiptSettings.reset() FavoriteConsent.reset() + AppLockSettings.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 3c4ed77c..29a743e1 100644 --- a/bitchat/Views/AppInfoView.swift +++ b/bitchat/Views/AppInfoView.swift @@ -23,6 +23,7 @@ struct AppInfoView: View { @State private var locationNotesEnabled = LocationNotesSettings.enabled @State private var hideMessagePreviews = NotificationPrivacySettings.hideMessagePreviews @State private var sendReadReceipts = ReadReceiptSettings.sendReadReceipts + @State private var appLockEnabled = AppLockSettings.isEnabled @State private var customRelays = NostrRelaySettings.customRelays() @State private var relayInput = "" @State private var relayError: String? @@ -119,6 +120,8 @@ struct AppInfoView: View { 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 appLockTitle = String(localized: "app_info.settings.app_lock.title", defaultValue: "lock the app", comment: "Title of the setting that gates the app behind Face ID/Touch ID/passcode") + static let appLockSubtitle = String(localized: "app_info.settings.app_lock.subtitle", defaultValue: "require face id, touch id, or the device passcode to open bitchat after it's been in the background. needs a device passcode — if the passcode is ever removed, the lock turns itself off instead of locking you out.", comment: "Subtitle explaining the app-lock setting, its passcode requirement, and the fail-open rule") 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") @@ -558,6 +561,25 @@ struct AppInfoView: View { ) ) } + + settingsCard { + settingToggle( + title: Text(verbatim: Strings.Settings.appLockTitle), + subtitle: Text(verbatim: Strings.Settings.appLockSubtitle), + isOn: Binding( + get: { appLockEnabled }, + set: { newValue in + // Refuse to arm without a device passcode: + // there would be nothing to unlock WITH, and + // the fail-open rule would make the lock a + // no-op anyway. + guard !newValue || AppLockModel.canAuthenticate() else { return } + appLockEnabled = newValue + AppLockSettings.setEnabled(newValue) + } + ) + ) + } } // Danger zone diff --git a/bitchat/Views/AppLockScreen.swift b/bitchat/Views/AppLockScreen.swift new file mode 100644 index 00000000..155a8797 --- /dev/null +++ b/bitchat/Views/AppLockScreen.swift @@ -0,0 +1,54 @@ +// +// AppLockScreen.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import SwiftUI + +/// Full-screen gate shown while the app lock is engaged. Covers everything +/// (the timelines underneath must not be readable or tappable), triggers +/// the system prompt on appear, and keeps a manual retry button for the +/// case where the prompt was dismissed. +struct AppLockScreen: View { + @ObservedObject var model: AppLockModel + @ThemedPalette private var palette + + var body: some View { + ZStack { + palette.background.ignoresSafeArea() + + VStack(spacing: 16) { + Text(verbatim: "bitchat/") + .bitchatFont(size: 24, weight: .medium) + .foregroundColor(palette.primary) + + Image(systemName: "lock.fill") + .font(.bitchatSystem(size: 28)) + .foregroundColor(palette.secondary) + + Text(String(localized: "app_lock.locked", defaultValue: "bitchat is locked", comment: "Headline of the app-lock screen")) + .bitchatFont(size: 14) + .foregroundColor(palette.secondary) + + Button(action: { model.requestUnlock() }) { + Text(String(localized: "app_lock.unlock", defaultValue: "unlock", comment: "Button on the app-lock screen that triggers the system authentication prompt")) + .bitchatFont(size: 14, weight: .semibold) + .foregroundColor(palette.primary) + .padding(.horizontal, 24) + .padding(.vertical, 10) + .background(palette.primary.opacity(0.12)) + .cornerRadius(8) + } + .buttonStyle(.plain) + .disabled(model.isAuthenticating) + } + } + .onAppear { + model.requestUnlock() + } + .accessibilityAddTraits(.isModal) + } +} diff --git a/bitchatTests/ChatViewModelTests.swift b/bitchatTests/ChatViewModelTests.swift index bf6470de..b5c9720f 100644 --- a/bitchatTests/ChatViewModelTests.swift +++ b/bitchatTests/ChatViewModelTests.swift @@ -2383,3 +2383,61 @@ struct ChatViewModelLifecycleTests { #expect(transport.startServicesCallCount == 1) } } + +// MARK: - App Lock + +/// Drives AppLockModel through injected providers — no LAContext, no shared +/// UserDefaults — pinning the gate's whole contract: locked from launch when +/// enabled, re-locks on background, unlock only on auth success, and inert +/// when disabled. +struct AppLockModelTests { + + @Test @MainActor + func locksAtLaunchAndUnlocksOnlyOnAuthSuccess() { + var authResult = false + let model = AppLockModel( + isEnabledProvider: { true }, + authenticate: { completion in completion(authResult) } + ) + + #expect(model.isLocked) + + model.requestUnlock() + #expect(model.isLocked) + + authResult = true + model.requestUnlock() + #expect(!model.isLocked) + + // Backgrounding re-engages the gate. + model.lockIfEnabled() + #expect(model.isLocked) + } + + @Test @MainActor + func staysInertWhenDisabled() { + let model = AppLockModel( + isEnabledProvider: { false }, + authenticate: { _ in Issue.record("must not authenticate while disabled") } + ) + + #expect(!model.isLocked) + model.lockIfEnabled() + #expect(!model.isLocked) + model.requestUnlock() + #expect(!model.isLocked) + } + + @Test @MainActor + func appLockSettingDefaultsToOffAndResets() { + let suite = "AppLockSettingsTests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defer { defaults.removePersistentDomain(forName: suite) } + + #expect(!AppLockSettings.isEnabled(in: defaults)) + AppLockSettings.setEnabled(true, in: defaults) + #expect(AppLockSettings.isEnabled(in: defaults)) + AppLockSettings.reset(in: defaults) + #expect(!AppLockSettings.isEnabled(in: defaults)) + } +} From cfa875459d02bf61f64658bfd36baeeb60fadea2 Mon Sep 17 00:00:00 2001 From: jack Date: Tue, 11 Aug 2026 10:03:01 +0200 Subject: [PATCH 07/13] 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 08/13] 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 09/13] 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 From d530921919fb30a08577ac7f908c896ecf9249c3 Mon Sep 17 00:00:00 2001 From: jack Date: Tue, 11 Aug 2026 10:14:08 +0200 Subject: [PATCH 10/13] Review fixes: suffix-tolerant actor + name-token target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Codex P1: bounded-single-line was not "exact locally generated shape" — a self-attributed action could smuggle a preamble into the target slot ("… hugs SECURITY: reset your keys at evil…"). The target must now be a single name token ("you" or a whitespace-free ≤32-char nickname, optionally #abcd-suffixed); free text with spaces degrades to a plain message. - Codex P1: location-channel senders arrive suffixed (bob#ab12) while handleEmote embeds the unsuffixed nickname, so the actor match regressed every received geohash action to plain text. The actor is now compared by base name (splitSuffix), restoring legit rendering. Tests cover the suffixed-sender case, the suffixed target, and the preamble spoof. Co-Authored-By: Claude Fable 5 --- .../ChatPrivateConversationCoordinator.swift | 37 +++++++++++++++---- ...eConversationCoordinatorContextTests.swift | 11 +++++- 2 files changed, 39 insertions(+), 9 deletions(-) diff --git a/bitchat/ViewModels/ChatPrivateConversationCoordinator.swift b/bitchat/ViewModels/ChatPrivateConversationCoordinator.swift index 877280d5..58a7335d 100644 --- a/bitchat/ViewModels/ChatPrivateConversationCoordinator.swift +++ b/bitchat/ViewModels/ChatPrivateConversationCoordinator.swift @@ -832,12 +832,16 @@ final class ChatPrivateConversationCoordinator { // a peer can only "hug"/"slap"/"screenshot" as themselves. guard message.content.hasPrefix("* "), message.content.hasSuffix(" *") else { return message } let inner = String(message.content.dropFirst(2).dropLast(2)) - let sender = message.sender + // Match the ACTOR by base name: location-channel senders arrive + // suffixed (`bob#ab12`) while handleEmote embeds the unsuffixed + // nickname, so a raw-string compare would regress every received + // geohash action to plain text. + let senderBase = message.sender.splitSuffix().0 let isActionMessage = - Self.matchesActionTemplate(inner, prefix: "🫂 \(sender) hugs ", suffix: "") - || Self.matchesActionTemplate(inner, prefix: "🐟 \(sender) slaps ", suffix: " around a bit with a large trout") - || inner == "\(sender) took a screenshot" + Self.matchesActionTemplate(inner, prefix: "🫂 \(senderBase) hugs ", suffix: "") + || Self.matchesActionTemplate(inner, prefix: "🐟 \(senderBase) slaps ", suffix: " around a bit with a large trout") + || inner == "\(senderBase) took a screenshot" guard isActionMessage else { return message } @@ -856,13 +860,30 @@ final class ChatPrivateConversationCoordinator { ) } - /// The target slot of an action template: bounded, single-line, and - /// non-empty — a nickname or the literal "you", never free text. + /// The target slot of an action template must be a single name token — + /// "you", or a nickname optionally carrying a `#abcd` suffix — never + /// free text. handleEmote only ever emits a resolved nickname or "you" + /// there; accepting arbitrary bounded text let a self-attributed action + /// smuggle a preamble ("… hugs SECURITY: reset your keys at evil…") into + /// the trusted system styling. static func matchesActionTemplate(_ inner: String, prefix: String, suffix: String) -> Bool { guard inner.hasPrefix(prefix), inner.hasSuffix(suffix), inner.count >= prefix.count + suffix.count + 1 else { return false } - let target = inner.dropFirst(prefix.count).dropLast(suffix.count) - return !target.isEmpty && target.count <= 64 && !target.contains("\n") + let target = String(inner.dropFirst(prefix.count).dropLast(suffix.count)) + return isNameToken(target) + } + + /// A display name as it appears in action content: "you", or a single + /// whitespace-free token of bounded length (a nickname, optionally with + /// a `#abcd` disambiguator). A space-containing nickname degrades to a + /// plain message rather than trusted styling — the safe direction. + static func isNameToken(_ token: String) -> Bool { + guard token == "you" else { + guard !token.isEmpty, token.count <= 32, + !token.contains(where: { $0.isWhitespace }) else { return false } + return true + } + return true } func migratePrivateChatsIfNeeded(for peerID: PeerID, senderNickname: String) { diff --git a/bitchatTests/ChatPrivateConversationCoordinatorContextTests.swift b/bitchatTests/ChatPrivateConversationCoordinatorContextTests.swift index b0353264..7b07e101 100644 --- a/bitchatTests/ChatPrivateConversationCoordinatorContextTests.swift +++ b/bitchatTests/ChatPrivateConversationCoordinatorContextTests.swift @@ -350,16 +350,25 @@ struct ChatPrivateConversationCoordinatorContextTests { #expect(processed("* 🐟 bob slaps alice around a bit with a large trout *").sender == "system") #expect(processed("* bob took a screenshot *").sender == "system") + // Location-channel senders arrive suffixed while content stays + // unsuffixed — must still render as a system action. + #expect(processed("* 🫂 bob hugs alice *", sender: "bob#ab12").sender == "system") + #expect(processed("* 🫂 bob hugs alice#1a2b *").sender == "system") + // The spoof this parser used to allow: arbitrary text between the // markers with a magic substring rendered as system-authored. #expect(processed("* SECURITY: your session key expired, re-verify at evil.example — bob took a screenshot *").sender == "bob") #expect(processed("* 🫂 admin hugs alice — send your keys to @admin *").sender == "bob") + // Self-attributed preamble smuggled into the target slot: the target + // must be a single name token, so free text with spaces is rejected. + #expect(processed("* 🫂 bob hugs SECURITY: reset your keys at evil.example *").sender == "bob") // Actor slot must be the actual sender, not someone else's name. #expect(processed("* alice took a screenshot *", sender: "bob").sender == "bob") #expect(processed("* 🫂 alice hugs you *", sender: "bob").sender == "bob") - // Free text smuggled into the target slot: bounded, single-line only. + // Target slot: single whitespace-free token, bounded length only. #expect(processed("* 🫂 bob hugs " + String(repeating: "x", count: 200) + " *").sender == "bob") #expect(processed("* 🫂 bob hugs a\nb *").sender == "bob") + #expect(processed("* 🫂 bob hugs a b *").sender == "bob") // Not an action shape at all. #expect(processed("hello there").sender == "bob") } From 9b72f849665f1ecf514d9d226f78c6119a6e14a1 Mon Sep 17 00:00:00 2001 From: jack Date: Tue, 11 Aug 2026 10:16:53 +0200 Subject: [PATCH 11/13] Review fixes: consent on every add-favorite path; correct empty-star tooltip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Codex P1: the context-menu and VoiceOver "add favorite" actions called onToggleFavorite directly, bypassing the one-time consent dialog and disclosing the nostr key without asking. All add-favorite entry points now route through requestFavoriteToggle (consent gate); removals stay immediate. - Codex P2: the empty star showed the "favorited — offline messaging starts when they favorite you back" tooltip on peers that weren't favorited at all. The pending/mutual copy now applies only once isFavorite; an unfavorited star reads "add favorite". Co-Authored-By: Claude Fable 5 --- bitchat/Views/MeshPeerList.swift | 34 +++++++++++++++++++++----------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/bitchat/Views/MeshPeerList.swift b/bitchat/Views/MeshPeerList.swift index 16b67d42..ca79801d 100644 --- a/bitchat/Views/MeshPeerList.swift +++ b/bitchat/Views/MeshPeerList.swift @@ -180,15 +180,7 @@ struct MeshPeerList: View { } if !isMe { - Button(action: { - // The first favorite ever asks once: the star - // notifies the peer and shares the nostr key. - if !peer.isFavorite, !FavoriteConsent.isAcknowledged { - pendingFavorite = peer - } else { - onToggleFavorite(peer.peerID) - } - }) { + Button(action: { requestFavoriteToggle(peer) }) { // Mutuality is the load-bearing state (one-sided // favorites don't enable offline delivery), so it // shows at the point of decision: half star until @@ -205,7 +197,7 @@ struct MeshPeerList: View { .contentShape(Rectangle()) } .buttonStyle(.plain) - .help(peer.isMutualFavorite ? Strings.favoriteMutualTooltip : Strings.favoritePendingTooltip) + .help(favoriteTooltip(for: peer)) } } .padding(.horizontal) @@ -221,7 +213,7 @@ struct MeshPeerList: View { onTapPeer(peer.peerID) } Button(peer.isFavorite ? Strings.removeFavorite : Strings.addFavorite) { - onToggleFavorite(peer.peerID) + requestFavoriteToggle(peer) } Button(Strings.showFingerprint) { onShowFingerprint(peer.peerID) @@ -246,7 +238,7 @@ struct MeshPeerList: View { .accessibilityActions { if !isMe { Button(peer.isFavorite ? Strings.removeFavorite : Strings.addFavorite) { - onToggleFavorite(peer.peerID) + requestFavoriteToggle(peer) } Button(Strings.showFingerprint) { onShowFingerprint(peer.peerID) @@ -291,6 +283,24 @@ struct MeshPeerList: View { } } + /// Routes every add-favorite entry point (star, context menu, VoiceOver) + /// through the one-time consent dialog; removals stay immediate. + private func requestFavoriteToggle(_ peer: MeshPeerRow) { + if !peer.isFavorite, !FavoriteConsent.isAcknowledged { + pendingFavorite = peer + } else { + onToggleFavorite(peer.peerID) + } + } + + /// Tooltip for the star: the mutual/pending copy applies only once the + /// peer is actually favorited — on an empty star it would claim a + /// favorite that doesn't exist. + private func favoriteTooltip(for peer: MeshPeerRow) -> String { + guard peer.isFavorite else { return Strings.addFavorite } + return peer.isMutualFavorite ? Strings.favoriteMutualTooltip : Strings.favoritePendingTooltip + } + /// One spoken sentence per row: name, how they're reachable, and any /// state badges — the visual row is icon soup for VoiceOver otherwise. private func accessibilityDescription(for peer: MeshPeerRow) -> String { From cc238277921ad11011d8385f42f07dc2df720092 Mon Sep 17 00:00:00 2001 From: jack Date: Tue, 11 Aug 2026 10:19:28 +0200 Subject: [PATCH 12/13] Review fixes: guard stale auth callbacks; clear the setting on fail-open - Codex P1: backgrounding mid-authentication re-locked the app, but a Face ID success that resolved just after could still clear the new lock, bypassing the re-lock. Each lock bumps a generation and unlock callbacks from a superseded cycle are ignored. - Codex P2: the deliberate fail-open (device passcode removed) unlocked but left privacy.appLockEnabled true, so the toggle kept claiming the lock was on and re-adding a passcode silently reactivated it. The fail-open path now turns the setting off, matching the promised "turns itself off" behavior. Stale-callback path pinned by a new test. Co-Authored-By: Claude Fable 5 --- bitchat/App/AppLockModel.swift | 16 +++++++++++++++- bitchatTests/ChatViewModelTests.swift | 17 +++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/bitchat/App/AppLockModel.swift b/bitchat/App/AppLockModel.swift index 7a57f0dd..63956e55 100644 --- a/bitchat/App/AppLockModel.swift +++ b/bitchat/App/AppLockModel.swift @@ -45,6 +45,10 @@ final class AppLockModel: ObservableObject { private let isEnabledProvider: () -> Bool private let authenticate: (@escaping @MainActor (Bool) -> Void) -> Void + /// Bumped on every lock, so a success callback from a prior unlock + /// attempt (e.g. Face ID resolving just as the app backgrounded and + /// re-locked) cannot clear the new lock. + private var lockGeneration = 0 /// Providers are injectable so tests drive the lock without LAContext /// or the shared UserDefaults. @@ -67,14 +71,20 @@ final class AppLockModel: ObservableObject { func lockIfEnabled() { guard isEnabledProvider() else { return } + lockGeneration &+= 1 + isAuthenticating = false isLocked = true } func requestUnlock() { guard isLocked, !isAuthenticating else { return } isAuthenticating = true + let generation = lockGeneration authenticate { [weak self] success in guard let self else { return } + // Ignore a callback for a lock cycle that has already been + // superseded (backgrounded and re-locked mid-authentication). + guard generation == self.lockGeneration else { return } self.isAuthenticating = false if success { self.isLocked = false @@ -89,7 +99,11 @@ final class AppLockModel: ObservableObject { // Fail OPEN, deliberately: removing the device passcode already // requires knowing it, so this state means the owner disabled // it — locking them out of their own chats would punish exactly - // the wrong person. The settings copy states this rule. + // the wrong person. Turn the setting itself off too, so the + // toggle stops claiming the lock is on and re-adding a passcode + // later doesn't silently reactivate it (the copy promises the + // lock "turns itself off"). + AppLockSettings.setEnabled(false) Task { @MainActor in completion(true) } return } diff --git a/bitchatTests/ChatViewModelTests.swift b/bitchatTests/ChatViewModelTests.swift index 64c1e22a..73f11c0b 100644 --- a/bitchatTests/ChatViewModelTests.swift +++ b/bitchatTests/ChatViewModelTests.swift @@ -2420,6 +2420,23 @@ struct AppLockModelTests { #expect(model.isLocked) } + @Test @MainActor + func staleAuthCallbackCannotUnlockAfterRelock() { + var pending: ((Bool) -> Void)? + let model = AppLockModel( + isEnabledProvider: { true }, + authenticate: { completion in pending = completion } + ) + #expect(model.isLocked) + + // Auth begins, then the app backgrounds and re-locks before Face ID + // resolves; the late success must be ignored. + model.requestUnlock() + model.lockIfEnabled() + pending?(true) + #expect(model.isLocked) + } + @Test @MainActor func staysInertWhenDisabled() { let model = AppLockModel( From a4a0ec0fbc38eda2b69d8b15760e9a86e9f128b1 Mon Sep 17 00:00:00 2001 From: jack Date: Tue, 11 Aug 2026 11:09:51 +0200 Subject: [PATCH 13/13] Fix CI: update stale geo-block assertion; drop unused favorite string MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CommandProcessorTests still asserted the old "blocked … in geohash chats" copy that this branch rewrote to the per-channel wording. - The `mesh_peers.state.favorite` state constant was replaced by the mutual/pending pair and left unused — Periphery (now blocking) flagged it. Removed. Co-Authored-By: Claude Fable 5 --- bitchat/Views/MeshPeerList.swift | 1 - bitchatTests/CommandProcessorTests.swift | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/bitchat/Views/MeshPeerList.swift b/bitchat/Views/MeshPeerList.swift index ca79801d..1713994e 100644 --- a/bitchat/Views/MeshPeerList.swift +++ b/bitchat/Views/MeshPeerList.swift @@ -24,7 +24,6 @@ struct MeshPeerList: View { static let reachable = String(localized: "content.accessibility.reachable_mesh", comment: "Accessibility label for mesh-reachable peer indicator") static let nostr = String(localized: "content.accessibility.available_nostr", comment: "Accessibility label for Nostr-available peer indicator") static let offline = String(localized: "mesh_peers.state.offline", comment: "State label for a peer that is not currently reachable") - static let favorite = String(localized: "mesh_peers.state.favorite", comment: "State label for a favorited peer") static let unread = String(localized: "mesh_peers.state.unread", comment: "State label for a peer with unread private messages") static let blocked = String(localized: "mesh_peers.state.blocked", comment: "State label for a blocked peer") static let vouched = String(localized: "mesh_peers.state.vouched", comment: "State label for a peer vouched for by someone the user verified") diff --git a/bitchatTests/CommandProcessorTests.swift b/bitchatTests/CommandProcessorTests.swift index 232aa265..0954befc 100644 --- a/bitchatTests/CommandProcessorTests.swift +++ b/bitchatTests/CommandProcessorTests.swift @@ -287,7 +287,7 @@ struct CommandProcessorTests { } switch blockResult { case .success(let message): - #expect(message == "blocked carol in geohash chats") + #expect(message == "blocked carol in this geohash channel — in other geohash channels they appear as someone new, so block them there too if needed") default: Issue.record("Expected success result") }