fix: route favorite notifications through the MessageRouter outbox

A favorite toggle sent while the peer was offline or mid-handshake was
silently dropped — for mesh-only peers a missed npub exchange. Build the
same [FAVORITED]:<npub> payload the transports built internally and route
it through sendPrivate, so the outbox retains it until an ack and the
alias flush drains it on reconnect/authentication under either PeerID
form. Wire format unchanged: receivers already parse the prefix from PM
content.

Co-Authored-By: goose <goose@block.xyz>
This commit is contained in:
Vincenzo Palazzo 2026-07-27 02:27:36 +02:00
parent a4502046ff
commit 265028c712
3 changed files with 43 additions and 8 deletions

View File

@ -48,6 +48,9 @@ final class MessageRouter {
private let courierDirectory: CourierDirectory
private let outboxStore: MessageOutboxStore?
private let metrics: StoreAndForwardMetrics?
// Stamps our npub onto favorite notifications so the recipient can learn
// our Nostr identity. Optional so tests can omit it.
private let idBridge: NostrIdentityBridge?
/// Invoked whenever a retained private message is dropped without a
/// delivery ack (attempt cap, TTL expiry, or per-peer overflow eviction)
@ -141,9 +144,11 @@ final class MessageRouter {
now: @escaping () -> Date = Date.init,
courierDirectory: CourierDirectory? = nil,
outboxStore: MessageOutboxStore? = nil,
metrics: StoreAndForwardMetrics? = nil
metrics: StoreAndForwardMetrics? = nil,
idBridge: NostrIdentityBridge? = nil
) {
self.transports = transports
self.idBridge = idBridge
self.now = now
self.courierDirectory = courierDirectory ?? .favoritesBacked()
self.outboxStore = outboxStore
@ -572,11 +577,19 @@ final class MessageRouter {
}
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
if let transport = connectedTransport(for: peerID) {
transport.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
} else if let transport = reachableTransport(for: peerID) {
transport.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
// Route favorites through the outbox instead of fire-and-forget: a
// toggle sent while the peer is offline or mid-handshake used to be
// silently dropped, which for mesh-only peers meant a missed npub
// exchange. The outbox retains until an ack, and the alias flush
// drains the queue on reconnect/authentication no matter which PeerID
// form the toggle was keyed under. The payload is the same
// [FAVORITED]:<npub> content the transports built internally the
// receiver parses the prefix from PM content and never displays it.
var content = isFavorite ? "[FAVORITED]" : "[UNFAVORITED]"
if let identity = try? idBridge?.getCurrentNostrIdentity() {
content += ":" + identity.npub
}
sendPrivate(content, to: peerID, recipientNickname: "", messageID: UUID().uuidString)
}
/// Retries only messages that the router previously transmitted through

View File

@ -33,7 +33,8 @@ struct ChatViewModelServiceBundle {
let messageRouter = MessageRouter(
transports: [meshService, nostrTransport],
outboxStore: outboxStore,
metrics: sfMetrics
metrics: sfMetrics,
idBridge: idBridge
)
self.commandProcessor = commandProcessor

View File

@ -653,7 +653,7 @@ struct MessageRouterTests {
}
@Test @MainActor
func sendFavoriteNotification_usesConnectedOrReachable() async {
func sendFavoriteNotification_routesThroughOutboxAsPrivateMessage() async {
let peerID = PeerID(str: "0000000000000004")
let transport = MockTransport()
transport.reachablePeers.insert(peerID)
@ -661,7 +661,28 @@ struct MessageRouterTests {
let router = MessageRouter(transports: [transport])
router.sendFavoriteNotification(to: peerID, isFavorite: true)
#expect(transport.sentFavoriteNotifications.count == 1)
// Favorites ride the outbox (sendPrivate) so they survive offline /
// handshake gaps; the recipient parses the [FAVORITED] prefix.
#expect(transport.sentFavoriteNotifications.isEmpty)
#expect(transport.sentPrivateMessages.count == 1)
#expect(transport.sentPrivateMessages.first?.content.hasPrefix("[FAVORITED]") == true)
}
@Test @MainActor
func sendFavoriteNotification_whileOffline_queuesAndFlushesOnReconnect() async {
let peerID = PeerID(str: "0000000000000009")
let transport = MockTransport()
// Peer is neither connected nor reachable: the favorite must be retained.
let router = MessageRouter(transports: [transport])
router.sendFavoriteNotification(to: peerID, isFavorite: false)
#expect(transport.sentPrivateMessages.isEmpty)
// Peer comes back: the queued favorite flushes.
transport.connectedPeers.insert(peerID)
router.flushOutbox(for: peerID)
#expect(transport.sentPrivateMessages.count == 1)
#expect(transport.sentPrivateMessages.first?.content.hasPrefix("[UNFAVORITED]") == true)
}
// MARK: - Courier deposits