From 55d51ba084e9b77f843eb9f613319e76d5aac7ef Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Wed, 8 Jul 2026 22:42:39 +0200 Subject: [PATCH] DMs to unreachable peers use store-and-forward instead of failing instantly (#1415) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * DMs to unreachable peers route through store-and-forward; drop stale reachability gates Field-found: sendPrivateMessage pre-judged reachability and marked the message failed without ever calling the router, so the retained outbox, courier deposits, and bridge drops never ran — a DM composed after a peer's reachability window lapsed was dead on arrival while an identical one sent a minute earlier delivered. Messages now always route; a live path earns "sent", everything else stays "sending" until the router reports carried/delivered or expires it as failed. A sweep for sibling gates found and fixed: - startPrivateChat refused offline non-mutual favorites ("mutual favorite required for offline messaging") — store-and-forward only needs the recipient's noise key, so the gate and its string are gone. - markPrivateMessagesAsRead skipped the whole receipt pass for peers without a stored Nostr key, starving mesh-connected non-favorites; the router picks the transport now (sentReadReceipts dedups the parallel PrivateChatManager path). - DM/geoDM blocked notices and the unknown-group error posted to the active public timeline; they now land in the thread they're about via addLocalPrivateSystemMessage. - Banned copy: literal "user" fallbacks in code become "anon" (the default-nickname convention), and es/fr/it/pt translations of four keys still saying usuario/utilisateur/utente/usuário now say person. Orphaned keys removed (system.dm.unreachable, content.delivery.reason.unreachable, system.chat.requires_favorite). Co-Authored-By: Claude Fable 5 * Deflake BLEServiceCoreTests: longTimeout for positive waits CI flaked on duplicatePacket_isDeduped: the first message landed after the 5s wait expired on a loaded runner (the test's later count==1 assertions passed, proving late delivery, not lost delivery). All four positive waits in the file now use TestConstants.longTimeout, which exists for exactly this — waitUntil returns as soon as the condition holds, so passing runs never pay the longer ceiling. Co-Authored-By: Claude Fable 5 * Fix read-receipt test contamination; baseline Periphery noiseKey flake Two CI failures, both environmental: - markReadReceiptSent_returnsFalseOnSecondCall flaked because every test ChatViewModel shared one per-process read-receipts scratch suite; the lifecycle test persists the same "read-1" ID (a path the receipt-gate removal now reaches), and test order decided who saw whose state. Each instance now gets its own UUID-suffixed suite. - Periphery intermittently misses the loadFromDisk read of PrekeyBundleStore.StoredBundle.noiseKey and fails --strict with a false "assign-only" finding (recurrence of a known flake). Its USR is baselined; an in-source periphery:ignore can't work because strict mode flags it as superfluous on runs where the indexer gets it right. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: jack Co-authored-by: Claude Fable 5 --- .periphery.baseline.json | 2 +- bitchat/Localizable.xcstrings | 563 +----------------- .../Services/Prekeys/PrekeyBundleStore.swift | 5 + bitchat/ViewModels/ChatGroupCoordinator.swift | 7 +- .../ViewModels/ChatLifecycleCoordinator.swift | 5 +- .../ChatPeerIdentityCoordinator.swift | 21 +- .../ChatPrivateConversationCoordinator.swift | 65 +- bitchat/ViewModels/ChatViewModel.swift | 16 +- bitchatTests/BLEServiceCoreTests.swift | 8 +- ...ChatLifecycleCoordinatorContextTests.swift | 9 +- ...eConversationCoordinatorContextTests.swift | 22 +- .../ChatViewModelExtensionsTests.swift | 23 +- 12 files changed, 114 insertions(+), 632 deletions(-) diff --git a/.periphery.baseline.json b/.periphery.baseline.json index 0b826869..67219de2 100644 --- a/.periphery.baseline.json +++ b/.periphery.baseline.json @@ -1 +1 @@ -{"v1":{"usrs":["param-buf-arti_bootstrap_summary(_:_:)-s:3Tor22arti_bootstrap_summary33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSpys4Int8VG_AEtF","param-dataDir-arti_start(_:_:)-s:3Tor10arti_start33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSPys4Int8VG_s6UInt16VtF","param-len-arti_bootstrap_summary(_:_:)-s:3Tor22arti_bootstrap_summary33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSpys4Int8VG_AEtF","param-socksPort-arti_start(_:_:)-s:3Tor10arti_start33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSPys4Int8VG_s6UInt16VtF","s:13BitFoundation16PeerCapabilitiesV8wifiBulkACvpZ","s:13BitFoundation18KeychainReadResultO18isRecoverableErrorSbvp","s:13BitFoundation23KeychainManagerProtocolP11secureClearyySSzF","s:18bitchatTests_macOS12MockKeychainC11secureClearyySSzF","s:18bitchatTests_macOS20TrackingMockKeychainC11resetCountsyyF","s:18bitchatTests_macOS20TrackingMockKeychainC11secureClearyySSzF","s:18bitchatTests_macOS20TrackingMockKeychainC25totalSecureClearCallCountSivp","s:18bitchatTests_macOS20TrackingMockKeychainC26secureClearStringCallCountSivp","s:18bitchatTests_macOS20TrackingMockKeychainC27_secureClearStringCallCount06_AB6D1M24FD239F2969C82F4108818260LLSivp","s:18bitchatTests_macOS24FailingCacheSaveKeychain33_22380C7A11A569A0B83FA83F34C498A7LLC11secureClearyySSzF","s:18bitchatTests_macOS24MockGeohashPresenceTimer33_483587EFB96650EE130EFB09BBA2A1AALLC7handleryycvp","s:3Tor0A7ManagerC21goDormantOnBackgroundyyF","s:7bitchat10AppRuntimeC24handleScreenshotCaptured33_C8B369AD8BC1D9963A50CEDA77A4332ALLyyF","s:7bitchat10AppRuntimeC33handleDidBecomeActiveNotificationyyF","s:7bitchat10BLEServiceC18logBluetoothStatus33_69191C53E68500C17D98DBCF2BDA7100LLyySSF","s:7bitchat10BLEServiceC20centralRestorationID33_69191C53E68500C17D98DBCF2BDA7100LLSSvpZ","s:7bitchat10BLEServiceC22captureBluetoothStatus33_69191C53E68500C17D98DBCF2BDA7100LL7contextySS_tF","s:7bitchat10BLEServiceC23peripheralRestorationID33_69191C53E68500C17D98DBCF2BDA7100LLSSvpZ","s:7bitchat10BLEServiceC29scheduleBluetoothStatusSample33_69191C53E68500C17D98DBCF2BDA7100LL5after7contextySd_SStF","s:7bitchat10QRScanViewV8isActiveSbvp","s:7bitchat15BLEPeerRegistryV5countSivp","s:7bitchat15KeychainManagerC11secureClearyySSzF","s:7bitchat15PaymentChipViewV7openURL33_10AC50641B1EBCD52E5092A2E521D236LL7SwiftUI13OpenURLActionVvp","s:7bitchat15TransportConfigO29uiBatchDispatchStaggerSecondsSdvpZ","s:7bitchat15TransportConfigO35uiShareExtensionDismissDelaySecondsSdvpZ","s:7bitchat15TransportConfigO38bleBackgroundPendingConnectSlotReserveSivpZ","s:7bitchat17GossipSyncManagerC10persistNowyyF","s:7bitchat17NostrRelayManagerC15InboundEventKey33_E4160FE8A9A2C9D6308EAAD5A8B5CB07LLV7eventIDSSvp","s:7bitchat25LocationNotesDependenciesV3now10Foundation4DateVycvp","s:7bitchat25NWPathReachabilityMonitorC7monitor33_84633C9DBCAF57538179C1E04DB8E015LL7Network0bD0CSgvp"]}} \ No newline at end of file +{"v1":{"usrs":["param-buf-arti_bootstrap_summary(_:_:)-s:3Tor22arti_bootstrap_summary33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSpys4Int8VG_AEtF","param-dataDir-arti_start(_:_:)-s:3Tor10arti_start33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSPys4Int8VG_s6UInt16VtF","param-len-arti_bootstrap_summary(_:_:)-s:3Tor22arti_bootstrap_summary33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSpys4Int8VG_AEtF","param-socksPort-arti_start(_:_:)-s:3Tor10arti_start33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSPys4Int8VG_s6UInt16VtF","s:13BitFoundation16PeerCapabilitiesV8wifiBulkACvpZ","s:13BitFoundation18KeychainReadResultO18isRecoverableErrorSbvp","s:13BitFoundation23KeychainManagerProtocolP11secureClearyySSzF","s:18bitchatTests_macOS12MockKeychainC11secureClearyySSzF","s:18bitchatTests_macOS20TrackingMockKeychainC11resetCountsyyF","s:18bitchatTests_macOS20TrackingMockKeychainC11secureClearyySSzF","s:18bitchatTests_macOS20TrackingMockKeychainC25totalSecureClearCallCountSivp","s:18bitchatTests_macOS20TrackingMockKeychainC26secureClearStringCallCountSivp","s:18bitchatTests_macOS20TrackingMockKeychainC27_secureClearStringCallCount06_AB6D1M24FD239F2969C82F4108818260LLSivp","s:18bitchatTests_macOS24FailingCacheSaveKeychain33_22380C7A11A569A0B83FA83F34C498A7LLC11secureClearyySSzF","s:18bitchatTests_macOS24MockGeohashPresenceTimer33_483587EFB96650EE130EFB09BBA2A1AALLC7handleryycvp","s:3Tor0A7ManagerC21goDormantOnBackgroundyyF","s:7bitchat10AppRuntimeC24handleScreenshotCaptured33_C8B369AD8BC1D9963A50CEDA77A4332ALLyyF","s:7bitchat10AppRuntimeC33handleDidBecomeActiveNotificationyyF","s:7bitchat10BLEServiceC18logBluetoothStatus33_69191C53E68500C17D98DBCF2BDA7100LLyySSF","s:7bitchat10BLEServiceC20centralRestorationID33_69191C53E68500C17D98DBCF2BDA7100LLSSvpZ","s:7bitchat10BLEServiceC22captureBluetoothStatus33_69191C53E68500C17D98DBCF2BDA7100LL7contextySS_tF","s:7bitchat10BLEServiceC23peripheralRestorationID33_69191C53E68500C17D98DBCF2BDA7100LLSSvpZ","s:7bitchat10BLEServiceC29scheduleBluetoothStatusSample33_69191C53E68500C17D98DBCF2BDA7100LL5after7contextySd_SStF","s:7bitchat10QRScanViewV8isActiveSbvp","s:7bitchat15BLEPeerRegistryV5countSivp","s:7bitchat15KeychainManagerC11secureClearyySSzF","s:7bitchat15PaymentChipViewV7openURL33_10AC50641B1EBCD52E5092A2E521D236LL7SwiftUI13OpenURLActionVvp","s:7bitchat15TransportConfigO29uiBatchDispatchStaggerSecondsSdvpZ","s:7bitchat15TransportConfigO35uiShareExtensionDismissDelaySecondsSdvpZ","s:7bitchat15TransportConfigO38bleBackgroundPendingConnectSlotReserveSivpZ","s:7bitchat17GossipSyncManagerC10persistNowyyF","s:7bitchat17NostrRelayManagerC15InboundEventKey33_E4160FE8A9A2C9D6308EAAD5A8B5CB07LLV7eventIDSSvp","s:7bitchat17PrekeyBundleStoreC12StoredBundleV8noiseKey10Foundation4DataVvp","s:7bitchat25LocationNotesDependenciesV3now10Foundation4DateVycvp","s:7bitchat25NWPathReachabilityMonitorC7monitor33_84633C9DBCAF57538179C1E04DB8E015LL7Network0bD0CSgvp"]}} \ No newline at end of file diff --git a/bitchat/Localizable.xcstrings b/bitchat/Localizable.xcstrings index 64067a7c..89942914 100644 --- a/bitchat/Localizable.xcstrings +++ b/bitchat/Localizable.xcstrings @@ -27498,7 +27498,7 @@ "es" : { "stringUnit" : { "state" : "translated", - "value" : "desbloquear a un usuario" + "value" : "desbloquear a una persona" } }, "fil" : { @@ -30155,185 +30155,6 @@ } } }, - "content.delivery.reason.unreachable" : { - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "القرين غير متاح" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "পিয়ার পৌঁছানো যাচ্ছে না" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "peer nicht erreichbar" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "peer not reachable" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "el destinatario no es alcanzable" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "hindi maabot ang peer" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "pair injoignable" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "עמית לא זמין" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "पीयर पहुंच योग्य नहीं" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "peer tidak dapat dijangkau" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "peer irraggiungibile" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "ピアに到達できません" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "피어에 연결할 수 없습니다" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "peer tidak dapat dijangkau" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "पीयर पहुँचयोग्य छैन" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "peer onbereikbaar" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "peer nieosiągalny" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "par inacessível" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "par inalcançável" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "пир недостижим" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "peer ej nåbar" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "peer அணுக முடியவில்லை" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "ติดต่อเพียร์ไม่ได้" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "eşe ulaşılamıyor" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "пір недосяжний" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "peer تک رسائی نہیں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "không liên lạc được với nút" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "同伴不可达" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "同伴不可達" - } - } - } - }, "content.delivery.reason.voice_send_failed" : { "comment" : "Failure reason shown when a voice note could not be sent", "extractionState" : "manual", @@ -58664,7 +58485,7 @@ "es" : { "stringUnit" : { "state" : "translated", - "value" : "no se puede iniciar un chat con %@: el usuario está bloqueado." + "value" : "no se puede iniciar un chat con %@: esta persona está bloqueada." } }, "fil" : { @@ -58676,7 +58497,7 @@ "fr" : { "stringUnit" : { "state" : "translated", - "value" : "impossible de démarrer un chat avec %@ : utilisateur bloqué." + "value" : "impossible de démarrer un chat avec %@ : cette personne est bloquée." } }, "he" : { @@ -58700,7 +58521,7 @@ "it" : { "stringUnit" : { "state" : "translated", - "value" : "impossibile avviare una chat con %@: utente bloccato." + "value" : "impossibile avviare una chat con %@: questa persona è bloccata." } }, "ja" : { @@ -58748,7 +58569,7 @@ "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "não é possível iniciar chat com %@: usuário bloqueado." + "value" : "não é possível iniciar chat com %@: a pessoa está bloqueada." } }, "ru" : { @@ -58813,185 +58634,6 @@ } } }, - "system.chat.requires_favorite" : { - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "لا يمكن بدء دردشة مع %@: يجب أن تكونا مفضلين متبادلين للتشغيل بدون اتصال." - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@-এর সঙ্গে চ্যাট শুরু করা যায় না: অফলাইন মেসেজিংয়ের জন্য পারস্পরিক প্রিয় দরকার।" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "chat mit %@ kann nicht gestartet werden: gegenseitige favoriten für offline nötig." - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "cannot start chat with %@: mutual favorite required for offline messaging." - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "no se puede iniciar un chat con %@: necesitas ser favoritos mutuos para mensajería sin conexión." - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "hindi makapagsimula ng chat kay %@: kailangan ang mutual na paborito para sa offline na mensahe." - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "impossible de démarrer un chat avec %@ : favoris mutuels requis pour le hors ligne." - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "לא ניתן להתחיל צ'אט עם %@: נדרשים מועדפים הדדיים לאופליין." - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ के साथ चैट शुरू नहीं कर सकते: ऑफ़लाइन मैसेजिंग के लिए आपसी पसंदीदा आवश्यक है।" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "tidak bisa mulai chat dengan %@: butuh favorit bersama untuk offline." - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "impossibile avviare una chat con %@: servono preferiti reciproci per l'offline." - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@とはチャットできません: オフラインには相互のお気に入りが必要です。" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@와 채팅을 시작할 수 없습니다: 오프라인 메시지를 보내려면 서로 즐겨찾기에 추가해야 합니다." - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "tidak bisa mulai chat dengan %@: butuh favorit bersama untuk offline." - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ सँग च्याट सुरु गर्न मिलेन: अफलाइनका लागि दुवै मनपर्ने हुनुपर्छ" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "kan chat met %@ niet starten: wederzijds favoriet vereist voor offline berichten." - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "nie można rozpocząć czatu z %@: potrzebne wzajemne ulubione dla wiadomości offline." - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "não é possível iniciar o chat com %@: precisam de ser favoritos mútuos para mensagens offline." - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "não é possível iniciar chat com %@: vocês precisam ser favoritos mútuos para mensagens offline." - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "нельзя начать чат с %@: нужны взаимные избранные для офлайна." - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "kan inte starta chatt med %@: ömsesidig favorit krävs för offline-meddelanden." - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ உடன் உரையாடல் தொடங்க முடியாது: ஆஃப்லைன் செய்திகளுக்கு இருபுறமும் பிரியப்பட்டவர்கள் ஆக வேண்டும்." - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "ไม่สามารถเริ่มแชทกับ %@: ต้องเป็นรายการโปรดทั้งสองฝ่ายเพื่อใช้งานออฟไลน์" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ ile sohbet başlatılamıyor: çevrimdışı mesajlaşma için karşılıklı favori gerekiyor." - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "не можна почати чат з %@: потрібне взаємне вибране для офлайна." - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ کے ساتھ چیٹ شروع نہیں کر سکتے: آفلائن پیغامات کیلئے باہمی پسندیدہ ہونا ضروری ہے۔" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "không thể bắt đầu chat với %@: cần yêu thích lẫn nhau để nhắn ngoại tuyến." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "无法与 %@ 开始聊天:离线消息需要互相关注。" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "無法與 %@ 開始聊天:離線訊息需要互相關注。" - } - } - } - }, "system.dm.blocked_generic" : { "extractionState" : "manual", "localizations" : { @@ -59022,7 +58664,7 @@ "es" : { "stringUnit" : { "state" : "translated", - "value" : "no se puede enviar el mensaje: el usuario está bloqueado." + "value" : "no se puede enviar el mensaje: esta persona está bloqueada." } }, "fil" : { @@ -59034,7 +58676,7 @@ "fr" : { "stringUnit" : { "state" : "translated", - "value" : "envoi impossible : utilisateur bloqué." + "value" : "envoi impossible : cette personne est bloquée." } }, "he" : { @@ -59058,7 +58700,7 @@ "it" : { "stringUnit" : { "state" : "translated", - "value" : "invio non riuscito: utente bloccato." + "value" : "invio non riuscito: questa persona è bloccata." } }, "ja" : { @@ -59106,7 +58748,7 @@ "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "não foi possível enviar: usuário bloqueado." + "value" : "não foi possível enviar: a pessoa está bloqueada." } }, "ru" : { @@ -59201,7 +58843,7 @@ "es" : { "stringUnit" : { "state" : "translated", - "value" : "no se puede enviar un mensaje a %@: el usuario está bloqueado." + "value" : "no se puede enviar un mensaje a %@: esta persona está bloqueada." } }, "fil" : { @@ -59213,7 +58855,7 @@ "fr" : { "stringUnit" : { "state" : "translated", - "value" : "impossible d'envoyer à %@ : utilisateur bloqué." + "value" : "impossible d'envoyer à %@ : cette personne est bloquée." } }, "he" : { @@ -59237,7 +58879,7 @@ "it" : { "stringUnit" : { "state" : "translated", - "value" : "impossibile inviare a %@: utente bloccato." + "value" : "impossibile inviare a %@: questa persona è bloccata." } }, "ja" : { @@ -59285,7 +58927,7 @@ "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "não é possível enviar mensagem para %@: usuário bloqueado." + "value" : "não é possível enviar mensagem para %@: a pessoa está bloqueada." } }, "ru" : { @@ -59350,185 +58992,6 @@ } } }, - "system.dm.unreachable" : { - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "لا يمكن الإرسال إلى %@: المستلم غير متاح عبر mesh أو nostr." - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@-কে বার্তা পাঠানো যায় না - পিয়ার মেশ বা নোস্টরে পৌঁছানো যাচ্ছে না।" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "senden an %@ nicht möglich: empfänger über mesh oder nostr nicht erreichbar." - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "cannot send message to %@ - peer is not reachable via mesh or nostr." - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "no se puede enviar un mensaje a %@: el destinatario no es alcanzable por mesh ni Nostr." - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "hindi maipadala kay %@ - hindi maabot ang peer sa mesh o nostr." - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "impossible d'envoyer à %@ : destinataire injoignable via mesh ou nostr." - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "אי אפשר לשלוח ל-%@: הנמען אינו נגיש דרך mesh או nostr." - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ को संदेश नहीं भेज सकते - पीयर मेश या नोस्ट्र पर पहुँच योग्य नहीं।" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "tidak bisa mengirim ke %@: penerima tidak dapat dijangkau lewat mesh atau nostr." - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "impossibile inviare a %@: destinatario irraggiungibile via mesh o nostr." - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@に送れません: 受信者はmeshやnostrで到達できません。" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@에게 메시지를 보낼 수 없습니다 - mesh 또는 nostr를 통해 피어에 연결할 수 없습니다." - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "tidak bisa menghantar ke %@: penerima tidak dapat dijangkau lewat mesh atau nostr." - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ लाई पठाउन मिलेन: प्राप्तकर्ता mesh वा nostr बाट उपलब्ध छैन" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "kan geen bericht sturen naar %@ – peer niet bereikbaar via mesh of nostr." - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "nie można wysłać do %@ – peer nieosiągalny przez mesh ani nostr." - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "não é possível enviar mensagem a %@ - o par não está acessível por mesh ou Nostr." - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "não é possível enviar mensagem para %@: destinatário inalcançável por mesh ou nostr." - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "нельзя отправить %@: адресат недоступен через mesh или nostr." - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "kan inte skicka till %@ – peer nås inte via mesh eller nostr." - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ க்கு செய்தி அனுப்ப முடியவில்லை - peer mesh அல்லது nostr மூலமாக அணுக முடியவில்லை." - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "ไม่สามารถส่งข้อความถึง %@ - ติดต่อเพียร์ผ่าน mesh หรือ nostr ไม่ได้" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@'a mesaj gönderilemiyor - eş mesh veya Nostr üzerinden ulaşılamıyor." - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "неможливо надіслати %@: одержувач недосяжний через mesh або nostr." - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ کو پیغام نہیں بھیج سکتے - peer mesh یا nostr کے ذریعے دستیاب نہیں۔" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "không thể gửi tin cho %@ - nút không thể liên lạc qua mesh hoặc nostr." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "无法向 %@ 发送:对方无法通过 mesh 或 Nostr 到达。" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "無法向 %@ 發送:對方無法透過 mesh 或 Nostr 到達。" - } - } - } - }, "system.gateway.sent_via_mesh" : { "comment" : "System message when a geohash message was handed to a mesh internet gateway because no relay is reachable", "extractionState" : "manual", diff --git a/bitchat/Services/Prekeys/PrekeyBundleStore.swift b/bitchat/Services/Prekeys/PrekeyBundleStore.swift index fac9227c..18d6ea00 100644 --- a/bitchat/Services/Prekeys/PrekeyBundleStore.swift +++ b/bitchat/Services/Prekeys/PrekeyBundleStore.swift @@ -22,6 +22,11 @@ import Foundation /// prekey-seal for recipients met long ago. Included in the panic wipe. final class PrekeyBundleStore { struct StoredBundle: Codable { + // noiseKey is read in loadFromDisk (dictionary keying), but the + // Periphery indexer intermittently misses that read and flakes CI + // with "assign-only" — its USR is baselined (an in-source ignore + // can't work: strict mode flags it as superfluous on the runs where + // the indexer gets it right). let noiseKey: Data var generatedAt: UInt64 var prekeyIDs: [UInt32] diff --git a/bitchat/ViewModels/ChatGroupCoordinator.swift b/bitchat/ViewModels/ChatGroupCoordinator.swift index 7489b3bc..6e146fa5 100644 --- a/bitchat/ViewModels/ChatGroupCoordinator.swift +++ b/bitchat/ViewModels/ChatGroupCoordinator.swift @@ -336,7 +336,12 @@ final class ChatGroupCoordinator { guard !content.isEmpty, content.count <= InputValidator.Limits.maxMessageLength else { return } guard let group = context.groupStore.group(for: groupPeerID), let key = context.groupStore.key(forGroupID: group.groupID) else { - context.addSystemMessage(String(localized: "system.group.unknown", comment: "System message when sending into an unknown group")) + // The person is inside the group thread; the error belongs there, + // not on the active public timeline. + context.addLocalPrivateSystemMessage( + String(localized: "system.group.unknown", comment: "System message when sending into an unknown group"), + to: groupPeerID + ) return } diff --git a/bitchat/ViewModels/ChatLifecycleCoordinator.swift b/bitchat/ViewModels/ChatLifecycleCoordinator.swift index 1241f71b..bbcde713 100644 --- a/bitchat/ViewModels/ChatLifecycleCoordinator.swift +++ b/bitchat/ViewModels/ChatLifecycleCoordinator.swift @@ -231,7 +231,10 @@ final class ChatLifecycleCoordinator { } } - guard peerNostrPubkey != nil else { return } + // No Nostr-key gate here: the router picks whatever transport can + // reach the peer (mesh included), so read receipts must flow for + // non-favorite mesh peers too. `sentReadReceipts` dedups against the + // PrivateChatManager path; the router drops receipts it can't route. for message in getPrivateChatMessages(for: peerID) { guard (message.senderPeerID == peerID || message.senderPeerID == noiseKeyHex) && !message.isRelay else { diff --git a/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift b/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift index a1e340fe..93b0f32a 100644 --- a/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift +++ b/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift @@ -347,20 +347,10 @@ final class ChatPeerIdentityCoordinator { return } - if let peer = context.unifiedPeer(for: peerID), - peer.isFavorite && !peer.theyFavoritedUs && !peer.isConnected { - context.addSystemMessage( - String( - format: String( - localized: "system.chat.requires_favorite", - comment: "System message when mutual favorite requirement blocks chat" - ), - locale: .current, - peerNickname - ) - ) - return - } + // No mutual-favorite gate: store-and-forward (couriers, bridge drops, + // retained outbox) only needs the recipient's noise key, so an + // offline non-mutual favorite is still worth writing to — the router + // decides what delivery looks like, not chat entry. _ = context.consolidatePrivateMessages(for: peerID, peerNickname: peerNickname) @@ -553,7 +543,8 @@ final class ChatPeerIdentityCoordinator { !favorite.peerNickname.isEmpty { return favorite.peerNickname } - return "user" + // "anon" matches the default-nickname convention; "user" is banned copy. + return "anon" } } diff --git a/bitchat/ViewModels/ChatPrivateConversationCoordinator.swift b/bitchat/ViewModels/ChatPrivateConversationCoordinator.swift index 6765746a..bfaaab90 100644 --- a/bitchat/ViewModels/ChatPrivateConversationCoordinator.swift +++ b/bitchat/ViewModels/ChatPrivateConversationCoordinator.swift @@ -92,6 +92,9 @@ protocol ChatPrivateConversationContext: AnyObject { // MARK: System messages func addSystemMessage(_ content: String) func addMeshOnlySystemMessage(_ content: String) + /// Appends a local-only system line into a specific private thread — + /// errors about a DM belong in that DM, not on the active timeline. + func addLocalPrivateSystemMessage(_ content: String, to peerID: PeerID) // MARK: Favorites & notifications /// The persisted favorite relationship for the peer's Noise static key, if any. @@ -250,13 +253,14 @@ final class ChatPrivateConversationCoordinator { guard !content.isEmpty else { return } if context.isPeerBlocked(peerID) { - let nickname = context.peerNickname(for: peerID) ?? "user" - context.addSystemMessage( + let nickname = context.peerNickname(for: peerID) ?? "anon" + context.addLocalPrivateSystemMessage( String( - format: String(localized: "system.dm.blocked_recipient", comment: "System message when attempting to message a blocked user"), + format: String(localized: "system.dm.blocked_recipient", comment: "System message when attempting to message a blocked person"), locale: .current, nickname - ) + ), + to: peerID ) return } @@ -278,11 +282,11 @@ final class ChatPrivateConversationCoordinator { let isMutualFavorite = favoriteStatus?.isMutual ?? false let hasNostrKey = favoriteStatus?.peerNostrPublicKey != nil - var recipientNickname = context.peerNickname(for: peerID) - if recipientNickname == nil && favoriteStatus != nil { - recipientNickname = favoriteStatus?.peerNickname - } - recipientNickname = recipientNickname ?? "user" + // "anon" matches the app's default-nickname convention; "user" is + // banned copy. + let recipientNickname = context.peerNickname(for: peerID) + ?? favoriteStatus?.peerNickname + ?? "anon" let messageID = UUID().uuidString let message = BitchatMessage( @@ -302,31 +306,25 @@ final class ChatPrivateConversationCoordinator { context.appendPrivateMessage(message, to: peerID) context.notifyUIChanged() + // Always hand the message to the router — it owns delivery. A live + // link sends now; an unreachable peer gets the retained-outbox path + // (resend on reconnect, courier deposits, bridge drops). Pre-judging + // reachability here used to mark the message failed without ever + // routing it, silently bypassing all of that (field-found: DMs + // composed after a peer's reachability window lapsed were dead on + // arrival while identical DMs sent a minute earlier delivered). + context.routePrivateMessage( + content, + to: peerID, + recipientNickname: recipientNickname, + messageID: messageID + ) if isConnected || isReachable || (isMutualFavorite && hasNostrKey) { - context.routePrivateMessage( - content, - to: peerID, - recipientNickname: recipientNickname ?? "user", - messageID: messageID - ) context.setPrivateDeliveryStatus(.sent, forMessageID: messageID, peerID: peerID) - } else { - context.setPrivateDeliveryStatus( - .failed( - reason: String(localized: "content.delivery.reason.unreachable", comment: "Failure reason when a peer is unreachable") - ), - forMessageID: messageID, - peerID: peerID - ) - let name = recipientNickname ?? "user" - context.addSystemMessage( - String( - format: String(localized: "system.dm.unreachable", comment: "System message when a recipient is unreachable"), - locale: .current, - name - ) - ) } + // Otherwise the message stays "sending"; router callbacks move it to + // carried (📦) when a courier/bridge copy ships, delivered/read on + // acks, or failed when the outbox TTL expires. } func sendGeohashDM(_ content: String, to peerID: PeerID) { @@ -372,8 +370,9 @@ final class ChatPrivateConversationCoordinator { forMessageID: messageID, peerID: peerID ) - context.addSystemMessage( - String(localized: "system.dm.blocked_generic", comment: "System message when sending fails because user is blocked") + context.addLocalPrivateSystemMessage( + String(localized: "system.dm.blocked_generic", comment: "System message when sending fails because the person is blocked"), + to: peerID ) return } diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index e9b84e64..082cfb63 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -398,16 +398,18 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele let readReceiptsDefaults: UserDefaults /// Default read-receipt persistence store. Production uses `.standard`. - /// Under test, a dedicated scratch suite is used instead — wiped at first - /// use per process — so back-to-back local test runs never see each - /// other's persisted receipts (and tests never pollute `.standard`). - static let defaultReadReceiptsDefaults: UserDefaults = { + /// Under test, every instance gets its own scratch suite: a per-process + /// shared suite let one test's persisted receipts leak into another + /// test's freshly constructed view model (surfaced as an order-dependent + /// CI flake on a duplicated message ID), and tests never pollute + /// `.standard`. + static func defaultReadReceiptsDefaults() -> UserDefaults { guard TestEnvironment.isRunningTests else { return .standard } - let suiteName = "chat.bitchat.tests.readReceipts" + let suiteName = "chat.bitchat.tests.readReceipts.\(UUID().uuidString)" guard let scratch = UserDefaults(suiteName: suiteName) else { return .standard } scratch.removePersistentDomain(forName: suiteName) return scratch - }() + } // Track sent read receipts to avoid duplicates (persisted across launches) // Note: Persistence happens automatically in didSet, no lifecycle observers needed @@ -809,7 +811,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele self.autocompleteService = services.autocompleteService self.deduplicationService = services.deduplicationService self.publicMessagePipeline = services.publicMessagePipeline - let readReceiptsDefaults = readReceiptsDefaults ?? Self.defaultReadReceiptsDefaults + let readReceiptsDefaults = readReceiptsDefaults ?? Self.defaultReadReceiptsDefaults() self.readReceiptsDefaults = readReceiptsDefaults self.sentReadReceipts = ChatViewModelBootstrapper.loadPersistedReadReceipts(userDefaults: readReceiptsDefaults) diff --git a/bitchatTests/BLEServiceCoreTests.swift b/bitchatTests/BLEServiceCoreTests.swift index 3da4a700..6f7d181b 100644 --- a/bitchatTests/BLEServiceCoreTests.swift +++ b/bitchatTests/BLEServiceCoreTests.swift @@ -32,7 +32,7 @@ struct BLEServiceCoreTests { ble._test_handlePacket(packet, fromPeerID: sender, signingPublicKey: signingKey) let receivedFirst = await TestHelpers.waitUntil( { delegate.publicMessagesSnapshot().count == 1 }, - timeout: TestConstants.defaultTimeout + timeout: TestConstants.longTimeout ) #expect(receivedFirst) @@ -188,7 +188,7 @@ struct BLEServiceCoreTests { let rebound = await TestHelpers.waitUntil( { ble._test_centralBinding(centralUUID) == newPeerID }, - timeout: TestConstants.defaultTimeout + timeout: TestConstants.longTimeout ) #expect(rebound) @@ -197,7 +197,7 @@ struct BLEServiceCoreTests { let peerIDs = ble.currentPeerSnapshots().map(\.peerID) return peerIDs.contains(newPeerID) && !peerIDs.contains(oldPeerID) }, - timeout: TestConstants.defaultTimeout + timeout: TestConstants.longTimeout ) #expect(retired) } @@ -374,7 +374,7 @@ struct BLEServiceCoreTests { let reachedBudget = await TestHelpers.waitUntil( { outbound.count(ofType: .pong) >= budget }, - timeout: TestConstants.defaultTimeout + timeout: TestConstants.longTimeout ) #expect(reachedBudget) // Give any over-budget pong a chance to surface, then confirm the diff --git a/bitchatTests/ChatLifecycleCoordinatorContextTests.swift b/bitchatTests/ChatLifecycleCoordinatorContextTests.swift index 88596f16..f8b0de66 100644 --- a/bitchatTests/ChatLifecycleCoordinatorContextTests.swift +++ b/bitchatTests/ChatLifecycleCoordinatorContextTests.swift @@ -328,7 +328,7 @@ struct ChatLifecycleCoordinatorContextTests { #expect(context.ownerLevelReadPasses == [peerID]) } @Test @MainActor - func markPrivateMessagesAsRead_routesReceiptsOnlyForNostrReachableFavorites() { + func markPrivateMessagesAsRead_routesReceiptsForFavoritesAndNonFavorites() { let context = MockChatLifecycleContext() let coordinator = ChatLifecycleCoordinator(context: context) let noiseKey = Data(repeating: 0xAB, count: 32) @@ -351,12 +351,15 @@ struct ChatLifecycleCoordinatorContextTests { #expect(context.routedReadReceipts.map(\.peerID) == [peerID]) #expect(context.sentReadReceipts.contains("in-1")) - // No favorite relationship (no Nostr key): the receipt pass is skipped. + // No favorite relationship: receipts still route — the router picks + // whatever transport can reach the peer (mesh included). Gating on a + // stored Nostr key silently starved mesh-connected non-favorites. let otherKey = Data(repeating: 0xCD, count: 32) let otherPeer = PeerID(hexData: otherKey) context.privateChats[otherPeer] = [makePrivateMessage(id: "in-2", senderPeerID: otherPeer)] coordinator.markPrivateMessagesAsRead(from: otherPeer) - #expect(context.routedReadReceipts.map(\.messageID) == ["in-1"]) + #expect(context.routedReadReceipts.map(\.messageID) == ["in-1", "in-2"]) + #expect(context.sentReadReceipts.contains("in-2")) } } diff --git a/bitchatTests/ChatPrivateConversationCoordinatorContextTests.swift b/bitchatTests/ChatPrivateConversationCoordinatorContextTests.swift index aed8f482..a92d1e9c 100644 --- a/bitchatTests/ChatPrivateConversationCoordinatorContextTests.swift +++ b/bitchatTests/ChatPrivateConversationCoordinatorContextTests.swift @@ -234,6 +234,12 @@ private final class MockChatPrivateConversationContext: ChatPrivateConversationC meshOnlySystemMessages.append(content) } + private(set) var privateSystemMessages: [(content: String, peerID: PeerID)] = [] + + func addLocalPrivateSystemMessage(_ content: String, to peerID: PeerID) { + privateSystemMessages.append((content, peerID)) + } + static let dummyIdentity = NostrIdentity( privateKey: Data(repeating: 0x11, count: 32), publicKey: Data(repeating: 0x22, count: 32), @@ -680,19 +686,21 @@ struct ChatPrivateConversationCoordinatorContextTests { #expect(context.systemMessages.isEmpty) } + /// Field-found: pre-judging reachability here marked the message failed + /// without ever routing it, so the router's retained outbox, courier + /// deposits, and bridge drops never got a chance. A fully unreachable + /// non-favorite must still be routed and stay "sending" (the router's + /// callbacks later move it to carried/delivered or expire it as failed). @Test @MainActor - func sendPrivateMessage_failsWhenOfflineWithoutMutualFavorite() async { + func sendPrivateMessage_routesAndStaysSendingWhenOfflineWithoutMutualFavorite() async { let context = MockChatPrivateConversationContext() let coordinator = ChatPrivateConversationCoordinator(context: context) let peerID = PeerID(hexData: Data(repeating: 0xCD, count: 32)) coordinator.sendPrivateMessage("hello?", to: peerID) - #expect(context.routedPrivateMessages.isEmpty) - #expect(context.systemMessages.count == 1) - guard case .failed = context.privateChats[peerID]?.first?.deliveryStatus else { - Issue.record("expected .failed delivery status") - return - } + #expect(context.routedPrivateMessages.map(\.content) == ["hello?"]) + #expect(context.privateChats[peerID]?.first?.deliveryStatus == .sending) + #expect(context.systemMessages.isEmpty) } } diff --git a/bitchatTests/ChatViewModelExtensionsTests.swift b/bitchatTests/ChatViewModelExtensionsTests.swift index 031247bf..dc0bdaa4 100644 --- a/bitchatTests/ChatViewModelExtensionsTests.swift +++ b/bitchatTests/ChatViewModelExtensionsTests.swift @@ -71,8 +71,11 @@ struct ChatViewModelPrivateChatExtensionTests { // Check MockTransport implementation... it might need update or verification } + /// An unreachable recipient no longer means instant failure: the message + /// is routed anyway so the router's outbox/courier/bridge machinery can + /// deliver it, and it stays "sending" until a router callback resolves it. @Test @MainActor - func sendPrivateMessage_unreachable_setsFailedStatus() async { + func sendPrivateMessage_unreachable_staysSendingForStoreAndForward() async { let (viewModel, _) = makeTestableViewModel() let validHex = "0102030405060708090a0b0c0d0e0f100102030405060708090a0b0c0d0e0f10" let peerID = PeerID(str: validHex) @@ -80,11 +83,7 @@ struct ChatViewModelPrivateChatExtensionTests { viewModel.sendPrivateMessage("Hello", to: peerID) #expect(viewModel.privateChats[peerID]?.count == 1) - let status = viewModel.privateChats[peerID]?.last?.deliveryStatus - #expect({ - if case .failed = status { return true } - return false - }()) + #expect(viewModel.privateChats[peerID]?.last?.deliveryStatus == .sending) } @Test @MainActor @@ -780,8 +779,10 @@ struct ChatViewModelGeoDMTests { #expect(isFailed(status: viewModel.privateChats[convKey]?.last?.deliveryStatus)) } + /// The blocked notice belongs in the DM thread the person is typing in, + /// not on the active location-channel timeline. @Test @MainActor - func sendGeohashDM_blockedRecipient_marksFailedAndAddsSystemMessage() async { + func sendGeohashDM_blockedRecipient_marksFailedAndAddsSystemMessageInThread() async { let (viewModel, _) = makeTestableViewModel() let geohash = "u4pruydq" let recipientHex = "0000000000000000000000000000000000000000000000000000000000000003" @@ -793,9 +794,11 @@ struct ChatViewModelGeoDMTests { viewModel.sendGeohashDM("hello", to: convKey) - #expect(viewModel.privateChats[convKey]?.count == 1) - #expect(isFailed(status: viewModel.privateChats[convKey]?.last?.deliveryStatus)) - #expect(viewModel.messages.contains(where: { $0.sender == "system" })) + let thread = viewModel.privateChats[convKey] ?? [] + #expect(thread.count == 2) + #expect(isFailed(status: thread.first?.deliveryStatus)) + #expect(thread.last?.sender == "system") + #expect(!viewModel.messages.contains(where: { $0.sender == "system" })) } @Test @MainActor