mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 02:25:20 +00:00
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:
co-authored by
jack
Claude Fable 5
parent
dc99d641c8
commit
55d51ba084
@@ -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
@@ -27498,7 +27498,7 @@
|
|||||||
"es" : {
|
"es" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value" : "desbloquear a un usuario"
|
"value" : "desbloquear a una persona"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"fil" : {
|
"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" : {
|
"content.delivery.reason.voice_send_failed" : {
|
||||||
"comment" : "Failure reason shown when a voice note could not be sent",
|
"comment" : "Failure reason shown when a voice note could not be sent",
|
||||||
"extractionState" : "manual",
|
"extractionState" : "manual",
|
||||||
@@ -58664,7 +58485,7 @@
|
|||||||
"es" : {
|
"es" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"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" : {
|
"fil" : {
|
||||||
@@ -58676,7 +58497,7 @@
|
|||||||
"fr" : {
|
"fr" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"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" : {
|
"he" : {
|
||||||
@@ -58700,7 +58521,7 @@
|
|||||||
"it" : {
|
"it" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value" : "impossibile avviare una chat con %@: utente bloccato."
|
"value" : "impossibile avviare una chat con %@: questa persona è bloccata."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ja" : {
|
"ja" : {
|
||||||
@@ -58748,7 +58569,7 @@
|
|||||||
"pt-BR" : {
|
"pt-BR" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"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" : {
|
"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" : {
|
"system.dm.blocked_generic" : {
|
||||||
"extractionState" : "manual",
|
"extractionState" : "manual",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -59022,7 +58664,7 @@
|
|||||||
"es" : {
|
"es" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"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" : {
|
"fil" : {
|
||||||
@@ -59034,7 +58676,7 @@
|
|||||||
"fr" : {
|
"fr" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value" : "envoi impossible : utilisateur bloqué."
|
"value" : "envoi impossible : cette personne est bloquée."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"he" : {
|
"he" : {
|
||||||
@@ -59058,7 +58700,7 @@
|
|||||||
"it" : {
|
"it" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value" : "invio non riuscito: utente bloccato."
|
"value" : "invio non riuscito: questa persona è bloccata."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ja" : {
|
"ja" : {
|
||||||
@@ -59106,7 +58748,7 @@
|
|||||||
"pt-BR" : {
|
"pt-BR" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value" : "não foi possível enviar: usuário bloqueado."
|
"value" : "não foi possível enviar: a pessoa está bloqueada."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ru" : {
|
"ru" : {
|
||||||
@@ -59201,7 +58843,7 @@
|
|||||||
"es" : {
|
"es" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"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" : {
|
"fil" : {
|
||||||
@@ -59213,7 +58855,7 @@
|
|||||||
"fr" : {
|
"fr" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value" : "impossible d'envoyer à %@ : utilisateur bloqué."
|
"value" : "impossible d'envoyer à %@ : cette personne est bloquée."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"he" : {
|
"he" : {
|
||||||
@@ -59237,7 +58879,7 @@
|
|||||||
"it" : {
|
"it" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value" : "impossibile inviare a %@: utente bloccato."
|
"value" : "impossibile inviare a %@: questa persona è bloccata."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ja" : {
|
"ja" : {
|
||||||
@@ -59285,7 +58927,7 @@
|
|||||||
"pt-BR" : {
|
"pt-BR" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"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" : {
|
"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" : {
|
"system.gateway.sent_via_mesh" : {
|
||||||
"comment" : "System message when a geohash message was handed to a mesh internet gateway because no relay is reachable",
|
"comment" : "System message when a geohash message was handed to a mesh internet gateway because no relay is reachable",
|
||||||
"extractionState" : "manual",
|
"extractionState" : "manual",
|
||||||
|
|||||||
@@ -22,6 +22,11 @@ import Foundation
|
|||||||
/// prekey-seal for recipients met long ago. Included in the panic wipe.
|
/// prekey-seal for recipients met long ago. Included in the panic wipe.
|
||||||
final class PrekeyBundleStore {
|
final class PrekeyBundleStore {
|
||||||
struct StoredBundle: Codable {
|
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
|
let noiseKey: Data
|
||||||
var generatedAt: UInt64
|
var generatedAt: UInt64
|
||||||
var prekeyIDs: [UInt32]
|
var prekeyIDs: [UInt32]
|
||||||
|
|||||||
@@ -336,7 +336,12 @@ final class ChatGroupCoordinator {
|
|||||||
guard !content.isEmpty, content.count <= InputValidator.Limits.maxMessageLength else { return }
|
guard !content.isEmpty, content.count <= InputValidator.Limits.maxMessageLength else { return }
|
||||||
guard let group = context.groupStore.group(for: groupPeerID),
|
guard let group = context.groupStore.group(for: groupPeerID),
|
||||||
let key = context.groupStore.key(forGroupID: group.groupID) else {
|
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
|
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) {
|
for message in getPrivateChatMessages(for: peerID) {
|
||||||
guard (message.senderPeerID == peerID || message.senderPeerID == noiseKeyHex) && !message.isRelay else {
|
guard (message.senderPeerID == peerID || message.senderPeerID == noiseKeyHex) && !message.isRelay else {
|
||||||
|
|||||||
@@ -347,20 +347,10 @@ final class ChatPeerIdentityCoordinator {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if let peer = context.unifiedPeer(for: peerID),
|
// No mutual-favorite gate: store-and-forward (couriers, bridge drops,
|
||||||
peer.isFavorite && !peer.theyFavoritedUs && !peer.isConnected {
|
// retained outbox) only needs the recipient's noise key, so an
|
||||||
context.addSystemMessage(
|
// offline non-mutual favorite is still worth writing to — the router
|
||||||
String(
|
// decides what delivery looks like, not chat entry.
|
||||||
format: String(
|
|
||||||
localized: "system.chat.requires_favorite",
|
|
||||||
comment: "System message when mutual favorite requirement blocks chat"
|
|
||||||
),
|
|
||||||
locale: .current,
|
|
||||||
peerNickname
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = context.consolidatePrivateMessages(for: peerID, peerNickname: peerNickname)
|
_ = context.consolidatePrivateMessages(for: peerID, peerNickname: peerNickname)
|
||||||
|
|
||||||
@@ -553,7 +543,8 @@ final class ChatPeerIdentityCoordinator {
|
|||||||
!favorite.peerNickname.isEmpty {
|
!favorite.peerNickname.isEmpty {
|
||||||
return favorite.peerNickname
|
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
|
// MARK: System messages
|
||||||
func addSystemMessage(_ content: String)
|
func addSystemMessage(_ content: String)
|
||||||
func addMeshOnlySystemMessage(_ 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
|
// MARK: Favorites & notifications
|
||||||
/// The persisted favorite relationship for the peer's Noise static key, if any.
|
/// 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 }
|
guard !content.isEmpty else { return }
|
||||||
|
|
||||||
if context.isPeerBlocked(peerID) {
|
if context.isPeerBlocked(peerID) {
|
||||||
let nickname = context.peerNickname(for: peerID) ?? "user"
|
let nickname = context.peerNickname(for: peerID) ?? "anon"
|
||||||
context.addSystemMessage(
|
context.addLocalPrivateSystemMessage(
|
||||||
String(
|
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,
|
locale: .current,
|
||||||
nickname
|
nickname
|
||||||
)
|
),
|
||||||
|
to: peerID
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -278,11 +282,11 @@ final class ChatPrivateConversationCoordinator {
|
|||||||
let isMutualFavorite = favoriteStatus?.isMutual ?? false
|
let isMutualFavorite = favoriteStatus?.isMutual ?? false
|
||||||
let hasNostrKey = favoriteStatus?.peerNostrPublicKey != nil
|
let hasNostrKey = favoriteStatus?.peerNostrPublicKey != nil
|
||||||
|
|
||||||
var recipientNickname = context.peerNickname(for: peerID)
|
// "anon" matches the app's default-nickname convention; "user" is
|
||||||
if recipientNickname == nil && favoriteStatus != nil {
|
// banned copy.
|
||||||
recipientNickname = favoriteStatus?.peerNickname
|
let recipientNickname = context.peerNickname(for: peerID)
|
||||||
}
|
?? favoriteStatus?.peerNickname
|
||||||
recipientNickname = recipientNickname ?? "user"
|
?? "anon"
|
||||||
|
|
||||||
let messageID = UUID().uuidString
|
let messageID = UUID().uuidString
|
||||||
let message = BitchatMessage(
|
let message = BitchatMessage(
|
||||||
@@ -302,31 +306,25 @@ final class ChatPrivateConversationCoordinator {
|
|||||||
context.appendPrivateMessage(message, to: peerID)
|
context.appendPrivateMessage(message, to: peerID)
|
||||||
context.notifyUIChanged()
|
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) {
|
if isConnected || isReachable || (isMutualFavorite && hasNostrKey) {
|
||||||
context.routePrivateMessage(
|
|
||||||
content,
|
|
||||||
to: peerID,
|
|
||||||
recipientNickname: recipientNickname ?? "user",
|
|
||||||
messageID: messageID
|
|
||||||
)
|
|
||||||
context.setPrivateDeliveryStatus(.sent, forMessageID: messageID, peerID: peerID)
|
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) {
|
func sendGeohashDM(_ content: String, to peerID: PeerID) {
|
||||||
@@ -372,8 +370,9 @@ final class ChatPrivateConversationCoordinator {
|
|||||||
forMessageID: messageID,
|
forMessageID: messageID,
|
||||||
peerID: peerID
|
peerID: peerID
|
||||||
)
|
)
|
||||||
context.addSystemMessage(
|
context.addLocalPrivateSystemMessage(
|
||||||
String(localized: "system.dm.blocked_generic", comment: "System message when sending fails because user is blocked")
|
String(localized: "system.dm.blocked_generic", comment: "System message when sending fails because the person is blocked"),
|
||||||
|
to: peerID
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -398,16 +398,18 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
let readReceiptsDefaults: UserDefaults
|
let readReceiptsDefaults: UserDefaults
|
||||||
|
|
||||||
/// Default read-receipt persistence store. Production uses `.standard`.
|
/// Default read-receipt persistence store. Production uses `.standard`.
|
||||||
/// Under test, a dedicated scratch suite is used instead — wiped at first
|
/// Under test, every instance gets its own scratch suite: a per-process
|
||||||
/// use per process — so back-to-back local test runs never see each
|
/// shared suite let one test's persisted receipts leak into another
|
||||||
/// other's persisted receipts (and tests never pollute `.standard`).
|
/// test's freshly constructed view model (surfaced as an order-dependent
|
||||||
static let defaultReadReceiptsDefaults: UserDefaults = {
|
/// CI flake on a duplicated message ID), and tests never pollute
|
||||||
|
/// `.standard`.
|
||||||
|
static func defaultReadReceiptsDefaults() -> UserDefaults {
|
||||||
guard TestEnvironment.isRunningTests else { return .standard }
|
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 }
|
guard let scratch = UserDefaults(suiteName: suiteName) else { return .standard }
|
||||||
scratch.removePersistentDomain(forName: suiteName)
|
scratch.removePersistentDomain(forName: suiteName)
|
||||||
return scratch
|
return scratch
|
||||||
}()
|
}
|
||||||
|
|
||||||
// Track sent read receipts to avoid duplicates (persisted across launches)
|
// Track sent read receipts to avoid duplicates (persisted across launches)
|
||||||
// Note: Persistence happens automatically in didSet, no lifecycle observers needed
|
// 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.autocompleteService = services.autocompleteService
|
||||||
self.deduplicationService = services.deduplicationService
|
self.deduplicationService = services.deduplicationService
|
||||||
self.publicMessagePipeline = services.publicMessagePipeline
|
self.publicMessagePipeline = services.publicMessagePipeline
|
||||||
let readReceiptsDefaults = readReceiptsDefaults ?? Self.defaultReadReceiptsDefaults
|
let readReceiptsDefaults = readReceiptsDefaults ?? Self.defaultReadReceiptsDefaults()
|
||||||
self.readReceiptsDefaults = readReceiptsDefaults
|
self.readReceiptsDefaults = readReceiptsDefaults
|
||||||
self.sentReadReceipts = ChatViewModelBootstrapper.loadPersistedReadReceipts(userDefaults: readReceiptsDefaults)
|
self.sentReadReceipts = ChatViewModelBootstrapper.loadPersistedReadReceipts(userDefaults: readReceiptsDefaults)
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ struct BLEServiceCoreTests {
|
|||||||
ble._test_handlePacket(packet, fromPeerID: sender, signingPublicKey: signingKey)
|
ble._test_handlePacket(packet, fromPeerID: sender, signingPublicKey: signingKey)
|
||||||
let receivedFirst = await TestHelpers.waitUntil(
|
let receivedFirst = await TestHelpers.waitUntil(
|
||||||
{ delegate.publicMessagesSnapshot().count == 1 },
|
{ delegate.publicMessagesSnapshot().count == 1 },
|
||||||
timeout: TestConstants.defaultTimeout
|
timeout: TestConstants.longTimeout
|
||||||
)
|
)
|
||||||
#expect(receivedFirst)
|
#expect(receivedFirst)
|
||||||
|
|
||||||
@@ -188,7 +188,7 @@ struct BLEServiceCoreTests {
|
|||||||
|
|
||||||
let rebound = await TestHelpers.waitUntil(
|
let rebound = await TestHelpers.waitUntil(
|
||||||
{ ble._test_centralBinding(centralUUID) == newPeerID },
|
{ ble._test_centralBinding(centralUUID) == newPeerID },
|
||||||
timeout: TestConstants.defaultTimeout
|
timeout: TestConstants.longTimeout
|
||||||
)
|
)
|
||||||
#expect(rebound)
|
#expect(rebound)
|
||||||
|
|
||||||
@@ -197,7 +197,7 @@ struct BLEServiceCoreTests {
|
|||||||
let peerIDs = ble.currentPeerSnapshots().map(\.peerID)
|
let peerIDs = ble.currentPeerSnapshots().map(\.peerID)
|
||||||
return peerIDs.contains(newPeerID) && !peerIDs.contains(oldPeerID)
|
return peerIDs.contains(newPeerID) && !peerIDs.contains(oldPeerID)
|
||||||
},
|
},
|
||||||
timeout: TestConstants.defaultTimeout
|
timeout: TestConstants.longTimeout
|
||||||
)
|
)
|
||||||
#expect(retired)
|
#expect(retired)
|
||||||
}
|
}
|
||||||
@@ -374,7 +374,7 @@ struct BLEServiceCoreTests {
|
|||||||
|
|
||||||
let reachedBudget = await TestHelpers.waitUntil(
|
let reachedBudget = await TestHelpers.waitUntil(
|
||||||
{ outbound.count(ofType: .pong) >= budget },
|
{ outbound.count(ofType: .pong) >= budget },
|
||||||
timeout: TestConstants.defaultTimeout
|
timeout: TestConstants.longTimeout
|
||||||
)
|
)
|
||||||
#expect(reachedBudget)
|
#expect(reachedBudget)
|
||||||
// Give any over-budget pong a chance to surface, then confirm the
|
// Give any over-budget pong a chance to surface, then confirm the
|
||||||
|
|||||||
@@ -328,7 +328,7 @@ struct ChatLifecycleCoordinatorContextTests {
|
|||||||
#expect(context.ownerLevelReadPasses == [peerID])
|
#expect(context.ownerLevelReadPasses == [peerID])
|
||||||
}
|
}
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
func markPrivateMessagesAsRead_routesReceiptsOnlyForNostrReachableFavorites() {
|
func markPrivateMessagesAsRead_routesReceiptsForFavoritesAndNonFavorites() {
|
||||||
let context = MockChatLifecycleContext()
|
let context = MockChatLifecycleContext()
|
||||||
let coordinator = ChatLifecycleCoordinator(context: context)
|
let coordinator = ChatLifecycleCoordinator(context: context)
|
||||||
let noiseKey = Data(repeating: 0xAB, count: 32)
|
let noiseKey = Data(repeating: 0xAB, count: 32)
|
||||||
@@ -351,12 +351,15 @@ struct ChatLifecycleCoordinatorContextTests {
|
|||||||
#expect(context.routedReadReceipts.map(\.peerID) == [peerID])
|
#expect(context.routedReadReceipts.map(\.peerID) == [peerID])
|
||||||
#expect(context.sentReadReceipts.contains("in-1"))
|
#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 otherKey = Data(repeating: 0xCD, count: 32)
|
||||||
let otherPeer = PeerID(hexData: otherKey)
|
let otherPeer = PeerID(hexData: otherKey)
|
||||||
context.privateChats[otherPeer] = [makePrivateMessage(id: "in-2", senderPeerID: otherPeer)]
|
context.privateChats[otherPeer] = [makePrivateMessage(id: "in-2", senderPeerID: otherPeer)]
|
||||||
coordinator.markPrivateMessagesAsRead(from: 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)
|
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(
|
static let dummyIdentity = NostrIdentity(
|
||||||
privateKey: Data(repeating: 0x11, count: 32),
|
privateKey: Data(repeating: 0x11, count: 32),
|
||||||
publicKey: Data(repeating: 0x22, count: 32),
|
publicKey: Data(repeating: 0x22, count: 32),
|
||||||
@@ -680,19 +686,21 @@ struct ChatPrivateConversationCoordinatorContextTests {
|
|||||||
#expect(context.systemMessages.isEmpty)
|
#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
|
@Test @MainActor
|
||||||
func sendPrivateMessage_failsWhenOfflineWithoutMutualFavorite() async {
|
func sendPrivateMessage_routesAndStaysSendingWhenOfflineWithoutMutualFavorite() async {
|
||||||
let context = MockChatPrivateConversationContext()
|
let context = MockChatPrivateConversationContext()
|
||||||
let coordinator = ChatPrivateConversationCoordinator(context: context)
|
let coordinator = ChatPrivateConversationCoordinator(context: context)
|
||||||
let peerID = PeerID(hexData: Data(repeating: 0xCD, count: 32))
|
let peerID = PeerID(hexData: Data(repeating: 0xCD, count: 32))
|
||||||
|
|
||||||
coordinator.sendPrivateMessage("hello?", to: peerID)
|
coordinator.sendPrivateMessage("hello?", to: peerID)
|
||||||
|
|
||||||
#expect(context.routedPrivateMessages.isEmpty)
|
#expect(context.routedPrivateMessages.map(\.content) == ["hello?"])
|
||||||
#expect(context.systemMessages.count == 1)
|
#expect(context.privateChats[peerID]?.first?.deliveryStatus == .sending)
|
||||||
guard case .failed = context.privateChats[peerID]?.first?.deliveryStatus else {
|
#expect(context.systemMessages.isEmpty)
|
||||||
Issue.record("expected .failed delivery status")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,8 +71,11 @@ struct ChatViewModelPrivateChatExtensionTests {
|
|||||||
// Check MockTransport implementation... it might need update or verification
|
// 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
|
@Test @MainActor
|
||||||
func sendPrivateMessage_unreachable_setsFailedStatus() async {
|
func sendPrivateMessage_unreachable_staysSendingForStoreAndForward() async {
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
let (viewModel, _) = makeTestableViewModel()
|
||||||
let validHex = "0102030405060708090a0b0c0d0e0f100102030405060708090a0b0c0d0e0f10"
|
let validHex = "0102030405060708090a0b0c0d0e0f100102030405060708090a0b0c0d0e0f10"
|
||||||
let peerID = PeerID(str: validHex)
|
let peerID = PeerID(str: validHex)
|
||||||
@@ -80,11 +83,7 @@ struct ChatViewModelPrivateChatExtensionTests {
|
|||||||
viewModel.sendPrivateMessage("Hello", to: peerID)
|
viewModel.sendPrivateMessage("Hello", to: peerID)
|
||||||
|
|
||||||
#expect(viewModel.privateChats[peerID]?.count == 1)
|
#expect(viewModel.privateChats[peerID]?.count == 1)
|
||||||
let status = viewModel.privateChats[peerID]?.last?.deliveryStatus
|
#expect(viewModel.privateChats[peerID]?.last?.deliveryStatus == .sending)
|
||||||
#expect({
|
|
||||||
if case .failed = status { return true }
|
|
||||||
return false
|
|
||||||
}())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
@@ -780,8 +779,10 @@ struct ChatViewModelGeoDMTests {
|
|||||||
#expect(isFailed(status: viewModel.privateChats[convKey]?.last?.deliveryStatus))
|
#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
|
@Test @MainActor
|
||||||
func sendGeohashDM_blockedRecipient_marksFailedAndAddsSystemMessage() async {
|
func sendGeohashDM_blockedRecipient_marksFailedAndAddsSystemMessageInThread() async {
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
let (viewModel, _) = makeTestableViewModel()
|
||||||
let geohash = "u4pruydq"
|
let geohash = "u4pruydq"
|
||||||
let recipientHex = "0000000000000000000000000000000000000000000000000000000000000003"
|
let recipientHex = "0000000000000000000000000000000000000000000000000000000000000003"
|
||||||
@@ -793,9 +794,11 @@ struct ChatViewModelGeoDMTests {
|
|||||||
|
|
||||||
viewModel.sendGeohashDM("hello", to: convKey)
|
viewModel.sendGeohashDM("hello", to: convKey)
|
||||||
|
|
||||||
#expect(viewModel.privateChats[convKey]?.count == 1)
|
let thread = viewModel.privateChats[convKey] ?? []
|
||||||
#expect(isFailed(status: viewModel.privateChats[convKey]?.last?.deliveryStatus))
|
#expect(thread.count == 2)
|
||||||
#expect(viewModel.messages.contains(where: { $0.sender == "system" }))
|
#expect(isFailed(status: thread.first?.deliveryStatus))
|
||||||
|
#expect(thread.last?.sender == "system")
|
||||||
|
#expect(!viewModel.messages.contains(where: { $0.sender == "system" }))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
|
|||||||
Reference in New Issue
Block a user