Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 38 additions & 59 deletions bitchat/Services/MessageRouter.swift
Original file line number Diff line number Diff line change
@@ -1,17 +1,14 @@
import BitLogger
import Foundation

/// Routes messages between BLE and Nostr transports
/// Routes messages using available transports (Mesh, Nostr, etc.)
@MainActor
final class MessageRouter {
private let mesh: Transport
private let nostr: NostrTransport
private let transports: [Transport]
private var outbox: [PeerID: [(content: String, nickname: String, messageID: String)]] = [:] // peerID -> queued messages

init(mesh: Transport, nostr: NostrTransport) {
self.mesh = mesh
self.nostr = nostr
self.nostr.senderPeerID = mesh.myPeerID
init(transports: [Transport]) {
self.transports = transports

// Observe favorites changes to learn Nostr mapping and flush queued messages
NotificationCenter.default.addObserver(
Expand All @@ -38,88 +35,70 @@ final class MessageRouter {
}

func sendPrivate(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
let reachableMesh = mesh.isPeerReachable(peerID)
if reachableMesh {
SecureLogger.debug("Routing PM via mesh (reachable) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
// BLEService will initiate a handshake if needed and queue the message
mesh.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
} else if canSendViaNostr(peerID: peerID) {
SecureLogger.debug("Routing PM via Nostr to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
nostr.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
// Try to find a reachable transport
if let transport = transports.first(where: { $0.isPeerReachable(peerID) }) {
SecureLogger.debug("Routing PM via \(type(of: transport)) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
} else {
// Queue for later (when mesh connects or Nostr mapping appears)
// Queue for later
if outbox[peerID] == nil { outbox[peerID] = [] }
outbox[peerID]?.append((content, recipientNickname, messageID))
SecureLogger.debug("Queued PM for \(peerID.id.prefix(8))… (no mesh, no Nostr mapping) id=\(messageID.prefix(8))…", category: .session)
SecureLogger.debug("Queued PM for \(peerID.id.prefix(8))… (no reachable transport) id=\(messageID.prefix(8))…", category: .session)
}
}

func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
// Prefer mesh for reachable peers; BLE will queue if handshake is needed
if mesh.isPeerReachable(peerID) {
SecureLogger.debug("Routing READ ack via mesh (reachable) to \(peerID.id.prefix(8))… id=\(receipt.originalMessageID.prefix(8))…", category: .session)
mesh.sendReadReceipt(receipt, to: peerID)
} else {
SecureLogger.debug("Routing READ ack via Nostr to \(peerID.id.prefix(8))… id=\(receipt.originalMessageID.prefix(8))…", category: .session)
nostr.sendReadReceipt(receipt, to: peerID)
if let transport = transports.first(where: { $0.isPeerReachable(peerID) }) {
SecureLogger.debug("Routing READ ack via \(type(of: transport)) to \(peerID.id.prefix(8))… id=\(receipt.originalMessageID.prefix(8))…", category: .session)
transport.sendReadReceipt(receipt, to: peerID)
} else if !transports.isEmpty {
// Fallback to last transport (usually Nostr) if neither is explicitly reachable?
// Or better: just try the first one that supports it?
// Existing logic preferred mesh, then nostr.
// If neither reachable, existing logic queued it (via mesh usually) or sent via nostr.
// Let's stick to "try reachable". If none, maybe pick the first one to queue?
// Actually, for READ receipts, we might want to just fire-and-forget on the "best effort" transport.
// But let's stick to the reachable check.
SecureLogger.debug("No reachable transport for READ ack to \(peerID.id.prefix(8))…", category: .session)
}
}

func sendDeliveryAck(_ messageID: String, to peerID: PeerID) {
if mesh.isPeerReachable(peerID) {
SecureLogger.debug("Routing DELIVERED ack via mesh (reachable) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
mesh.sendDeliveryAck(for: messageID, to: peerID)
} else {
nostr.sendDeliveryAck(for: messageID, to: peerID)
if let transport = transports.first(where: { $0.isPeerReachable(peerID) }) {
SecureLogger.debug("Routing DELIVERED ack via \(type(of: transport)) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
transport.sendDeliveryAck(for: messageID, to: peerID)
}
}

func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
// Route via mesh when connected; else use Nostr
if mesh.isPeerConnected(peerID) {
mesh.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
if let transport = transports.first(where: { $0.isPeerConnected(peerID) }) {
transport.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
} else if let transport = transports.first(where: { $0.isPeerReachable(peerID) }) {
transport.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
} else {
nostr.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
// Fallback: try all? or just the last one?
// Old logic: if mesh connected, mesh. Else nostr.
// Note: NostrTransport.isPeerReachable now returns true if mapped.
// If not mapped, we can't send via Nostr anyway.
}
}

// MARK: - Outbox Management
private func canSendViaNostr(peerID: PeerID) -> Bool {
// Two forms are supported:
// - 64-hex Noise public key (32 bytes)
// - 16-hex short peer ID (derived from Noise pubkey)
if let noiseKey = peerID.noiseKey {
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
fav.peerNostrPublicKey != nil {
return true
}
} else if peerID.isShort {
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID),
fav.peerNostrPublicKey != nil {
return true
}
}
return false
}

func flushOutbox(for peerID: PeerID) {
guard let queued = outbox[peerID], !queued.isEmpty else { return }
SecureLogger.debug("Flushing outbox for \(peerID.id.prefix(8))… count=\(queued.count)", category: .session)
var remaining: [(content: String, nickname: String, messageID: String)] = []
// Prefer mesh if connected; else try Nostr if mapping exists

for (content, nickname, messageID) in queued {
if mesh.isPeerReachable(peerID) {
SecureLogger.debug("Outbox -> mesh for \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
mesh.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID)
} else if canSendViaNostr(peerID: peerID) {
SecureLogger.debug("Outbox -> Nostr for \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
nostr.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID)
if let transport = transports.first(where: { $0.isPeerReachable(peerID) }) {
SecureLogger.debug("Outbox -> \(type(of: transport)) for \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
transport.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID)
} else {
// Keep unsent items queued
remaining.append((content, nickname, messageID))
}
Comment on lines +94 to 99

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Outbox never flushes to Nostr after startup cache race

MessageRouter now delivers queued messages only when a transport reports reachability (transports.first(where: { $0.isPeerReachable(peerID) })). However NostrTransport.isPeerReachable is fed from the asynchronously warmed reachablePeers cache (see NostrTransport.swift:43-52), and flushAllOutbox() is invoked during ChatViewModel initialization (ChatViewModel.swift:518) before that cache is populated. At launch, favorites that are Nostr-reachable therefore appear unreachable, leaving queued messages in outbox indefinitely until another event (mesh reconnect or another favorite toggle) retriggers a flush. Users sending immediately after startup to an existing favorite over Nostr will see those messages stuck.

Useful? React with 👍 / 👎.

}
// Persist only items we could not send

if remaining.isEmpty {
outbox.removeValue(forKey: peerID)
} else {
Expand Down
56 changes: 54 additions & 2 deletions bitchat/Services/NostrTransport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import Foundation
import Combine

// Minimal Nostr transport conforming to Transport for offline sending
final class NostrTransport: Transport {
final class NostrTransport: Transport, @unchecked Sendable {
// Provide BLE short peer ID for BitChat embedding
var senderPeerID = PeerID(str: "")

Expand All @@ -18,9 +18,49 @@ final class NostrTransport: Transport {
private let keychain: KeychainManagerProtocol
private let idBridge: NostrIdentityBridge

// Reachability Cache (thread-safe)
private var reachablePeers: Set<PeerID> = []
private let queue = DispatchQueue(label: "nostr.transport.state", attributes: .concurrent)

@MainActor
init(keychain: KeychainManagerProtocol, idBridge: NostrIdentityBridge) {
self.keychain = keychain
self.idBridge = idBridge

setupObservers()

// Synchronously warm the cache to avoid startup race
let favorites = FavoritesPersistenceService.shared.favorites
let reachable = favorites.values
.filter { $0.peerNostrPublicKey != nil }
.map { PeerID(publicKey: $0.peerNoisePublicKey) }

queue.sync(flags: .barrier) {
self.reachablePeers = Set(reachable)
}
}

private func setupObservers() {
NotificationCenter.default.addObserver(
forName: .favoriteStatusChanged,
object: nil,
queue: nil
) { [weak self] _ in
self?.refreshReachablePeers()
}
}

private func refreshReachablePeers() {
Task { @MainActor in
let favorites = FavoritesPersistenceService.shared.favorites
let reachable = favorites.values
.filter { $0.peerNostrPublicKey != nil }
.map { PeerID(publicKey: $0.peerNoisePublicKey) }

self.queue.async(flags: .barrier) { [weak self] in
self?.reachablePeers = Set(reachable)
}
}
}

// MARK: - Transport Protocol Conformance
Expand All @@ -42,7 +82,19 @@ final class NostrTransport: Transport {
func emergencyDisconnectAll() { /* no-op */ }

func isPeerConnected(_ peerID: PeerID) -> Bool { false }
func isPeerReachable(_ peerID: PeerID) -> Bool { false }

func isPeerReachable(_ peerID: PeerID) -> Bool {
queue.sync {
// Check if exact match
if reachablePeers.contains(peerID) { return true }
// Check for short ID match
if peerID.isShort {
return reachablePeers.contains(where: { $0.toShort() == peerID })
}
return false
}
}

func peerNickname(peerID: PeerID) -> String? { nil }
func getPeerNicknames() -> [PeerID : String] { [:] }

Expand Down
3 changes: 2 additions & 1 deletion bitchat/ViewModels/ChatViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -436,7 +436,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
self.privateChatManager = PrivateChatManager(meshService: meshService)
self.unifiedPeerService = UnifiedPeerService(meshService: meshService, idBridge: idBridge, identityManager: identityManager)
let nostrTransport = NostrTransport(keychain: keychain, idBridge: idBridge)
self.messageRouter = MessageRouter(mesh: meshService, nostr: nostrTransport)
nostrTransport.senderPeerID = meshService.myPeerID
self.messageRouter = MessageRouter(transports: [meshService, nostrTransport])
// Route receipts from PrivateChatManager through MessageRouter
self.privateChatManager.messageRouter = self.messageRouter
// Allow PrivateChatManager to look up peer info for message consolidation
Expand Down
2 changes: 1 addition & 1 deletion bitchatTests/ChatViewModelTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ struct ChatViewModelReceivingTests {
)

// Give time for async Task and pipeline processing
try? await Task.sleep(nanoseconds: 200_000_000)
try? await Task.sleep(nanoseconds: 500_000_000)

#expect(viewModel.messages.contains { $0.content == "Public hello from Bob" })
}
Expand Down