From 89175e2065730b47552f62f16a112677213667de Mon Sep 17 00:00:00 2001 From: jack Date: Tue, 26 Aug 2025 11:58:41 +0200 Subject: [PATCH] mesh DMs/acks: route to reachable peers; queue READ/DELIVERED until handshake; add Transport.isPeerReachable; UI: hide offline non-mutuals; DM header: better name fallback + show transport + encryption icons; fix NostrTransport conformance --- bitchat/Services/BLEService.swift | 164 ++++++++++++++-------- bitchat/Services/MessageRouter.swift | 20 +-- bitchat/Services/NostrTransport.swift | 1 + bitchat/Services/Transport.swift | 1 + bitchat/Services/UnifiedPeerService.swift | 7 +- bitchat/ViewModels/ChatViewModel.swift | 17 +-- bitchat/Views/ContentView.swift | 59 +++++--- 7 files changed, 171 insertions(+), 98 deletions(-) diff --git a/bitchat/Services/BLEService.swift b/bitchat/Services/BLEService.swift index a6f23f34..95fd4680 100644 --- a/bitchat/Services/BLEService.swift +++ b/bitchat/Services/BLEService.swift @@ -102,6 +102,8 @@ final class BLEService: NSObject { // Queue for messages pending handshake completion private var pendingMessagesAfterHandshake: [String: [(content: String, messageID: String)]] = [:] + // Noise typed payloads (ACKs, read receipts, etc.) pending handshake + private var pendingNoisePayloadsAfterHandshake: [String: [Data]] = [:] // Queue for notifications that failed due to full queue private var pendingNotifications: [(data: Data, centrals: [CBCentral]?)] = [] @@ -307,6 +309,7 @@ final class BLEService: NSObject { // Send any messages that were queued during handshake self?.messageQueue.async { [weak self] in self?.sendPendingMessagesAfterHandshake(for: peerID) + self?.sendPendingNoisePayloadsAfterHandshake(for: peerID) } } @@ -502,6 +505,18 @@ final class BLEService: NSObject { return collectionsQueue.sync { peers[shortID]?.isConnected ?? false } } + func isPeerReachable(_ peerID: String) -> Bool { + // Accept both 16-hex short IDs and 64-hex Noise keys + let shortID: String = { + if peerID.count == 64, let key = Data(hexString: peerID) { + return PeerIDUtils.derivePeerID(fromPublicKey: key) + } + return peerID + }() + // A peer is reachable if we have a recent entry for it (connected or recently seen via mesh) + return collectionsQueue.sync { peers[shortID] != nil } + } + func peerNickname(peerID: String) -> String? { collectionsQueue.sync { guard let peer = peers[peerID], peer.isConnected else { return nil } @@ -541,43 +556,41 @@ final class BLEService: NSObject { } func sendReadReceipt(_ receipt: ReadReceipt, to peerID: String) { - // Send encrypted read receipt - guard noiseService.hasSession(with: peerID) else { - SecureLogger.log("Cannot send read receipt - no Noise session with \(peerID)", category: SecureLogger.noise, level: .warning) - return - } - - SecureLogger.log("📤 Sending READ receipt for message \(receipt.originalMessageID) to \(peerID)", - category: SecureLogger.session, level: .debug) - - // Create read receipt payload: [type byte] + [message ID] - var receiptPayload = Data([NoisePayloadType.readReceipt.rawValue]) - receiptPayload.append(contentsOf: receipt.originalMessageID.utf8) - - do { - let encrypted = try noiseService.encrypt(receiptPayload, for: peerID) - let packet = BitchatPacket( - type: MessageType.noiseEncrypted.rawValue, - senderID: Data(hexString: myPeerID) ?? Data(), - recipientID: Data(hexString: peerID), - timestamp: UInt64(Date().timeIntervalSince1970 * 1000), - payload: encrypted, - signature: nil, - ttl: messageTTL - ) - - // If already on messageQueue, call directly - if DispatchQueue.getSpecific(key: messageQueueKey) != nil { - broadcastPacket(packet) - } else { - messageQueue.async { [weak self] in - self?.broadcastPacket(packet) + // Create typed payload: [type byte] + [message ID] + var payload = Data([NoisePayloadType.readReceipt.rawValue]) + payload.append(contentsOf: receipt.originalMessageID.utf8) + + if noiseService.hasEstablishedSession(with: peerID) { + SecureLogger.log("📤 Sending READ receipt for message \(receipt.originalMessageID) to \(peerID)", + category: SecureLogger.session, level: .debug) + do { + let encrypted = try noiseService.encrypt(payload, for: peerID) + let packet = BitchatPacket( + type: MessageType.noiseEncrypted.rawValue, + senderID: Data(hexString: myPeerID) ?? Data(), + recipientID: Data(hexString: peerID), + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: encrypted, + signature: nil, + ttl: messageTTL + ) + if DispatchQueue.getSpecific(key: messageQueueKey) != nil { + broadcastPacket(packet) + } else { + messageQueue.async { [weak self] in self?.broadcastPacket(packet) } } + } catch { + SecureLogger.log("Failed to send read receipt: \(error)", category: SecureLogger.noise, level: .error) } - - // Read receipt sent - } catch { - SecureLogger.log("Failed to send read receipt: \(error)", category: SecureLogger.noise, level: .error) + } else { + // Queue for after handshake and initiate if needed + collectionsQueue.async(flags: .barrier) { [weak self] in + guard let self = self else { return } + self.pendingNoisePayloadsAfterHandshake[peerID, default: []].append(payload) + } + if !noiseService.hasSession(with: peerID) { initiateNoiseHandshake(with: peerID) } + SecureLogger.log("🕒 Queued READ receipt for \(peerID) until handshake completes", + category: SecureLogger.session, level: .debug) } } @@ -1720,31 +1733,64 @@ final class BLEService: NSObject { } func sendDeliveryAck(for messageID: String, to peerID: String) { - // Send encrypted delivery ACK - guard noiseService.hasSession(with: peerID) else { - SecureLogger.log("Cannot send ACK - no Noise session with \(peerID)", category: SecureLogger.noise, level: .warning) - return + // Create typed payload: [type byte] + [message ID] + var payload = Data([NoisePayloadType.delivered.rawValue]) + payload.append(contentsOf: messageID.utf8) + + if noiseService.hasEstablishedSession(with: peerID) { + do { + let encrypted = try noiseService.encrypt(payload, for: peerID) + let packet = BitchatPacket( + type: MessageType.noiseEncrypted.rawValue, + senderID: Data(hexString: myPeerID) ?? Data(), + recipientID: Data(hexString: peerID), + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: encrypted, + signature: nil, + ttl: messageTTL + ) + broadcastPacket(packet) + } catch { + SecureLogger.log("Failed to send delivery ACK: \(error)", category: SecureLogger.noise, level: .error) + } + } else { + // Queue for after handshake and initiate if needed + collectionsQueue.async(flags: .barrier) { [weak self] in + guard let self = self else { return } + self.pendingNoisePayloadsAfterHandshake[peerID, default: []].append(payload) + } + if !noiseService.hasSession(with: peerID) { initiateNoiseHandshake(with: peerID) } + SecureLogger.log("🕒 Queued DELIVERED ack for \(peerID) until handshake completes", + category: SecureLogger.session, level: .debug) } - - // Create ACK payload: [type byte] + [message ID] - var ackPayload = Data([NoisePayloadType.delivered.rawValue]) - ackPayload.append(contentsOf: messageID.utf8) - - do { - let encrypted = try noiseService.encrypt(ackPayload, for: peerID) - let packet = BitchatPacket( - type: MessageType.noiseEncrypted.rawValue, - senderID: Data(hexString: myPeerID) ?? Data(), - recipientID: Data(hexString: peerID), - timestamp: UInt64(Date().timeIntervalSince1970 * 1000), - payload: encrypted, - signature: nil, - ttl: messageTTL - ) - broadcastPacket(packet) - // Delivery ACK sent - } catch { - SecureLogger.log("Failed to send delivery ACK: \(error)", category: SecureLogger.noise, level: .error) + } + + private func sendPendingNoisePayloadsAfterHandshake(for peerID: String) { + let payloads = collectionsQueue.sync(flags: .barrier) { () -> [Data] in + let list = pendingNoisePayloadsAfterHandshake[peerID] ?? [] + pendingNoisePayloadsAfterHandshake.removeValue(forKey: peerID) + return list + } + guard !payloads.isEmpty else { return } + SecureLogger.log("📤 Sending \(payloads.count) pending noise payloads to \(peerID) after handshake", + category: SecureLogger.session, level: .debug) + for payload in payloads { + do { + let encrypted = try noiseService.encrypt(payload, for: peerID) + let packet = BitchatPacket( + type: MessageType.noiseEncrypted.rawValue, + senderID: Data(hexString: myPeerID) ?? Data(), + recipientID: Data(hexString: peerID), + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: encrypted, + signature: nil, + ttl: messageTTL + ) + broadcastPacket(packet) + } catch { + SecureLogger.log("❌ Failed to send pending noise payload to \(peerID): \(error)", + category: SecureLogger.noise, level: .error) + } } } diff --git a/bitchat/Services/MessageRouter.swift b/bitchat/Services/MessageRouter.swift index 1bf2bd4e..5937bdec 100644 --- a/bitchat/Services/MessageRouter.swift +++ b/bitchat/Services/MessageRouter.swift @@ -37,11 +37,11 @@ final class MessageRouter { } func sendPrivate(_ content: String, to peerID: String, recipientNickname: String, messageID: String) { - let hasMesh = mesh.isPeerConnected(peerID) - let hasEstablished = mesh.getNoiseService().hasEstablishedSession(with: peerID) - if hasMesh && hasEstablished { - SecureLogger.log("Routing PM via mesh to \(peerID.prefix(8))… id=\(messageID.prefix(8))…", + let reachableMesh = mesh.isPeerReachable(peerID) + if reachableMesh { + SecureLogger.log("Routing PM via mesh (reachable) to \(peerID.prefix(8))… id=\(messageID.prefix(8))…", category: SecureLogger.session, level: .debug) + // 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.log("Routing PM via Nostr to \(peerID.prefix(8))… id=\(messageID.prefix(8))…", @@ -57,9 +57,9 @@ final class MessageRouter { } func sendReadReceipt(_ receipt: ReadReceipt, to peerID: String) { - // Prefer mesh only if a Noise session is established; else use Nostr to avoid handshakeRequired spam - if mesh.isPeerConnected(peerID) && mesh.getNoiseService().hasEstablishedSession(with: peerID) { - SecureLogger.log("Routing READ ack via mesh to \(peerID.prefix(8))… id=\(receipt.originalMessageID.prefix(8))…", + // Prefer mesh for reachable peers; BLE will queue if handshake is needed + if mesh.isPeerReachable(peerID) { + SecureLogger.log("Routing READ ack via mesh (reachable) to \(peerID.prefix(8))… id=\(receipt.originalMessageID.prefix(8))…", category: SecureLogger.session, level: .debug) mesh.sendReadReceipt(receipt, to: peerID) } else { @@ -70,7 +70,9 @@ final class MessageRouter { } func sendDeliveryAck(_ messageID: String, to peerID: String) { - if mesh.isPeerConnected(peerID) && mesh.getNoiseService().hasEstablishedSession(with: peerID) { + if mesh.isPeerReachable(peerID) { + SecureLogger.log("Routing DELIVERED ack via mesh (reachable) to \(peerID.prefix(8))… id=\(messageID.prefix(8))…", + category: SecureLogger.session, level: .debug) mesh.sendDeliveryAck(for: messageID, to: peerID) } else { nostr.sendDeliveryAck(for: messageID, to: peerID) @@ -101,7 +103,7 @@ final class MessageRouter { category: SecureLogger.session, level: .debug) // Prefer mesh if connected; else try Nostr if mapping exists for (content, nickname, messageID) in queued { - if mesh.isPeerConnected(peerID) { + if mesh.isPeerReachable(peerID) { SecureLogger.log("Outbox -> mesh for \(peerID.prefix(8))… id=\(messageID.prefix(8))…", category: SecureLogger.session, level: .debug) mesh.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID) diff --git a/bitchat/Services/NostrTransport.swift b/bitchat/Services/NostrTransport.swift index b19a6924..c1fad64d 100644 --- a/bitchat/Services/NostrTransport.swift +++ b/bitchat/Services/NostrTransport.swift @@ -31,6 +31,7 @@ final class NostrTransport: Transport { func emergencyDisconnectAll() { /* no-op */ } func isPeerConnected(_ peerID: String) -> Bool { false } + func isPeerReachable(_ peerID: String) -> Bool { false } func peerNickname(peerID: String) -> String? { nil } func getPeerNicknames() -> [String : String] { [:] } diff --git a/bitchat/Services/Transport.swift b/bitchat/Services/Transport.swift index 372e3e3b..fe352a83 100644 --- a/bitchat/Services/Transport.swift +++ b/bitchat/Services/Transport.swift @@ -29,6 +29,7 @@ protocol Transport: AnyObject { // Connectivity and peers func isPeerConnected(_ peerID: String) -> Bool + func isPeerReachable(_ peerID: String) -> Bool func peerNickname(peerID: String) -> String? func getPeerNicknames() -> [String: String] diff --git a/bitchat/Services/UnifiedPeerService.swift b/bitchat/Services/UnifiedPeerService.swift index 2855ab21..53dd3f59 100644 --- a/bitchat/Services/UnifiedPeerService.swift +++ b/bitchat/Services/UnifiedPeerService.swift @@ -143,8 +143,11 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate { } } - // Phase 5: Update published properties - self.peers = enrichedPeers + // Phase 5: Filter out offline non-mutual peers and update published properties + let filtered = enrichedPeers.filter { p in + p.isConnected || p.isReachable || p.isMutualFavorite + } + self.peers = filtered self.connectedPeerIDs = connected self.favorites = favoritesList self.mutualFavorites = mutualsList diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 80b089db..608ed861 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -1874,6 +1874,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate { // Determine routing method and recipient nickname guard let noiseKey = Data(hexString: peerID) else { return } let isConnected = meshService.isPeerConnected(peerID) + let isReachable = meshService.isPeerReachable(peerID) let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey) let isMutualFavorite = favoriteStatus?.isMutual ?? false let hasNostrKey = favoriteStatus?.peerNostrPublicKey != nil @@ -1913,8 +1914,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate { // Trigger UI update for sent message objectWillChange.send() - // Send via appropriate transport (BLE if connected, else Nostr when possible) - if isConnected || (isMutualFavorite && hasNostrKey) { + // Send via appropriate transport (BLE if connected/reachable, else Nostr when possible) + if isConnected || isReachable || (isMutualFavorite && hasNostrKey) { messageRouter.sendPrivate(content, to: peerID, recipientNickname: recipientNickname ?? "user", messageID: messageID) // Optimistically mark as sent for both transports; delivery/read will update subsequently if let idx = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) { @@ -2542,16 +2543,12 @@ class ChatViewModel: ObservableObject, BitchatDelegate { } } - // If we know the original transport, use it for the read receipt + // If this originated over Nostr, skip (handled by Nostr code paths) if originalTransport == "nostr" { - // Skip read receipts for Nostr messages - unnecessary complexity - // The radical simplification plan says to accept occasional loss - } else if meshService.peerNickname(peerID: actualPeerID) != nil { - // Use mesh for connected peers (default behavior) - messageRouter.sendReadReceipt(receipt, to: actualPeerID) - } else { - // Skip read receipts for offline peers - fire and forget principle + return } + // Use router to decide (mesh if reachable, else Nostr if available) + messageRouter.sendReadReceipt(receipt, to: actualPeerID) } @MainActor diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index 2405e648..83b51bff 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -1180,20 +1180,11 @@ struct ContentView: View { @ViewBuilder private func privateHeaderContent(for privatePeerID: String) -> some View { - // Prefer short (mesh) ID when mesh-connected (radio). Only use full Noise key when not connected (globe). + // Prefer short (mesh) ID whenever available for encryption/session status; keep stable key for display resolution only. let headerPeerID: String = { - if privatePeerID.count == 16 { - let isMeshConnected = viewModel.meshService.isPeerConnected(privatePeerID) || viewModel.connectedPeers.contains(privatePeerID) - if !isMeshConnected, let stable = viewModel.getNoiseKeyForShortID(privatePeerID) { - return stable - } - } else if privatePeerID.count == 64 { - // If we have a full Noise key and a corresponding short ID is currently mesh-connected, prefer short ID - if let short = viewModel.getShortIDForNoiseKey(privatePeerID) { - if viewModel.meshService.isPeerConnected(short) || viewModel.connectedPeers.contains(short) { - return short - } - } + if privatePeerID.count == 64 { + // Map stable Noise key to short ID if we know it (even if not directly connected) + if let short = viewModel.getShortIDForNoiseKey(privatePeerID) { return short } } return privatePeerID }() @@ -1208,10 +1199,29 @@ struct ContentView: View { return "#\(ch.geohash)/@\(disp)" } } - return peer?.displayName ?? - viewModel.meshService.peerNickname(peerID: headerPeerID) ?? - FavoritesPersistenceService.shared.getFavoriteStatus(for: Data(hexString: headerPeerID) ?? Data())?.peerNickname ?? - "Unknown" + // Try mesh/unified peer display + if let name = peer?.displayName { return name } + // Try direct mesh nickname (connected-only) + if let name = viewModel.meshService.peerNickname(peerID: headerPeerID) { return name } + // Try favorite nickname by stable Noise key + if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: Data(hexString: headerPeerID) ?? Data()), + !fav.peerNickname.isEmpty { return fav.peerNickname } + // Fallback: resolve from persisted social identity via fingerprint mapping + if headerPeerID.count == 16 { + let candidates = SecureIdentityStateManager.shared.getCryptoIdentitiesByPeerIDPrefix(headerPeerID) + if let id = candidates.first, + let social = SecureIdentityStateManager.shared.getSocialIdentity(for: id.fingerprint) { + if let pet = social.localPetname, !pet.isEmpty { return pet } + if !social.claimedNickname.isEmpty { return social.claimedNickname } + } + } else if headerPeerID.count == 64, let keyData = Data(hexString: headerPeerID) { + let fp = keyData.sha256Fingerprint() + if let social = SecureIdentityStateManager.shared.getSocialIdentity(for: fp) { + if let pet = social.localPetname, !pet.isEmpty { return pet } + if !social.claimedNickname.isEmpty { return social.claimedNickname } + } + } + return "Unknown" }() let isNostrAvailable: Bool = { guard let connectionState = peer?.connectionState else { @@ -1262,6 +1272,12 @@ struct ContentView: View { // Should not happen for PM header, but handle gracefully EmptyView() } + } else if viewModel.meshService.isPeerReachable(headerPeerID) { + // Fallback: reachable via mesh but not in current peer list + Image(systemName: "point.3.filled.connected.trianglepath.dotted") + .font(.system(size: 14)) + .foregroundColor(textColor) + .accessibilityLabel("Reachable via mesh") } else if isNostrAvailable { // Fallback to Nostr if peer not in list but is mutual favorite Image(systemName: "globe") @@ -1280,7 +1296,14 @@ struct ContentView: View { .font(.system(size: 16, weight: .medium, design: .monospaced)) .foregroundColor(textColor) // Dynamic encryption status icon (hide for geohash DMs) if !privatePeerID.hasPrefix("nostr_") { - let encryptionStatus = viewModel.getEncryptionStatus(for: headerPeerID) + // Use short peer ID if available for encryption status (sessions keyed by short ID) + let statusPeerID: String = { + if privatePeerID.count == 64, let short = viewModel.getShortIDForNoiseKey(privatePeerID) { + return short + } + return headerPeerID + }() + let encryptionStatus = viewModel.getEncryptionStatus(for: statusPeerID) if let icon = encryptionStatus.icon { Image(systemName: icon) .font(.system(size: 14))