From 3ebfa85e90bab2906619bf5f4d97b0d91301c6db Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Sun, 17 Aug 2025 11:25:42 +0200 Subject: [PATCH] Feature/nostr embedded bitchat (#448) * UI: prefer mesh radio icon when connected; map short peer ID to full Noise key; rename ephemeral mapping to shortIDToNoiseKey; ensure header flips to purple globe on disconnect and keeps name. * chore: stop tracking build artifacts; ignore .DerivedData and .Result* * logging: add global threshold via BITCHAT_LOG_LEVEL and demote chatty logs to debug; keep critical errors/warnings and key state transitions --------- Co-authored-by: jack --- .gitignore | 5 + bitchat/Models/BitchatPeer.swift | 11 +- bitchat/Noise/NoiseSession.swift | 14 +- bitchat/Services/NoiseEncryptionService.swift | 2 +- .../Services/SimplifiedBluetoothService.swift | 150 ++++- bitchat/Services/UnifiedPeerService.swift | 8 +- bitchat/Utils/SecureLogger.swift | 30 + bitchat/ViewModels/ChatViewModel.swift | 560 ++++++++++-------- bitchat/Views/ContentView.swift | 38 +- 9 files changed, 510 insertions(+), 308 deletions(-) diff --git a/.gitignore b/.gitignore index 45e52c28..76236eff 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ xcuserdata/ ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) build/ DerivedData/ +.DerivedData/ *.moved-aside *.pbxuser !default.pbxuser @@ -65,3 +66,7 @@ __pycache__/ ## Temporary files *.tmp *.temp + +# Local build results +.Result*/ +.Result*.xcresult/ diff --git a/bitchat/Models/BitchatPeer.swift b/bitchat/Models/BitchatPeer.swift index 8dd1ab44..65c34b46 100644 --- a/bitchat/Models/BitchatPeer.swift +++ b/bitchat/Models/BitchatPeer.swift @@ -282,16 +282,13 @@ class PeerManager: ObservableObject { self.favorites = favorites self.mutualFavorites = mutualFavorites - // Always log favorites debug info when there are favorites + // Log peer list summary sparingly at debug level if favoritesService.favorites.count > 0 { SecureLogger.log("πŸ“Š Peer list update: \(allPeers.count) total (\(connectedCount) connected, \(offlineCount) offline), \(favorites.count) favorites, \(mutualFavorites.count) mutual", - category: SecureLogger.session, level: .info) - - // Detailed peer status logging removed for brevity + category: SecureLogger.session, level: .debug) } else if previousCount != allPeers.count { - // Only log non-favorite updates if count changed SecureLogger.log("βœ… Updated peer list: \(allPeers.count) total peers", - category: SecureLogger.session, level: .info) + category: SecureLogger.session, level: .debug) } } @@ -307,4 +304,4 @@ class PeerManager: ObservableObject { } updatePeers() } -} \ No newline at end of file +} diff --git a/bitchat/Noise/NoiseSession.swift b/bitchat/Noise/NoiseSession.swift index 005710a1..1139b861 100644 --- a/bitchat/Noise/NoiseSession.swift +++ b/bitchat/Noise/NoiseSession.swift @@ -92,7 +92,7 @@ class NoiseSession { func processHandshakeMessage(_ message: Data) throws -> Data? { return try sessionQueue.sync(flags: .barrier) { - SecureLogger.log("NoiseSession[\(peerID)]: Processing handshake message, current state: \(state), role: \(role)", category: SecureLogger.noise, level: .info) + SecureLogger.log("NoiseSession[\(peerID)]: Processing handshake message, current state: \(state), role: \(role)", category: SecureLogger.noise, level: .debug) // Initialize handshake state if needed (for responders) if state == .uninitialized && role == .responder { @@ -103,7 +103,7 @@ class NoiseSession { remoteStaticKey: nil ) state = .handshaking - SecureLogger.log("NoiseSession[\(peerID)]: Initialized handshake state for responder", category: SecureLogger.noise, level: .info) + SecureLogger.log("NoiseSession[\(peerID)]: Initialized handshake state for responder", category: SecureLogger.noise, level: .debug) } guard case .handshaking = state, let handshake = handshakeState else { @@ -112,7 +112,7 @@ class NoiseSession { // Process incoming message _ = try handshake.readMessage(message) - SecureLogger.log("NoiseSession[\(peerID)]: Read handshake message, checking if complete", category: SecureLogger.noise, level: .info) + SecureLogger.log("NoiseSession[\(peerID)]: Read handshake message, checking if complete", category: SecureLogger.noise, level: .debug) // Check if handshake is complete if handshake.isHandshakeComplete() { @@ -130,7 +130,7 @@ class NoiseSession { state = .established handshakeState = nil // Clear handshake state - SecureLogger.log("NoiseSession[\(peerID)]: Handshake complete (no response needed), transitioning to established", category: SecureLogger.noise, level: .info) + SecureLogger.log("NoiseSession[\(peerID)]: Handshake complete (no response needed), transitioning to established", category: SecureLogger.noise, level: .debug) SecureLogger.logSecurityEvent(.handshakeCompleted(peerID: peerID)) return nil @@ -138,7 +138,7 @@ class NoiseSession { // Generate response let response = try handshake.writeMessage() sentHandshakeMessages.append(response) - SecureLogger.log("NoiseSession[\(peerID)]: Generated handshake response of size \(response.count)", category: SecureLogger.noise, level: .info) + SecureLogger.log("NoiseSession[\(peerID)]: Generated handshake response of size \(response.count)", category: SecureLogger.noise, level: .debug) // Check if handshake is complete after writing if handshake.isHandshakeComplete() { @@ -156,7 +156,7 @@ class NoiseSession { state = .established handshakeState = nil // Clear handshake state - SecureLogger.log("NoiseSession[\(peerID)]: Handshake complete after writing response, transitioning to established", category: SecureLogger.noise, level: .info) + SecureLogger.log("NoiseSession[\(peerID)]: Handshake complete after writing response, transitioning to established", category: SecureLogger.noise, level: .debug) SecureLogger.logSecurityEvent(.handshakeCompleted(peerID: peerID)) } @@ -474,4 +474,4 @@ enum NoiseSessionError: Error { case sessionNotFound case handshakeFailed(Error) case alreadyEstablished -} \ No newline at end of file +} diff --git a/bitchat/Services/NoiseEncryptionService.swift b/bitchat/Services/NoiseEncryptionService.swift index d1f0853e..464ba04d 100644 --- a/bitchat/Services/NoiseEncryptionService.swift +++ b/bitchat/Services/NoiseEncryptionService.swift @@ -485,7 +485,7 @@ class NoiseEncryptionService { // Attempt to rekey the session do { try sessionManager.initiateRekey(for: peerID) - SecureLogger.log("Key rotation initiated for peer: \(peerID)", category: SecureLogger.security, level: .info) + SecureLogger.log("Key rotation initiated for peer: \(peerID)", category: SecureLogger.security, level: .debug) // Signal that handshake is needed onHandshakeRequired?(peerID) diff --git a/bitchat/Services/SimplifiedBluetoothService.swift b/bitchat/Services/SimplifiedBluetoothService.swift index 5dc8cf2d..e5095f5f 100644 --- a/bitchat/Services/SimplifiedBluetoothService.swift +++ b/bitchat/Services/SimplifiedBluetoothService.swift @@ -152,7 +152,7 @@ final class SimplifiedBluetoothService: NSObject { // This ensures we send pending messages only when session is truly established noiseService.onPeerAuthenticated = { [weak self] peerID, fingerprint in SecureLogger.log("πŸ” Noise session authenticated with \(peerID), fingerprint: \(fingerprint.prefix(16))...", - category: SecureLogger.noise, level: .info) + category: SecureLogger.noise, level: .debug) // Send any messages that were queued during handshake self?.messageQueue.async { [weak self] in self?.sendPendingMessagesAfterHandshake(for: peerID) @@ -341,7 +341,7 @@ final class SimplifiedBluetoothService: NSObject { func sendFavoriteNotification(to peerID: String, isFavorite: Bool) { SecureLogger.log("πŸ”” sendFavoriteNotification called - peerID: \(peerID), isFavorite: \(isFavorite)", - category: SecureLogger.session, level: .info) + category: SecureLogger.session, level: .debug) // Include Nostr public key in the notification var content = isFavorite ? "[FAVORITED]" : "[UNFAVORITED]" @@ -350,11 +350,11 @@ final class SimplifiedBluetoothService: NSObject { if let myNostrIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() { content += ":" + myNostrIdentity.npub SecureLogger.log("πŸ“ Sending favorite notification with Nostr npub: \(myNostrIdentity.npub)", - category: SecureLogger.session, level: .info) + category: SecureLogger.session, level: .debug) } SecureLogger.log("πŸ“€ Sending favorite notification to \(peerID): \(content)", - category: SecureLogger.session, level: .info) + category: SecureLogger.session, level: .debug) sendPrivateMessage(content, to: peerID, messageID: UUID().uuidString) } @@ -366,7 +366,7 @@ final class SimplifiedBluetoothService: NSObject { } SecureLogger.log("πŸ“€ Sending READ receipt for message \(receipt.originalMessageID) to \(peerID)", - category: SecureLogger.session, level: .info) + category: SecureLogger.session, level: .debug) // Create read receipt payload: [type byte] + [message ID] var receiptPayload = Data([NoisePayloadType.readReceipt.rawValue]) @@ -496,7 +496,7 @@ final class SimplifiedBluetoothService: NSObject { // MARK: - Private Message Handling private func sendPrivateMessage(_ content: String, to recipientID: String, messageID: String) { - SecureLogger.log("πŸ“¨ Sending PM to \(recipientID): \(content.prefix(30))...", category: SecureLogger.session, level: .info) + SecureLogger.log("πŸ“¨ Sending PM to \(recipientID): \(content.prefix(30))...", category: SecureLogger.session, level: .debug) // Check if we have an established Noise session if noiseService.hasEstablishedSession(with: recipientID) { @@ -558,7 +558,7 @@ final class SimplifiedBluetoothService: NSObject { } } else { // Queue message for sending after handshake completes - SecureLogger.log("🀝 No session with \(recipientID), initiating handshake and queueing message", category: SecureLogger.session, level: .info) + SecureLogger.log("🀝 No session with \(recipientID), initiating handshake and queueing message", category: SecureLogger.session, level: .debug) // Queue the message (especially important for favorite notifications) collectionsQueue.sync(flags: .barrier) { @@ -618,7 +618,7 @@ final class SimplifiedBluetoothService: NSObject { guard let messages = pendingMessages, !messages.isEmpty else { return } SecureLogger.log("πŸ“€ Sending \(messages.count) pending messages after handshake to \(peerID)", - category: SecureLogger.session, level: .info) + category: SecureLogger.session, level: .debug) // Send each pending message directly (we know session is established) for (content, messageID) in messages { @@ -650,7 +650,7 @@ final class SimplifiedBluetoothService: NSObject { } SecureLogger.log("βœ… Sent pending message \(messageID) to \(peerID) after handshake", - category: SecureLogger.session, level: .info) + category: SecureLogger.session, level: .debug) } catch { SecureLogger.log("Failed to send pending message after handshake: \(error)", category: SecureLogger.noise, level: .error) @@ -675,7 +675,7 @@ final class SimplifiedBluetoothService: NSObject { // Log encrypted and relayed packets for debugging if packet.type == MessageType.noiseEncrypted.rawValue { SecureLogger.log("πŸ“‘ Encrypted packet to \(packet.recipientID?.hexEncodedString() ?? "unknown")", - category: SecureLogger.session, level: .info) + category: SecureLogger.session, level: .debug) } else if packet.ttl < messageTTL { // Relayed packet } @@ -1042,11 +1042,11 @@ final class SimplifiedBluetoothService: NSObject { // Log connection status if existingPeer == nil { - SecureLogger.log("πŸ†• New peer: \(announcement.nickname)", category: SecureLogger.session, level: .info) + SecureLogger.log("πŸ†• New peer: \(announcement.nickname)", category: SecureLogger.session, level: .debug) } else if wasDisconnected { - SecureLogger.log("πŸ”„ Peer \(announcement.nickname) reconnected", category: SecureLogger.session, level: .info) + SecureLogger.log("πŸ”„ Peer \(announcement.nickname) reconnected", category: SecureLogger.session, level: .debug) } else if existingPeer?.nickname != announcement.nickname { - SecureLogger.log("πŸ”„ Peer \(peerID) changed nickname: \(existingPeer?.nickname ?? "Unknown") -> \(announcement.nickname)", category: SecureLogger.session, level: .info) + SecureLogger.log("πŸ”„ Peer \(peerID) changed nickname: \(existingPeer?.nickname ?? "Unknown") -> \(announcement.nickname)", category: SecureLogger.session, level: .debug) } } @@ -1117,7 +1117,7 @@ final class SimplifiedBluetoothService: NSObject { } let senderNickname = peers[peerID]?.nickname ?? "Unknown" - SecureLogger.log("πŸ’¬ [\(senderNickname)] TTL:\(packet.ttl): \(String(content.prefix(50)))\(content.count > 50 ? "..." : "")", category: SecureLogger.session, level: .info) + SecureLogger.log("πŸ’¬ [\(senderNickname)] TTL:\(packet.ttl): \(String(content.prefix(50)))\(content.count > 50 ? "..." : "")", category: SecureLogger.session, level: .debug) // Parse mentions from the message content let mentions = parseMentions(from: content) @@ -1231,7 +1231,7 @@ final class SimplifiedBluetoothService: NSObject { mentions: mentions.isEmpty ? nil : mentions ) - SecureLogger.log("πŸ”“ Decrypted TLV PM from \(message.sender): \(messageContent.prefix(30))...", category: SecureLogger.session, level: .info) + SecureLogger.log("πŸ”“ Decrypted TLV PM from \(message.sender): \(messageContent.prefix(30))...", category: SecureLogger.session, level: .debug) // Send on main thread notifyUI { [weak self] in @@ -1262,7 +1262,7 @@ final class SimplifiedBluetoothService: NSObject { guard let messageID = String(data: payloadData, encoding: .utf8) else { return } SecureLogger.log("πŸ“– Received READ receipt for message \(messageID) from \(peerID)", - category: SecureLogger.session, level: .info) + category: SecureLogger.session, level: .debug) // Update read status notifyUI { [weak self] in @@ -1277,7 +1277,7 @@ final class SimplifiedBluetoothService: NSObject { // We received an encrypted message before establishing a session with this peer. // Trigger a handshake so future messages can be decrypted. SecureLogger.log("πŸ”‘ Encrypted message from \(peerID) without session; initiating handshake", - category: SecureLogger.noise, level: .info) + category: SecureLogger.noise, level: .debug) if !noiseService.hasSession(with: peerID) { initiateNoiseHandshake(with: peerID) } @@ -1308,7 +1308,7 @@ final class SimplifiedBluetoothService: NSObject { // MARK: - Helper Functions private func sendLeave() { - SecureLogger.log("πŸ‘‹ Sending leave announcement", category: SecureLogger.session, level: .info) + SecureLogger.log("πŸ‘‹ Sending leave announcement", category: SecureLogger.session, level: .debug) let packet = BitchatPacket( type: MessageType.leave.rawValue, ttl: messageTTL, @@ -1460,7 +1460,7 @@ final class SimplifiedBluetoothService: NSObject { if !hasPeripheralConnection && !hasCentralConnection { // Remove the peer completely (they'll be re-added when they reconnect) SecureLogger.log("⏱️ Peer timed out (no packets for 20s): \(peerID) (\(peer.nickname))", - category: SecureLogger.session, level: .info) + category: SecureLogger.session, level: .debug) peers.removeValue(forKey: peerID) disconnectedPeers.append(peerID) } @@ -1584,7 +1584,7 @@ extension SimplifiedBluetoothService: CBCentralManagerDelegate { // Connect to the peripheral with options for faster connection SecureLogger.log("πŸ“± Connect: \(advertisedName) [RSSI:\(rssiValue)]", - category: SecureLogger.session, level: .info) + category: SecureLogger.session, level: .debug) // Use connection options for faster reconnection let options: [String: Any] = [ @@ -1627,7 +1627,7 @@ extension SimplifiedBluetoothService: CBCentralManagerDelegate { ) } - SecureLogger.log("βœ… Connected: \(peripheral.name ?? "Unknown") [\(peripheralID)]", category: SecureLogger.session, level: .info) + SecureLogger.log("βœ… Connected: \(peripheral.name ?? "Unknown") [\(peripheralID)]", category: SecureLogger.session, level: .debug) // Discover services peripheral.discoverServices([SimplifiedBluetoothService.serviceUUID]) @@ -1640,7 +1640,7 @@ extension SimplifiedBluetoothService: CBCentralManagerDelegate { let peerID = peripherals[peripheralID]?.peerID SecureLogger.log("πŸ“± Disconnect: \(peerID ?? peripheralID)\(error != nil ? " (\(error!.localizedDescription))" : "")", - category: SecureLogger.session, level: .info) + category: SecureLogger.session, level: .debug) // Clean up references peripherals.removeValue(forKey: peripheralID) @@ -1895,13 +1895,13 @@ extension SimplifiedBluetoothService: CBPeripheralManagerDelegate { return } - SecureLogger.log("βœ… Service added successfully, starting advertising", category: SecureLogger.session, level: .info) + SecureLogger.log("βœ… Service added successfully, starting advertising", category: SecureLogger.session, level: .debug) // Start advertising after service is confirmed added let adData = buildAdvertisementData() peripheral.startAdvertising(adData) - SecureLogger.log("πŸ“‘ Started advertising (LocalName: \((adData[CBAdvertisementDataLocalNameKey] as? String) != nil ? "on" : "off"), ID: \(myPeerID.prefix(8))…)", category: SecureLogger.session, level: .info) + SecureLogger.log("πŸ“‘ Started advertising (LocalName: \((adData[CBAdvertisementDataLocalNameKey] as? String) != nil ? "on" : "off"), ID: \(myPeerID.prefix(8))…)", category: SecureLogger.session, level: .debug) } func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didSubscribeTo characteristic: CBCharacteristic) { @@ -1913,12 +1913,12 @@ extension SimplifiedBluetoothService: CBPeripheralManagerDelegate { } func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didUnsubscribeFrom characteristic: CBCharacteristic) { - SecureLogger.log("πŸ“€ Central unsubscribed: \(central.identifier.uuidString)", category: SecureLogger.session, level: .info) + SecureLogger.log("πŸ“€ Central unsubscribed: \(central.identifier.uuidString)", category: SecureLogger.session, level: .debug) subscribedCentrals.removeAll { $0.identifier == central.identifier } // Ensure we're still advertising for other devices to find us if peripheral.isAdvertising == false { - SecureLogger.log("πŸ“‘ Restarting advertising after central unsubscribed", category: SecureLogger.session, level: .info) + SecureLogger.log("πŸ“‘ Restarting advertising after central unsubscribed", category: SecureLogger.session, level: .debug) peripheral.startAdvertising(buildAdvertisementData()) } @@ -1948,7 +1948,7 @@ extension SimplifiedBluetoothService: CBPeripheralManagerDelegate { } func peripheralManagerIsReady(toUpdateSubscribers peripheral: CBPeripheralManager) { - SecureLogger.log("πŸ“€ Peripheral manager ready to send more notifications", category: SecureLogger.session, level: .info) + SecureLogger.log("πŸ“€ Peripheral manager ready to send more notifications", category: SecureLogger.session, level: .debug) // Retry pending notifications now that queue has space collectionsQueue.async(flags: .barrier) { [weak self] in @@ -1995,7 +1995,7 @@ extension SimplifiedBluetoothService: CBPeripheralManagerDelegate { func peripheralManager(_ peripheral: CBPeripheralManager, didReceiveWrite requests: [CBATTRequest]) { // Suppress logs for single write requests to reduce noise if requests.count > 1 { - SecureLogger.log("πŸ“₯ Received \(requests.count) write requests from central", category: SecureLogger.session, level: .info) + SecureLogger.log("πŸ“₯ Received \(requests.count) write requests from central", category: SecureLogger.session, level: .debug) } // IMPORTANT: Respond immediately to prevent timeouts! @@ -2022,7 +2022,7 @@ extension SimplifiedBluetoothService: CBPeripheralManagerDelegate { let senderID = dataToHexString(packet.senderID) // Only log non-announce packets if packet.type != MessageType.announce.rawValue { - SecureLogger.log("πŸ“¦ Decoded packet type: \(packet.type) from sender: \(senderID)", category: SecureLogger.session, level: .info) + SecureLogger.log("πŸ“¦ Decoded packet type: \(packet.type) from sender: \(senderID)", category: SecureLogger.session, level: .debug) } // Store central in our list if not already there @@ -2089,7 +2089,7 @@ extension SimplifiedBluetoothService { } private func buildAdvertisementData() -> [String: Any] { - var data: [String: Any] = [ + let data: [String: Any] = [ CBAdvertisementDataServiceUUIDsKey: [SimplifiedBluetoothService.serviceUUID] ] // No Local Name for privacy @@ -2099,6 +2099,96 @@ extension SimplifiedBluetoothService { // No alias rotation or advertising restarts required. } +// MARK: - Nostr Embedding Helpers + +extension SimplifiedBluetoothService { + /// Build a `bitchat1:` base64url-encoded BitChat packet carrying a private message + /// for transport over Nostr DMs. The payload is a plaintext typed NoisePayload + /// (no inner Noise encryption; NIP-17 provides transport-layer E2E). + func buildNostrEmbeddedPrivateMessageContent(content: String, to recipientPeerID: String, messageID: String) -> String? { + // TLV-encode the private message + let pm = PrivateMessagePacket(messageID: messageID, content: content) + guard let tlv = pm.encode() else { return nil } + + // Prefix with NoisePayloadType + var payload = Data([NoisePayloadType.privateMessage.rawValue]) + payload.append(tlv) + + // Build BitChat packet (noiseEncrypted type used as a typed envelope) + // Determine correct 8-byte recipient ID (peerID) to embed + let recipientIDHex: String = { + if let maybeData = Data(hexString: recipientPeerID) { + if maybeData.count == 32 { + // Treat as Noise static public key; derive peerID from fingerprint + return Self.derivePeerID(fromPublicKey: maybeData) + } else if maybeData.count == 8 { + // Already an 8-byte peer ID + return recipientPeerID + } + } + // Fallback (should not happen): use myPeerID to avoid dropping + return recipientPeerID.count == 16 ? recipientPeerID : myPeerID + }() + + let packet = BitchatPacket( + type: MessageType.noiseEncrypted.rawValue, + senderID: hexStringToData(myPeerID), + recipientID: hexStringToData(recipientIDHex), + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: payload, + signature: nil, + ttl: messageTTL + ) + + guard let data = packet.toBinaryData() else { return nil } + return "bitchat1:" + Self.base64URLEncode(data) + } + + /// Build a `bitchat1:` base64url-encoded BitChat packet carrying a delivery/read ack + /// for transport over Nostr DMs. Payload is plaintext typed NoisePayload. + func buildNostrEmbeddedAckContent(type: NoisePayloadType, messageID: String, to recipientPeerID: String) -> String? { + guard type == .delivered || type == .readReceipt else { return nil } + + var payload = Data([type.rawValue]) + payload.append(Data(messageID.utf8)) + + // Determine correct 8-byte recipient ID (peerID) to embed + let recipientIDHex: String = { + if let maybeData = Data(hexString: recipientPeerID) { + if maybeData.count == 32 { + return Self.derivePeerID(fromPublicKey: maybeData) + } else if maybeData.count == 8 { + return recipientPeerID + } + } + return recipientPeerID.count == 16 ? recipientPeerID : myPeerID + }() + + let packet = BitchatPacket( + type: MessageType.noiseEncrypted.rawValue, + senderID: hexStringToData(myPeerID), + recipientID: hexStringToData(recipientIDHex), + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: payload, + signature: nil, + ttl: messageTTL + ) + + guard let data = packet.toBinaryData() else { return nil } + return "bitchat1:" + Self.base64URLEncode(data) + } + + /// Base64url encode without padding + private static func base64URLEncode(_ data: Data) -> String { + let b64 = data.base64EncodedString() + let urlSafe = b64 + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + return urlSafe + } +} + // MARK: - Message Deduplicator /// Efficient message deduplication with time-based cleanup diff --git a/bitchat/Services/UnifiedPeerService.swift b/bitchat/Services/UnifiedPeerService.swift index 7a57ccf8..c70b0d00 100644 --- a/bitchat/Services/UnifiedPeerService.swift +++ b/bitchat/Services/UnifiedPeerService.swift @@ -190,7 +190,7 @@ class UnifiedPeerService: ObservableObject { SecureLogger.log( "πŸ”„ Found favorite for '\(peerInfo.nickname)' by nickname, updating noise key", category: SecureLogger.session, - level: .info + level: .debug ) // Update the favorite's key in persistence @@ -277,7 +277,7 @@ class UnifiedPeerService: ObservableObject { // Debug logging to understand the issue SecureLogger.log("πŸ” Toggle favorite - peer.nickname: '\(peer.nickname)', peer.displayName: '\(peer.displayName)', peerID: \(peerID)", - category: SecureLogger.session, level: .info) + category: SecureLogger.session, level: .debug) if actualNickname.isEmpty { // Try to get from mesh service's current peer list @@ -312,7 +312,7 @@ class UnifiedPeerService: ObservableObject { // Log the final nickname being saved SecureLogger.log("⭐️ Toggled favorite for '\(finalNickname)' (peerID: \(peerID), was: \(wasFavorite), now: \(!wasFavorite))", - category: SecureLogger.session, level: .info) + category: SecureLogger.session, level: .debug) // Send favorite notification to the peer meshService.sendFavoriteNotification(to: peerID, isFavorite: !wasFavorite) @@ -402,4 +402,4 @@ extension Data { let hash = SHA256.hash(data: self) return hash.map { String(format: "%02x", $0) }.joined() } -} \ No newline at end of file +} diff --git a/bitchat/Utils/SecureLogger.swift b/bitchat/Utils/SecureLogger.swift index 04fa9a0a..befc1421 100644 --- a/bitchat/Utils/SecureLogger.swift +++ b/bitchat/Utils/SecureLogger.swift @@ -58,6 +58,16 @@ class SecureLogger { case error case fault + fileprivate var order: Int { + switch self { + case .debug: return 0 + case .info: return 1 + case .warning: return 2 + case .error: return 3 + case .fault: return 4 + } + } + var osLogType: OSLogType { switch self { case .debug: return .debug @@ -68,6 +78,24 @@ class SecureLogger { } } } + + // MARK: - Global Threshold + + /// Minimum level that will be logged. Defaults to .info. Override via env BITCHAT_LOG_LEVEL. + private static let minimumLevel: LogLevel = { + let env = ProcessInfo.processInfo.environment["BITCHAT_LOG_LEVEL"]?.lowercased() + switch env { + case "debug": return .debug + case "warning": return .warning + case "error": return .error + case "fault": return .fault + default: return .info + } + }() + + private static func shouldLog(_ level: LogLevel) -> Bool { + return level.order >= minimumLevel.order + } // MARK: - Security Event Types @@ -99,6 +127,7 @@ class SecureLogger { /// Log a security event static func logSecurityEvent(_ event: SecurityEvent, level: LogLevel = .info, file: String = #file, line: Int = #line, function: String = #function) { + guard shouldLog(level) else { return } let location = formatLocation(file: file, line: line, function: function) let message = "\(location) \(event.message)" @@ -113,6 +142,7 @@ class SecureLogger { /// Log general messages with automatic sensitive data filtering static func log(_ message: String, category: OSLog = noise, level: LogLevel = .debug, file: String = #file, line: Int = #line, function: String = #function) { + guard shouldLog(level) else { return } let location = formatLocation(file: file, line: line, function: function) let sanitized = sanitize("\(location) \(message)") diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 613f33a9..b2f7d188 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -151,6 +151,22 @@ class ChatViewModel: ObservableObject, BitchatDelegate { // Missing properties that were removed during refactoring private var peerIDToPublicKeyFingerprint: [String: String] = [:] private var selectedPrivateChatFingerprint: String? = nil + // Map stable short peer IDs (16-hex) to full Noise public key hex (64-hex) for session continuity + private var shortIDToNoiseKey: [String: String] = [:] + + // Resolve full Noise key for a peer's short ID (used by UI header rendering) + @MainActor + func getNoiseKeyForShortID(_ shortPeerID: String) -> String? { + if let mapped = shortIDToNoiseKey[shortPeerID] { return mapped } + // Fallback: derive from active Noise session if available + if shortPeerID.count == 16, + let key = meshService.getNoiseService().getPeerPublicKeyData(shortPeerID) { + let stable = key.hexEncodedString() + shortIDToNoiseKey[shortPeerID] = stable + return stable + } + return nil + } private var peerIndex: [String: BitchatPeer] = [:] // MARK: - Autocomplete Properties @@ -848,7 +864,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate { } else if isMutualFavorite && hasNostrKey, let recipientNostrPubkey = favoriteStatus?.peerNostrPublicKey { // Mutual favorite offline - send via Nostr - sendViaNostr(content, to: recipientNostrPubkey, messageId: messageID) + sendViaNostr(content, to: recipientNostrPubkey, recipientPeerID: peerID, messageId: messageID) } else { // Update delivery status to failed if let index = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) { @@ -1441,7 +1457,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate { if (message.senderPeerID == peerID || message.senderPeerID == noiseKeyHex) && !message.isRelay { // Skip if we already sent an ACK for this message if !sentReadReceipts.contains(message.id) { - sendNostrAcknowledgment(messageId: message.id, to: nostrPubkey, type: "READ") + // Use stable Noise key hex if available; else fall back to peerID + let recipPeer = (Data(hexString: peerID) != nil) ? peerID : (unifiedPeerService.getPeer(by: peerID)?.noisePublicKey.hexEncodedString() ?? peerID) + sendNostrAcknowledgment(messageId: message.id, to: nostrPubkey, type: "READ", recipientPeerID: recipPeer) sentReadReceipts.insert(message.id) } } @@ -2221,9 +2239,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate { noiseService.onPeerAuthenticated = { [weak self] peerID, fingerprint in DispatchQueue.main.async { guard let self = self else { return } - - SecureLogger.log("πŸ” Authenticated: \(peerID)", category: SecureLogger.security, level: .info) - + + SecureLogger.log("πŸ” Authenticated: \(peerID)", category: SecureLogger.security, level: .debug) + // Update encryption status if self.verifiedFingerprints.contains(fingerprint) { self.peerEncryptionStatus[peerID] = .noiseVerified @@ -2232,10 +2250,19 @@ class ChatViewModel: ObservableObject, BitchatDelegate { self.peerEncryptionStatus[peerID] = .noiseSecured // Encryption: noiseSecured } - + // Invalidate cache when encryption status changes self.invalidateEncryptionCache(for: peerID) - + + // Cache shortID -> full Noise key mapping as soon as session authenticates + if self.shortIDToNoiseKey[peerID] == nil, + let keyData = self.meshService.getNoiseService().getPeerPublicKeyData(peerID) { + let stable = keyData.hexEncodedString() + self.shortIDToNoiseKey[peerID] = stable + SecureLogger.log("πŸ—ΊοΈ Mapped short peerID to Noise key for header continuity: \(peerID) -> \(stable.prefix(8))…", + category: SecureLogger.session, level: .debug) + } + // Schedule UI update // UI will update automatically } @@ -2323,6 +2350,12 @@ class ChatViewModel: ObservableObject, BitchatDelegate { // Force UI refresh objectWillChange.send() + + // Cache mapping to full Noise key for session continuity on disconnect + if let peer = unifiedPeerService.getPeer(by: peerID) { + let noiseKeyHex = peer.noisePublicKey.hexEncodedString() + shortIDToNoiseKey[peerID] = noiseKeyHex + } } // Connection messages removed to reduce chat noise @@ -2333,6 +2366,47 @@ class ChatViewModel: ObservableObject, BitchatDelegate { // Remove ephemeral session from identity manager SecureIdentityStateManager.shared.removeEphemeralSession(peerID: peerID) + + // If the open PM is tied to this short peer ID, switch UI context to the full Noise key (offline favorite) + var derivedStableKeyHex: String? = shortIDToNoiseKey[peerID] + if derivedStableKeyHex == nil, + let key = meshService.getNoiseService().getPeerPublicKeyData(peerID) { + derivedStableKeyHex = key.hexEncodedString() + shortIDToNoiseKey[peerID] = derivedStableKeyHex + } + + if let current = selectedPrivateChatPeer, current == peerID, + let stableKeyHex = derivedStableKeyHex { + // Migrate messages view context to stable key so header shows favorite + Nostr globe + if let messages = privateChats[peerID] { + if privateChats[stableKeyHex] == nil { privateChats[stableKeyHex] = [] } + let existing = Set(privateChats[stableKeyHex]!.map { $0.id }) + for msg in messages where !existing.contains(msg.id) { + let updated = BitchatMessage( + id: msg.id, + sender: msg.sender, + content: msg.content, + timestamp: msg.timestamp, + isRelay: msg.isRelay, + originalSender: msg.originalSender, + isPrivate: msg.isPrivate, + recipientNickname: msg.recipientNickname, + senderPeerID: (msg.senderPeerID == meshService.myPeerID) ? meshService.myPeerID : stableKeyHex, + mentions: msg.mentions, + deliveryStatus: msg.deliveryStatus + ) + privateChats[stableKeyHex]?.append(updated) + } + privateChats[stableKeyHex]?.sort { $0.timestamp < $1.timestamp } + privateChats.removeValue(forKey: peerID) + } + if unreadPrivateMessages.contains(peerID) { + unreadPrivateMessages.remove(peerID) + unreadPrivateMessages.insert(stableKeyHex) + } + selectedPrivateChatPeer = stableKeyHex + objectWillChange.send() + } // Update peer list immediately and force UI refresh DispatchQueue.main.async { [weak self] in @@ -2615,7 +2689,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate { // MARK: - Simplified Nostr Integration (Inlined from MessageRouter) @MainActor - private func sendViaNostr(_ content: String, to recipientNostrPubkey: String, messageId: String) { + private func sendViaNostr(_ content: String, to recipientNostrPubkey: String, recipientPeerID: String, messageId: String) { guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { SecureLogger.log("No Nostr identity available", category: SecureLogger.session, level: .error) return @@ -2636,11 +2710,18 @@ class ChatViewModel: ObservableObject, BitchatDelegate { } } - // Include sender nickname in message format for better identification - let structuredContent = "MSG:\(messageId):\(nickname):\(content)" - + // Build embedded BitChat packet content (bitchat1:...) + guard let embeddedContent = meshService.buildNostrEmbeddedPrivateMessageContent( + content: content, + to: recipientPeerID, + messageID: messageId + ) else { + SecureLogger.log("Failed to build embedded BitChat content for Nostr", category: SecureLogger.session, level: .error) + return + } + guard let event = try? NostrProtocol.createPrivateMessage( - content: structuredContent, + content: embeddedContent, recipientPubkey: recipientHexPubkey, senderIdentity: senderIdentity ) else { @@ -2653,7 +2734,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate { if let relayManager = nostrRelayManager { relayManager.sendEvent(event) - SecureLogger.log("Sent Nostr message via relay", category: SecureLogger.session, level: .info) + SecureLogger.log("Sent Nostr message via relay", category: SecureLogger.session, level: .debug) // Update delivery status to sent for Nostr messages // Need to find which peerID (ephemeral or Noise key) contains this message @@ -2681,7 +2762,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate { } @MainActor - private func sendNostrAcknowledgment(messageId: String, to recipientNostrPubkey: String, type: String) { + private func sendNostrAcknowledgment(messageId: String, to recipientNostrPubkey: String, type: String, recipientPeerID: String) { guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { SecureLogger.log("No Nostr identity for sending ACK", category: SecureLogger.session, level: .error) return @@ -2702,10 +2783,11 @@ class ChatViewModel: ObservableObject, BitchatDelegate { } } - // Create ACK message format - let ackContent = "ACK:\(type):\(messageId)" - - guard let event = try? NostrProtocol.createPrivateMessage( + // Build embedded BitChat ACK content + let ackType: NoisePayloadType? = (type == "DELIVERED") ? .delivered : (type == "READ" ? .readReceipt : nil) + guard let ackTypeUnwrapped = ackType, + let ackContent = meshService.buildNostrEmbeddedAckContent(type: ackTypeUnwrapped, messageID: messageId, to: recipientPeerID), + let event = try? NostrProtocol.createPrivateMessage( content: ackContent, recipientPubkey: recipientHexPubkey, senderIdentity: senderIdentity @@ -2769,213 +2851,171 @@ class ChatViewModel: ObservableObject, BitchatDelegate { recipientIdentity: currentIdentity ) - // Log timestamp difference for debugging - // Using rumor timestamp instead of gift wrap timestamp - - // Handle favorite notifications - if content.hasPrefix("FAVORITED") || content.hasPrefix("UNFAVORITED") { - handleFavoriteNotification(content: content, from: senderPubkey) + // Expect embedded BitChat packet content + guard content.hasPrefix("bitchat1:") else { + SecureLogger.log("Ignoring non-embedded Nostr DM content", category: SecureLogger.session, level: .debug) return } - - // Handle acknowledgments - if content.hasPrefix("ACK:") { - handleNostrAcknowledgment(content: content, from: senderPubkey) + + guard let packetData = Self.base64URLDecode(String(content.dropFirst("bitchat1:".count))), + let packet = BitchatPacket.from(packetData) else { + SecureLogger.log("Failed to decode embedded BitChat packet from Nostr DM", category: SecureLogger.session, level: .error) return } - - // Handle regular messages - var messageId = UUID().uuidString - var messageContent = content - var extractedNickname: String? = nil - - if content.hasPrefix("MSG:") { - let parts = content.split(separator: ":", maxSplits: 3) - if parts.count >= 4 { - // New format with nickname: MSG:ID:NICKNAME:CONTENT - messageId = String(parts[1]) - extractedNickname = String(parts[2]) - messageContent = String(parts[3]) - } else if parts.count >= 3 { - // Old format without nickname: MSG:ID:CONTENT - messageId = String(parts[1]) - messageContent = String(parts[2]) + + // Only process typed noiseEncrypted envelope for private messages/receipts + guard packet.type == MessageType.noiseEncrypted.rawValue else { + SecureLogger.log("Unsupported embedded packet type: \(packet.type)", category: SecureLogger.session, level: .warning) + return + } + + // Validate recipient + if let rid = packet.recipientID { + let ridHex = rid.map { String(format: "%02x", $0) }.joined() + if ridHex != meshService.myPeerID { + return } } - - // Try to find sender's Noise key + + // Parse plaintext typed payload + guard let noisePayload = NoisePayload.decode(packet.payload) else { + SecureLogger.log("Failed to parse embedded NoisePayload", category: SecureLogger.session, level: .error) + return + } + + // Map sender by Nostr pubkey to Noise key when possible let senderNoiseKey = findNoiseKey(for: senderPubkey) - - // If we can't find the Noise key, try to match by nickname from the message content - // This can happen when receiving messages from someone not yet in favorites - if senderNoiseKey == nil { - SecureLogger.log("⚠️ Cannot find Noise key for Nostr sender: \(senderPubkey.prefix(16))..., will try nickname matching", - category: SecureLogger.session, level: .debug) - - // Use extracted nickname if available - handleNostrMessageFromUnknownSender( - messageId: messageId, - content: messageContent, - senderPubkey: senderPubkey, - senderNickname: extractedNickname, - timestamp: Date(timeIntervalSince1970: TimeInterval(rumorTimestamp)) - ) - return - } - - // We have the sender's Noise key, proceed normally - guard let actualSenderNoiseKey = senderNoiseKey else { return } - - let senderNoiseKeyHex = actualSenderNoiseKey.hexEncodedString() - let senderNickname = FavoritesPersistenceService.shared.getFavoriteStatus(for: actualSenderNoiseKey)?.peerNickname ?? "Unknown" + let actualSenderNoiseKey = senderNoiseKey // may be nil let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTimestamp)) - - // For Nostr messages, always use the stable Noise key hex as the peer ID - // This ensures messages persist across ephemeral peer ID changes - let targetPeerID = senderNoiseKeyHex - - // Processing message from sender - - // Check if message already exists in local storage - var messageExistsLocally = false - - // Check stable key location - if privateChats[targetPeerID]?.contains(where: { $0.id == messageId }) == true { - messageExistsLocally = true - } - - // Check all ephemeral peer locations - if !messageExistsLocally { - for (_, messages) in privateChats { - if messages.contains(where: { $0.id == messageId }) { - messageExistsLocally = true - break + let senderNickname = (actualSenderNoiseKey != nil) ? (FavoritesPersistenceService.shared.getFavoriteStatus(for: actualSenderNoiseKey!)?.peerNickname ?? "Unknown") : "Unknown" + // Stable target ID if we know Noise key; otherwise temporary Nostr-based peer + let targetPeerID = actualSenderNoiseKey?.hexEncodedString() ?? ("nostr_" + senderPubkey.prefix(16)) + + switch noisePayload.type { + case .privateMessage: + guard let (messageId, messageContent) = Self.decodePrivateMessageTLV(noisePayload.data) else { return } + + // Favorite/unfavorite notifications embedded as private messages + if messageContent.hasPrefix("[FAVORITED]") || messageContent.hasPrefix("[UNFAVORITED]") { + if let key = actualSenderNoiseKey { + handleFavoriteNotificationFromMesh(messageContent, from: key.hexEncodedString(), senderNickname: senderNickname) + } + return + } + + // Check for duplicate + var messageExistsLocally = false + if privateChats[targetPeerID]?.contains(where: { $0.id == messageId }) == true { + messageExistsLocally = true + } + if !messageExistsLocally { + for (_, messages) in privateChats { + if messages.contains(where: { $0.id == messageId }) { messageExistsLocally = true; break } } } - } - - // If message already exists locally, skip entirely - if messageExistsLocally { - return // Skipping duplicate message - } - - // Check if we've read this message before (in a previous session) - let wasReadBefore = sentReadReceipts.contains(messageId) - - // Check if message is recent (for notification purposes) - let messageAgeSeconds = Date().timeIntervalSince(messageTimestamp) - let isRecentMessage = messageAgeSeconds < 30 // Message must be less than 30 seconds old - - // Check if we're viewing this chat BEFORE adding the message - var isViewingThisChat = false - if selectedPrivateChatPeer == targetPeerID { - isViewingThisChat = true - } else if let selectedPeer = selectedPrivateChatPeer, - let selectedPeerData = unifiedPeerService.getPeer(by: selectedPeer), - selectedPeerData.noisePublicKey == actualSenderNoiseKey { - // We're viewing this chat via ephemeral ID - isViewingThisChat = true - } - - // Determine if this should be marked as unread BEFORE adding to chats - // Only mark as unread if: not previously read AND not currently viewing - // During startup phase, only block OLD messages (>30s) from being marked as unread - // Recent messages should always be marked as unread if not previously read - let shouldMarkAsUnread = !wasReadBefore && !isViewingThisChat && (isRecentMessage || !isStartupPhase) - - // Handle based on read status - - // Create message - let message = BitchatMessage( - id: messageId, - sender: senderNickname, - content: messageContent, - timestamp: messageTimestamp, - isRelay: false, - originalSender: nil, - isPrivate: true, - recipientNickname: nickname, // We are the recipient - senderPeerID: targetPeerID, - mentions: nil, - deliveryStatus: .delivered(to: nickname, at: Date()) - ) - - // Add to private chat (not public messages!) - if privateChats[targetPeerID] == nil { - privateChats[targetPeerID] = [] - } - privateChats[targetPeerID]?.append(message) - trimPrivateChatMessagesIfNeeded(for: targetPeerID) - - // IMPORTANT: Also add to ephemeral peer chat if one is open - // Find any ephemeral peer ID that maps to this stable Noise key - if let ephemeralPeerID = unifiedPeerService.peers.first(where: { peer in - peer.noisePublicKey == actualSenderNoiseKey - })?.id, ephemeralPeerID != targetPeerID { - // Also add the message to the ephemeral peer's chat - if privateChats[ephemeralPeerID] == nil { - privateChats[ephemeralPeerID] = [] + if messageExistsLocally { return } + + let wasReadBefore = sentReadReceipts.contains(messageId) + + // Is viewing? + var isViewingThisChat = false + if selectedPrivateChatPeer == targetPeerID { + isViewingThisChat = true + } else if let selectedPeer = selectedPrivateChatPeer, + let selectedPeerData = unifiedPeerService.getPeer(by: selectedPeer), + let key = actualSenderNoiseKey, + selectedPeerData.noisePublicKey == key { + isViewingThisChat = true } - // Check if message doesn't already exist to avoid duplicates - if !privateChats[ephemeralPeerID]!.contains(where: { $0.id == messageId }) { - privateChats[ephemeralPeerID]?.append(message) - trimPrivateChatMessagesIfNeeded(for: ephemeralPeerID) - } - } - - // Send delivery acknowledgment via Nostr only if we haven't read it before - // If we already read it, we've already sent DELIVERED (and probably READ) ACKs - if !wasReadBefore { - sendNostrAcknowledgment(messageId: messageId, to: senderPubkey, type: "DELIVERED") - } - - // Handle notifications and read receipts - if wasReadBefore { - // Message was read in a previous session - don't mark as unread or notify - // Just add to chat history silently - // Not marking previously-read message as unread - } else if isViewingThisChat { - // We're viewing this chat - mark as read and send read receipt - unreadPrivateMessages.remove(targetPeerID) - // Also remove ephemeral ID from unread if present - if let ephemeralPeerID = unifiedPeerService.peers.first(where: { peer in - peer.noisePublicKey == actualSenderNoiseKey - })?.id { - unreadPrivateMessages.remove(ephemeralPeerID) - } - // Send read acknowledgment - if !sentReadReceipts.contains(messageId) { - sendNostrAcknowledgment(messageId: messageId, to: senderPubkey, type: "READ") - sentReadReceipts.insert(messageId) - } - } else { - // Not viewing and not previously read - // Use pre-calculated shouldMarkAsUnread to avoid UI flicker - if shouldMarkAsUnread { - unreadPrivateMessages.insert(targetPeerID) - - // Also mark ephemeral peer ID as unread if present - if let ephemeralPeerID = unifiedPeerService.peers.first(where: { peer in - peer.noisePublicKey == actualSenderNoiseKey - })?.id, ephemeralPeerID != targetPeerID { - unreadPrivateMessages.insert(ephemeralPeerID) - } - - // Only notify if it's a recent message - if isRecentMessage { - NotificationService.shared.sendPrivateMessageNotification( - from: senderNickname, - message: messageContent, - peerID: targetPeerID - ) - } else { - // Not notifying for old message + + // Recency check + let isRecentMessage = Date().timeIntervalSince(messageTimestamp) < 30 + let shouldMarkAsUnread = !wasReadBefore && !isViewingThisChat && (isRecentMessage || !isStartupPhase) + + let message = BitchatMessage( + id: messageId, + sender: senderNickname, + content: messageContent, + timestamp: messageTimestamp, + isRelay: false, + originalSender: nil, + isPrivate: true, + recipientNickname: nickname, + senderPeerID: targetPeerID, + mentions: nil, + deliveryStatus: .delivered(to: nickname, at: Date()) + ) + + if privateChats[targetPeerID] == nil { privateChats[targetPeerID] = [] } + privateChats[targetPeerID]?.append(message) + trimPrivateChatMessagesIfNeeded(for: targetPeerID) + + // Mirror to ephemeral if applicable + if let key = actualSenderNoiseKey, + let ephemeralPeerID = unifiedPeerService.peers.first(where: { $0.noisePublicKey == key })?.id, + ephemeralPeerID != targetPeerID { + if privateChats[ephemeralPeerID] == nil { privateChats[ephemeralPeerID] = [] } + if !privateChats[ephemeralPeerID]!.contains(where: { $0.id == messageId }) { + privateChats[ephemeralPeerID]?.append(message) + trimPrivateChatMessagesIfNeeded(for: ephemeralPeerID) } } + + // Send delivery ack via Nostr embedded if not previously read and we know sender's Noise key + if !wasReadBefore, let key = actualSenderNoiseKey { + sendNostrAcknowledgment(messageId: messageId, to: senderPubkey, type: "DELIVERED", recipientPeerID: key.hexEncodedString()) + } + + if wasReadBefore { + // do nothing + } else if isViewingThisChat { + unreadPrivateMessages.remove(targetPeerID) + if let key = actualSenderNoiseKey, + let ephemeralPeerID = unifiedPeerService.peers.first(where: { $0.noisePublicKey == key })?.id { + unreadPrivateMessages.remove(ephemeralPeerID) + } + if !sentReadReceipts.contains(messageId), let key = actualSenderNoiseKey { + sendNostrAcknowledgment(messageId: messageId, to: senderPubkey, type: "READ", recipientPeerID: key.hexEncodedString()) + sentReadReceipts.insert(messageId) + } + } else { + if shouldMarkAsUnread { + unreadPrivateMessages.insert(targetPeerID) + if let key = actualSenderNoiseKey, + let ephemeralPeerID = unifiedPeerService.peers.first(where: { $0.noisePublicKey == key })?.id, + ephemeralPeerID != targetPeerID { + unreadPrivateMessages.insert(ephemeralPeerID) + } + if isRecentMessage { + NotificationService.shared.sendPrivateMessageNotification( + from: senderNickname, + message: messageContent, + peerID: targetPeerID + ) + } + } + } + + objectWillChange.send() + + case .delivered: + guard let messageID = String(data: noisePayload.data, encoding: .utf8) else { return } + let peerName = senderNickname + // Update status to delivered + if let messages = privateChats[targetPeerID], let idx = messages.firstIndex(where: { $0.id == messageID }) { + privateChats[targetPeerID]?[idx].deliveryStatus = .delivered(to: peerName, at: Date()) + objectWillChange.send() + } + + case .readReceipt: + guard let messageID = String(data: noisePayload.data, encoding: .utf8) else { return } + let peerName = senderNickname + if let messages = privateChats[targetPeerID], let idx = messages.firstIndex(where: { $0.id == messageID }) { + privateChats[targetPeerID]?[idx].deliveryStatus = .read(by: peerName, at: Date()) + objectWillChange.send() + } } - objectWillChange.send() - } catch { SecureLogger.log("Failed to decrypt Nostr message: \(error)", category: SecureLogger.session, level: .error) } @@ -3050,6 +3090,37 @@ class ChatViewModel: ObservableObject, BitchatDelegate { category: SecureLogger.session, level: .warning) } } + + // MARK: - Base64URL utils + private static func base64URLDecode(_ s: String) -> Data? { + var str = s.replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + // Add padding if needed + let rem = str.count % 4 + if rem > 0 { str.append(String(repeating: "=", count: 4 - rem)) } + return Data(base64Encoded: str) + } + + // Decode PrivateMessagePacket TLV (local minimal decoder) + private static func decodePrivateMessageTLV(_ data: Data) -> (String, String)? { + var offset = 0 + var messageID: String? + var content: String? + while offset + 2 <= data.count { + let type = data[offset]; offset += 1 + let length = Int(data[offset]); offset += 1 + guard offset + length <= data.count else { return nil } + let value = data[offset.. some View { - // Try to resolve to current peer ID if this is an old one - // resolveCurrentPeerID not implemented - let currentPeerID: String = privatePeerID + // Prefer short (mesh) ID when mesh-connected (radio). Only use full Noise key when not connected (globe). + 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 + } + } + return privatePeerID + }() - let peer = viewModel.getPeer(byID: currentPeerID) + // Resolve peer object for header context (may be offline favorite) + let peer = viewModel.getPeer(byID: headerPeerID) let privatePeerNick = peer?.displayName ?? - viewModel.meshService.getPeerNicknames()[currentPeerID] ?? - FavoritesPersistenceService.shared.getFavoriteStatus(for: Data(hexString: privatePeerID) ?? Data())?.peerNickname ?? + viewModel.meshService.getPeerNicknames()[headerPeerID] ?? + FavoritesPersistenceService.shared.getFavoriteStatus(for: Data(hexString: headerPeerID) ?? Data())?.peerNickname ?? // getFavoriteStatusByNostrKey not implemented // FavoritesPersistenceService.shared.getFavoriteStatusByNostrKey(privatePeerID)?.peerNickname ?? "Unknown" let isNostrAvailable: Bool = { guard let connectionState = peer?.connectionState else { // Check if we can reach this peer via Nostr even if not in allPeers - if let noiseKey = Data(hexString: privatePeerID), + if let noiseKey = Data(hexString: headerPeerID), let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey), favoriteStatus.isMutual { return true @@ -981,7 +989,7 @@ struct ContentView: View { ZStack { // Center content - always perfectly centered Button(action: { - viewModel.showFingerprint(for: privatePeerID) + viewModel.showFingerprint(for: headerPeerID) }) { HStack(spacing: 6) { // Show transport icon based on connection state (like peer list) @@ -1009,6 +1017,12 @@ struct ContentView: View { .font(.system(size: 14)) .foregroundColor(.purple) .accessibilityLabel("Available via Nostr") + } else if viewModel.meshService.isPeerConnected(headerPeerID) || viewModel.connectedPeers.contains(headerPeerID) { + // Fallback: if peer lookup is missing but mesh reports connected, show radio + Image(systemName: "dot.radiowaves.left.and.right") + .font(.system(size: 14)) + .foregroundColor(textColor) + .accessibilityLabel("Connected via mesh") } Text("\(privatePeerNick)") @@ -1016,7 +1030,7 @@ struct ContentView: View { .foregroundColor(textColor) // Dynamic encryption status icon - let encryptionStatus = viewModel.getEncryptionStatus(for: privatePeerID) + let encryptionStatus = viewModel.getEncryptionStatus(for: headerPeerID) if let icon = encryptionStatus.icon { Image(systemName: icon) .font(.system(size: 14)) @@ -1052,11 +1066,11 @@ struct ContentView: View { // Favorite button Button(action: { - viewModel.toggleFavorite(peerID: privatePeerID) + viewModel.toggleFavorite(peerID: headerPeerID) }) { - Image(systemName: viewModel.isFavorite(peerID: privatePeerID) ? "star.fill" : "star") + Image(systemName: viewModel.isFavorite(peerID: headerPeerID) ? "star.fill" : "star") .font(.system(size: 16)) - .foregroundColor(viewModel.isFavorite(peerID: privatePeerID) ? Color.yellow : textColor) + .foregroundColor(viewModel.isFavorite(peerID: headerPeerID) ? Color.yellow : textColor) } .buttonStyle(.plain) .accessibilityLabel(viewModel.isFavorite(peerID: privatePeerID) ? "Remove from favorites" : "Add to favorites")