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