diff --git a/bitchat/Services/MessageRouter.swift b/bitchat/Services/MessageRouter.swift index 2837a6ea..234902d0 100644 --- a/bitchat/Services/MessageRouter.swift +++ b/bitchat/Services/MessageRouter.swift @@ -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]: 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 diff --git a/bitchat/ViewModels/ChatViewModelBootstrapper.swift b/bitchat/ViewModels/ChatViewModelBootstrapper.swift index 99ca2886..3e2c7c78 100644 --- a/bitchat/ViewModels/ChatViewModelBootstrapper.swift +++ b/bitchat/ViewModels/ChatViewModelBootstrapper.swift @@ -33,7 +33,8 @@ struct ChatViewModelServiceBundle { let messageRouter = MessageRouter( transports: [meshService, nostrTransport], outboxStore: outboxStore, - metrics: sfMetrics + metrics: sfMetrics, + idBridge: idBridge ) self.commandProcessor = commandProcessor diff --git a/bitchatTests/Services/MessageRouterTests.swift b/bitchatTests/Services/MessageRouterTests.swift index 497040eb..b0e1d868 100644 --- a/bitchatTests/Services/MessageRouterTests.swift +++ b/bitchatTests/Services/MessageRouterTests.swift @@ -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