Add a read-receipt toggle

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 <noreply@anthropic.com>
This commit is contained in:
jack 2026-08-11 09:17:55 +02:00
parent 9b84b36122
commit 3e46829f32
7 changed files with 512 additions and 1 deletions

View File

@ -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",

View File

@ -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(

View File

@ -0,0 +1,47 @@
//
// ReadReceiptSettings.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
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)
}
}

View File

@ -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)
}

View File

@ -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<String> = [] { // 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()

View File

@ -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

View File

@ -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()