mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 06:45:18 +00:00
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
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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] { [:] }
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user