DMs to unreachable peers use store-and-forward instead of failing instantly (#1415)

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jack
2026-07-08 22:42:39 +02:00
committed by GitHub
co-authored by jack Claude Fable 5
parent dc99d641c8
commit 55d51ba084
12 changed files with 114 additions and 632 deletions
+1 -1
View File
@@ -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"]}}
{"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"]}}
+13 -550
View File
@@ -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",
@@ -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]
@@ -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
}
@@ -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 {
@@ -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"
}
}
@@ -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
}
+9 -7
View File
@@ -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)
+4 -4
View File
@@ -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
@@ -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"))
}
}
@@ -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)
}
}
+13 -10
View File
@@ -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