mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 23:45:20 +00:00
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 <jackjackbits@users.noreply.github.com>
This commit is contained in:
@@ -18,6 +18,7 @@ xcuserdata/
|
|||||||
## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4)
|
## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4)
|
||||||
build/
|
build/
|
||||||
DerivedData/
|
DerivedData/
|
||||||
|
.DerivedData/
|
||||||
*.moved-aside
|
*.moved-aside
|
||||||
*.pbxuser
|
*.pbxuser
|
||||||
!default.pbxuser
|
!default.pbxuser
|
||||||
@@ -65,3 +66,7 @@ __pycache__/
|
|||||||
## Temporary files
|
## Temporary files
|
||||||
*.tmp
|
*.tmp
|
||||||
*.temp
|
*.temp
|
||||||
|
|
||||||
|
# Local build results
|
||||||
|
.Result*/
|
||||||
|
.Result*.xcresult/
|
||||||
|
|||||||
@@ -282,16 +282,13 @@ class PeerManager: ObservableObject {
|
|||||||
self.favorites = favorites
|
self.favorites = favorites
|
||||||
self.mutualFavorites = mutualFavorites
|
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 {
|
if favoritesService.favorites.count > 0 {
|
||||||
SecureLogger.log("📊 Peer list update: \(allPeers.count) total (\(connectedCount) connected, \(offlineCount) offline), \(favorites.count) favorites, \(mutualFavorites.count) mutual",
|
SecureLogger.log("📊 Peer list update: \(allPeers.count) total (\(connectedCount) connected, \(offlineCount) offline), \(favorites.count) favorites, \(mutualFavorites.count) mutual",
|
||||||
category: SecureLogger.session, level: .info)
|
category: SecureLogger.session, level: .debug)
|
||||||
|
|
||||||
// Detailed peer status logging removed for brevity
|
|
||||||
} else if previousCount != allPeers.count {
|
} else if previousCount != allPeers.count {
|
||||||
// Only log non-favorite updates if count changed
|
|
||||||
SecureLogger.log("✅ Updated peer list: \(allPeers.count) total peers",
|
SecureLogger.log("✅ Updated peer list: \(allPeers.count) total peers",
|
||||||
category: SecureLogger.session, level: .info)
|
category: SecureLogger.session, level: .debug)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ class NoiseSession {
|
|||||||
|
|
||||||
func processHandshakeMessage(_ message: Data) throws -> Data? {
|
func processHandshakeMessage(_ message: Data) throws -> Data? {
|
||||||
return try sessionQueue.sync(flags: .barrier) {
|
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)
|
// Initialize handshake state if needed (for responders)
|
||||||
if state == .uninitialized && role == .responder {
|
if state == .uninitialized && role == .responder {
|
||||||
@@ -103,7 +103,7 @@ class NoiseSession {
|
|||||||
remoteStaticKey: nil
|
remoteStaticKey: nil
|
||||||
)
|
)
|
||||||
state = .handshaking
|
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 {
|
guard case .handshaking = state, let handshake = handshakeState else {
|
||||||
@@ -112,7 +112,7 @@ class NoiseSession {
|
|||||||
|
|
||||||
// Process incoming message
|
// Process incoming message
|
||||||
_ = try handshake.readMessage(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
|
// Check if handshake is complete
|
||||||
if handshake.isHandshakeComplete() {
|
if handshake.isHandshakeComplete() {
|
||||||
@@ -130,7 +130,7 @@ class NoiseSession {
|
|||||||
state = .established
|
state = .established
|
||||||
handshakeState = nil // Clear handshake state
|
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))
|
SecureLogger.logSecurityEvent(.handshakeCompleted(peerID: peerID))
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -138,7 +138,7 @@ class NoiseSession {
|
|||||||
// Generate response
|
// Generate response
|
||||||
let response = try handshake.writeMessage()
|
let response = try handshake.writeMessage()
|
||||||
sentHandshakeMessages.append(response)
|
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
|
// Check if handshake is complete after writing
|
||||||
if handshake.isHandshakeComplete() {
|
if handshake.isHandshakeComplete() {
|
||||||
@@ -156,7 +156,7 @@ class NoiseSession {
|
|||||||
state = .established
|
state = .established
|
||||||
handshakeState = nil // Clear handshake state
|
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))
|
SecureLogger.logSecurityEvent(.handshakeCompleted(peerID: peerID))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -485,7 +485,7 @@ class NoiseEncryptionService {
|
|||||||
// Attempt to rekey the session
|
// Attempt to rekey the session
|
||||||
do {
|
do {
|
||||||
try sessionManager.initiateRekey(for: peerID)
|
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
|
// Signal that handshake is needed
|
||||||
onHandshakeRequired?(peerID)
|
onHandshakeRequired?(peerID)
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ final class SimplifiedBluetoothService: NSObject {
|
|||||||
// This ensures we send pending messages only when session is truly established
|
// This ensures we send pending messages only when session is truly established
|
||||||
noiseService.onPeerAuthenticated = { [weak self] peerID, fingerprint in
|
noiseService.onPeerAuthenticated = { [weak self] peerID, fingerprint in
|
||||||
SecureLogger.log("🔐 Noise session authenticated with \(peerID), fingerprint: \(fingerprint.prefix(16))...",
|
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
|
// Send any messages that were queued during handshake
|
||||||
self?.messageQueue.async { [weak self] in
|
self?.messageQueue.async { [weak self] in
|
||||||
self?.sendPendingMessagesAfterHandshake(for: peerID)
|
self?.sendPendingMessagesAfterHandshake(for: peerID)
|
||||||
@@ -341,7 +341,7 @@ final class SimplifiedBluetoothService: NSObject {
|
|||||||
|
|
||||||
func sendFavoriteNotification(to peerID: String, isFavorite: Bool) {
|
func sendFavoriteNotification(to peerID: String, isFavorite: Bool) {
|
||||||
SecureLogger.log("🔔 sendFavoriteNotification called - peerID: \(peerID), isFavorite: \(isFavorite)",
|
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
|
// Include Nostr public key in the notification
|
||||||
var content = isFavorite ? "[FAVORITED]" : "[UNFAVORITED]"
|
var content = isFavorite ? "[FAVORITED]" : "[UNFAVORITED]"
|
||||||
@@ -350,11 +350,11 @@ final class SimplifiedBluetoothService: NSObject {
|
|||||||
if let myNostrIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() {
|
if let myNostrIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() {
|
||||||
content += ":" + myNostrIdentity.npub
|
content += ":" + myNostrIdentity.npub
|
||||||
SecureLogger.log("📝 Sending favorite notification with Nostr npub: \(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)",
|
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)
|
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)",
|
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]
|
// Create read receipt payload: [type byte] + [message ID]
|
||||||
var receiptPayload = Data([NoisePayloadType.readReceipt.rawValue])
|
var receiptPayload = Data([NoisePayloadType.readReceipt.rawValue])
|
||||||
@@ -496,7 +496,7 @@ final class SimplifiedBluetoothService: NSObject {
|
|||||||
// MARK: - Private Message Handling
|
// MARK: - Private Message Handling
|
||||||
|
|
||||||
private func sendPrivateMessage(_ content: String, to recipientID: String, messageID: String) {
|
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
|
// Check if we have an established Noise session
|
||||||
if noiseService.hasEstablishedSession(with: recipientID) {
|
if noiseService.hasEstablishedSession(with: recipientID) {
|
||||||
@@ -558,7 +558,7 @@ final class SimplifiedBluetoothService: NSObject {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Queue message for sending after handshake completes
|
// 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)
|
// Queue the message (especially important for favorite notifications)
|
||||||
collectionsQueue.sync(flags: .barrier) {
|
collectionsQueue.sync(flags: .barrier) {
|
||||||
@@ -618,7 +618,7 @@ final class SimplifiedBluetoothService: NSObject {
|
|||||||
guard let messages = pendingMessages, !messages.isEmpty else { return }
|
guard let messages = pendingMessages, !messages.isEmpty else { return }
|
||||||
|
|
||||||
SecureLogger.log("📤 Sending \(messages.count) pending messages after handshake to \(peerID)",
|
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)
|
// Send each pending message directly (we know session is established)
|
||||||
for (content, messageID) in messages {
|
for (content, messageID) in messages {
|
||||||
@@ -650,7 +650,7 @@ final class SimplifiedBluetoothService: NSObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
SecureLogger.log("✅ Sent pending message \(messageID) to \(peerID) after handshake",
|
SecureLogger.log("✅ Sent pending message \(messageID) to \(peerID) after handshake",
|
||||||
category: SecureLogger.session, level: .info)
|
category: SecureLogger.session, level: .debug)
|
||||||
} catch {
|
} catch {
|
||||||
SecureLogger.log("Failed to send pending message after handshake: \(error)",
|
SecureLogger.log("Failed to send pending message after handshake: \(error)",
|
||||||
category: SecureLogger.noise, level: .error)
|
category: SecureLogger.noise, level: .error)
|
||||||
@@ -675,7 +675,7 @@ final class SimplifiedBluetoothService: NSObject {
|
|||||||
// Log encrypted and relayed packets for debugging
|
// Log encrypted and relayed packets for debugging
|
||||||
if packet.type == MessageType.noiseEncrypted.rawValue {
|
if packet.type == MessageType.noiseEncrypted.rawValue {
|
||||||
SecureLogger.log("📡 Encrypted packet to \(packet.recipientID?.hexEncodedString() ?? "unknown")",
|
SecureLogger.log("📡 Encrypted packet to \(packet.recipientID?.hexEncodedString() ?? "unknown")",
|
||||||
category: SecureLogger.session, level: .info)
|
category: SecureLogger.session, level: .debug)
|
||||||
} else if packet.ttl < messageTTL {
|
} else if packet.ttl < messageTTL {
|
||||||
// Relayed packet
|
// Relayed packet
|
||||||
}
|
}
|
||||||
@@ -1042,11 +1042,11 @@ final class SimplifiedBluetoothService: NSObject {
|
|||||||
|
|
||||||
// Log connection status
|
// Log connection status
|
||||||
if existingPeer == nil {
|
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 {
|
} 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 {
|
} 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"
|
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
|
// Parse mentions from the message content
|
||||||
let mentions = parseMentions(from: content)
|
let mentions = parseMentions(from: content)
|
||||||
@@ -1231,7 +1231,7 @@ final class SimplifiedBluetoothService: NSObject {
|
|||||||
mentions: mentions.isEmpty ? nil : mentions
|
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
|
// Send on main thread
|
||||||
notifyUI { [weak self] in
|
notifyUI { [weak self] in
|
||||||
@@ -1262,7 +1262,7 @@ final class SimplifiedBluetoothService: NSObject {
|
|||||||
guard let messageID = String(data: payloadData, encoding: .utf8) else { return }
|
guard let messageID = String(data: payloadData, encoding: .utf8) else { return }
|
||||||
|
|
||||||
SecureLogger.log("📖 Received READ receipt for message \(messageID) from \(peerID)",
|
SecureLogger.log("📖 Received READ receipt for message \(messageID) from \(peerID)",
|
||||||
category: SecureLogger.session, level: .info)
|
category: SecureLogger.session, level: .debug)
|
||||||
|
|
||||||
// Update read status
|
// Update read status
|
||||||
notifyUI { [weak self] in
|
notifyUI { [weak self] in
|
||||||
@@ -1277,7 +1277,7 @@ final class SimplifiedBluetoothService: NSObject {
|
|||||||
// We received an encrypted message before establishing a session with this peer.
|
// We received an encrypted message before establishing a session with this peer.
|
||||||
// Trigger a handshake so future messages can be decrypted.
|
// Trigger a handshake so future messages can be decrypted.
|
||||||
SecureLogger.log("🔑 Encrypted message from \(peerID) without session; initiating handshake",
|
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) {
|
if !noiseService.hasSession(with: peerID) {
|
||||||
initiateNoiseHandshake(with: peerID)
|
initiateNoiseHandshake(with: peerID)
|
||||||
}
|
}
|
||||||
@@ -1308,7 +1308,7 @@ final class SimplifiedBluetoothService: NSObject {
|
|||||||
// MARK: - Helper Functions
|
// MARK: - Helper Functions
|
||||||
|
|
||||||
private func sendLeave() {
|
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(
|
let packet = BitchatPacket(
|
||||||
type: MessageType.leave.rawValue,
|
type: MessageType.leave.rawValue,
|
||||||
ttl: messageTTL,
|
ttl: messageTTL,
|
||||||
@@ -1460,7 +1460,7 @@ final class SimplifiedBluetoothService: NSObject {
|
|||||||
if !hasPeripheralConnection && !hasCentralConnection {
|
if !hasPeripheralConnection && !hasCentralConnection {
|
||||||
// Remove the peer completely (they'll be re-added when they reconnect)
|
// Remove the peer completely (they'll be re-added when they reconnect)
|
||||||
SecureLogger.log("⏱️ Peer timed out (no packets for 20s): \(peerID) (\(peer.nickname))",
|
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)
|
peers.removeValue(forKey: peerID)
|
||||||
disconnectedPeers.append(peerID)
|
disconnectedPeers.append(peerID)
|
||||||
}
|
}
|
||||||
@@ -1584,7 +1584,7 @@ extension SimplifiedBluetoothService: CBCentralManagerDelegate {
|
|||||||
|
|
||||||
// Connect to the peripheral with options for faster connection
|
// Connect to the peripheral with options for faster connection
|
||||||
SecureLogger.log("📱 Connect: \(advertisedName) [RSSI:\(rssiValue)]",
|
SecureLogger.log("📱 Connect: \(advertisedName) [RSSI:\(rssiValue)]",
|
||||||
category: SecureLogger.session, level: .info)
|
category: SecureLogger.session, level: .debug)
|
||||||
|
|
||||||
// Use connection options for faster reconnection
|
// Use connection options for faster reconnection
|
||||||
let options: [String: Any] = [
|
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
|
// Discover services
|
||||||
peripheral.discoverServices([SimplifiedBluetoothService.serviceUUID])
|
peripheral.discoverServices([SimplifiedBluetoothService.serviceUUID])
|
||||||
@@ -1640,7 +1640,7 @@ extension SimplifiedBluetoothService: CBCentralManagerDelegate {
|
|||||||
let peerID = peripherals[peripheralID]?.peerID
|
let peerID = peripherals[peripheralID]?.peerID
|
||||||
|
|
||||||
SecureLogger.log("📱 Disconnect: \(peerID ?? peripheralID)\(error != nil ? " (\(error!.localizedDescription))" : "")",
|
SecureLogger.log("📱 Disconnect: \(peerID ?? peripheralID)\(error != nil ? " (\(error!.localizedDescription))" : "")",
|
||||||
category: SecureLogger.session, level: .info)
|
category: SecureLogger.session, level: .debug)
|
||||||
|
|
||||||
// Clean up references
|
// Clean up references
|
||||||
peripherals.removeValue(forKey: peripheralID)
|
peripherals.removeValue(forKey: peripheralID)
|
||||||
@@ -1895,13 +1895,13 @@ extension SimplifiedBluetoothService: CBPeripheralManagerDelegate {
|
|||||||
return
|
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
|
// Start advertising after service is confirmed added
|
||||||
let adData = buildAdvertisementData()
|
let adData = buildAdvertisementData()
|
||||||
peripheral.startAdvertising(adData)
|
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) {
|
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) {
|
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 }
|
subscribedCentrals.removeAll { $0.identifier == central.identifier }
|
||||||
|
|
||||||
// Ensure we're still advertising for other devices to find us
|
// Ensure we're still advertising for other devices to find us
|
||||||
if peripheral.isAdvertising == false {
|
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())
|
peripheral.startAdvertising(buildAdvertisementData())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1948,7 +1948,7 @@ extension SimplifiedBluetoothService: CBPeripheralManagerDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func peripheralManagerIsReady(toUpdateSubscribers peripheral: CBPeripheralManager) {
|
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
|
// Retry pending notifications now that queue has space
|
||||||
collectionsQueue.async(flags: .barrier) { [weak self] in
|
collectionsQueue.async(flags: .barrier) { [weak self] in
|
||||||
@@ -1995,7 +1995,7 @@ extension SimplifiedBluetoothService: CBPeripheralManagerDelegate {
|
|||||||
func peripheralManager(_ peripheral: CBPeripheralManager, didReceiveWrite requests: [CBATTRequest]) {
|
func peripheralManager(_ peripheral: CBPeripheralManager, didReceiveWrite requests: [CBATTRequest]) {
|
||||||
// Suppress logs for single write requests to reduce noise
|
// Suppress logs for single write requests to reduce noise
|
||||||
if requests.count > 1 {
|
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!
|
// IMPORTANT: Respond immediately to prevent timeouts!
|
||||||
@@ -2022,7 +2022,7 @@ extension SimplifiedBluetoothService: CBPeripheralManagerDelegate {
|
|||||||
let senderID = dataToHexString(packet.senderID)
|
let senderID = dataToHexString(packet.senderID)
|
||||||
// Only log non-announce packets
|
// Only log non-announce packets
|
||||||
if packet.type != MessageType.announce.rawValue {
|
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
|
// Store central in our list if not already there
|
||||||
@@ -2089,7 +2089,7 @@ extension SimplifiedBluetoothService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func buildAdvertisementData() -> [String: Any] {
|
private func buildAdvertisementData() -> [String: Any] {
|
||||||
var data: [String: Any] = [
|
let data: [String: Any] = [
|
||||||
CBAdvertisementDataServiceUUIDsKey: [SimplifiedBluetoothService.serviceUUID]
|
CBAdvertisementDataServiceUUIDsKey: [SimplifiedBluetoothService.serviceUUID]
|
||||||
]
|
]
|
||||||
// No Local Name for privacy
|
// No Local Name for privacy
|
||||||
@@ -2099,6 +2099,96 @@ extension SimplifiedBluetoothService {
|
|||||||
// No alias rotation or advertising restarts required.
|
// 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
|
// MARK: - Message Deduplicator
|
||||||
|
|
||||||
/// Efficient message deduplication with time-based cleanup
|
/// Efficient message deduplication with time-based cleanup
|
||||||
|
|||||||
@@ -190,7 +190,7 @@ class UnifiedPeerService: ObservableObject {
|
|||||||
SecureLogger.log(
|
SecureLogger.log(
|
||||||
"🔄 Found favorite for '\(peerInfo.nickname)' by nickname, updating noise key",
|
"🔄 Found favorite for '\(peerInfo.nickname)' by nickname, updating noise key",
|
||||||
category: SecureLogger.session,
|
category: SecureLogger.session,
|
||||||
level: .info
|
level: .debug
|
||||||
)
|
)
|
||||||
|
|
||||||
// Update the favorite's key in persistence
|
// Update the favorite's key in persistence
|
||||||
@@ -277,7 +277,7 @@ class UnifiedPeerService: ObservableObject {
|
|||||||
|
|
||||||
// Debug logging to understand the issue
|
// Debug logging to understand the issue
|
||||||
SecureLogger.log("🔍 Toggle favorite - peer.nickname: '\(peer.nickname)', peer.displayName: '\(peer.displayName)', peerID: \(peerID)",
|
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 {
|
if actualNickname.isEmpty {
|
||||||
// Try to get from mesh service's current peer list
|
// Try to get from mesh service's current peer list
|
||||||
@@ -312,7 +312,7 @@ class UnifiedPeerService: ObservableObject {
|
|||||||
|
|
||||||
// Log the final nickname being saved
|
// Log the final nickname being saved
|
||||||
SecureLogger.log("⭐️ Toggled favorite for '\(finalNickname)' (peerID: \(peerID), was: \(wasFavorite), now: \(!wasFavorite))",
|
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
|
// Send favorite notification to the peer
|
||||||
meshService.sendFavoriteNotification(to: peerID, isFavorite: !wasFavorite)
|
meshService.sendFavoriteNotification(to: peerID, isFavorite: !wasFavorite)
|
||||||
|
|||||||
@@ -58,6 +58,16 @@ class SecureLogger {
|
|||||||
case error
|
case error
|
||||||
case fault
|
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 {
|
var osLogType: OSLogType {
|
||||||
switch self {
|
switch self {
|
||||||
case .debug: return .debug
|
case .debug: return .debug
|
||||||
@@ -69,6 +79,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
|
// MARK: - Security Event Types
|
||||||
|
|
||||||
enum SecurityEvent {
|
enum SecurityEvent {
|
||||||
@@ -99,6 +127,7 @@ class SecureLogger {
|
|||||||
/// Log a security event
|
/// Log a security event
|
||||||
static func logSecurityEvent(_ event: SecurityEvent, level: LogLevel = .info,
|
static func logSecurityEvent(_ event: SecurityEvent, level: LogLevel = .info,
|
||||||
file: String = #file, line: Int = #line, function: String = #function) {
|
file: String = #file, line: Int = #line, function: String = #function) {
|
||||||
|
guard shouldLog(level) else { return }
|
||||||
let location = formatLocation(file: file, line: line, function: function)
|
let location = formatLocation(file: file, line: line, function: function)
|
||||||
let message = "\(location) \(event.message)"
|
let message = "\(location) \(event.message)"
|
||||||
|
|
||||||
@@ -113,6 +142,7 @@ class SecureLogger {
|
|||||||
/// Log general messages with automatic sensitive data filtering
|
/// Log general messages with automatic sensitive data filtering
|
||||||
static func log(_ message: String, category: OSLog = noise, level: LogLevel = .debug,
|
static func log(_ message: String, category: OSLog = noise, level: LogLevel = .debug,
|
||||||
file: String = #file, line: Int = #line, function: String = #function) {
|
file: String = #file, line: Int = #line, function: String = #function) {
|
||||||
|
guard shouldLog(level) else { return }
|
||||||
let location = formatLocation(file: file, line: line, function: function)
|
let location = formatLocation(file: file, line: line, function: function)
|
||||||
let sanitized = sanitize("\(location) \(message)")
|
let sanitized = sanitize("\(location) \(message)")
|
||||||
|
|
||||||
|
|||||||
@@ -151,6 +151,22 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
// Missing properties that were removed during refactoring
|
// Missing properties that were removed during refactoring
|
||||||
private var peerIDToPublicKeyFingerprint: [String: String] = [:]
|
private var peerIDToPublicKeyFingerprint: [String: String] = [:]
|
||||||
private var selectedPrivateChatFingerprint: String? = nil
|
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] = [:]
|
private var peerIndex: [String: BitchatPeer] = [:]
|
||||||
|
|
||||||
// MARK: - Autocomplete Properties
|
// MARK: - Autocomplete Properties
|
||||||
@@ -848,7 +864,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
} else if isMutualFavorite && hasNostrKey,
|
} else if isMutualFavorite && hasNostrKey,
|
||||||
let recipientNostrPubkey = favoriteStatus?.peerNostrPublicKey {
|
let recipientNostrPubkey = favoriteStatus?.peerNostrPublicKey {
|
||||||
// Mutual favorite offline - send via Nostr
|
// Mutual favorite offline - send via Nostr
|
||||||
sendViaNostr(content, to: recipientNostrPubkey, messageId: messageID)
|
sendViaNostr(content, to: recipientNostrPubkey, recipientPeerID: peerID, messageId: messageID)
|
||||||
} else {
|
} else {
|
||||||
// Update delivery status to failed
|
// Update delivery status to failed
|
||||||
if let index = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
|
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 {
|
if (message.senderPeerID == peerID || message.senderPeerID == noiseKeyHex) && !message.isRelay {
|
||||||
// Skip if we already sent an ACK for this message
|
// Skip if we already sent an ACK for this message
|
||||||
if !sentReadReceipts.contains(message.id) {
|
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)
|
sentReadReceipts.insert(message.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2222,7 +2240,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
guard let self = self else { return }
|
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
|
// Update encryption status
|
||||||
if self.verifiedFingerprints.contains(fingerprint) {
|
if self.verifiedFingerprints.contains(fingerprint) {
|
||||||
@@ -2236,6 +2254,15 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
// Invalidate cache when encryption status changes
|
// Invalidate cache when encryption status changes
|
||||||
self.invalidateEncryptionCache(for: peerID)
|
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
|
// Schedule UI update
|
||||||
// UI will update automatically
|
// UI will update automatically
|
||||||
}
|
}
|
||||||
@@ -2323,6 +2350,12 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
|
|
||||||
// Force UI refresh
|
// Force UI refresh
|
||||||
objectWillChange.send()
|
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
|
// Connection messages removed to reduce chat noise
|
||||||
@@ -2334,6 +2367,47 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
// Remove ephemeral session from identity manager
|
// Remove ephemeral session from identity manager
|
||||||
SecureIdentityStateManager.shared.removeEphemeralSession(peerID: peerID)
|
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
|
// Update peer list immediately and force UI refresh
|
||||||
DispatchQueue.main.async { [weak self] in
|
DispatchQueue.main.async { [weak self] in
|
||||||
// UnifiedPeerService updates automatically via subscriptions
|
// UnifiedPeerService updates automatically via subscriptions
|
||||||
@@ -2615,7 +2689,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
// MARK: - Simplified Nostr Integration (Inlined from MessageRouter)
|
// MARK: - Simplified Nostr Integration (Inlined from MessageRouter)
|
||||||
|
|
||||||
@MainActor
|
@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 {
|
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else {
|
||||||
SecureLogger.log("No Nostr identity available", category: SecureLogger.session, level: .error)
|
SecureLogger.log("No Nostr identity available", category: SecureLogger.session, level: .error)
|
||||||
return
|
return
|
||||||
@@ -2636,11 +2710,18 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Include sender nickname in message format for better identification
|
// Build embedded BitChat packet content (bitchat1:...)
|
||||||
let structuredContent = "MSG:\(messageId):\(nickname):\(content)"
|
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(
|
guard let event = try? NostrProtocol.createPrivateMessage(
|
||||||
content: structuredContent,
|
content: embeddedContent,
|
||||||
recipientPubkey: recipientHexPubkey,
|
recipientPubkey: recipientHexPubkey,
|
||||||
senderIdentity: senderIdentity
|
senderIdentity: senderIdentity
|
||||||
) else {
|
) else {
|
||||||
@@ -2653,7 +2734,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
|
|
||||||
if let relayManager = nostrRelayManager {
|
if let relayManager = nostrRelayManager {
|
||||||
relayManager.sendEvent(event)
|
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
|
// Update delivery status to sent for Nostr messages
|
||||||
// Need to find which peerID (ephemeral or Noise key) contains this message
|
// Need to find which peerID (ephemeral or Noise key) contains this message
|
||||||
@@ -2681,7 +2762,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@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 {
|
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else {
|
||||||
SecureLogger.log("No Nostr identity for sending ACK", category: SecureLogger.session, level: .error)
|
SecureLogger.log("No Nostr identity for sending ACK", category: SecureLogger.session, level: .error)
|
||||||
return
|
return
|
||||||
@@ -2702,10 +2783,11 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create ACK message format
|
// Build embedded BitChat ACK content
|
||||||
let ackContent = "ACK:\(type):\(messageId)"
|
let ackType: NoisePayloadType? = (type == "DELIVERED") ? .delivered : (type == "READ" ? .readReceipt : nil)
|
||||||
|
guard let ackTypeUnwrapped = ackType,
|
||||||
guard let event = try? NostrProtocol.createPrivateMessage(
|
let ackContent = meshService.buildNostrEmbeddedAckContent(type: ackTypeUnwrapped, messageID: messageId, to: recipientPeerID),
|
||||||
|
let event = try? NostrProtocol.createPrivateMessage(
|
||||||
content: ackContent,
|
content: ackContent,
|
||||||
recipientPubkey: recipientHexPubkey,
|
recipientPubkey: recipientHexPubkey,
|
||||||
senderIdentity: senderIdentity
|
senderIdentity: senderIdentity
|
||||||
@@ -2769,123 +2851,87 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
recipientIdentity: currentIdentity
|
recipientIdentity: currentIdentity
|
||||||
)
|
)
|
||||||
|
|
||||||
// Log timestamp difference for debugging
|
// Expect embedded BitChat packet content
|
||||||
// Using rumor timestamp instead of gift wrap timestamp
|
guard content.hasPrefix("bitchat1:") else {
|
||||||
|
SecureLogger.log("Ignoring non-embedded Nostr DM content", category: SecureLogger.session, level: .debug)
|
||||||
// Handle favorite notifications
|
|
||||||
if content.hasPrefix("FAVORITED") || content.hasPrefix("UNFAVORITED") {
|
|
||||||
handleFavoriteNotification(content: content, from: senderPubkey)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle acknowledgments
|
guard let packetData = Self.base64URLDecode(String(content.dropFirst("bitchat1:".count))),
|
||||||
if content.hasPrefix("ACK:") {
|
let packet = BitchatPacket.from(packetData) else {
|
||||||
handleNostrAcknowledgment(content: content, from: senderPubkey)
|
SecureLogger.log("Failed to decode embedded BitChat packet from Nostr DM", category: SecureLogger.session, level: .error)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle regular messages
|
// Only process typed noiseEncrypted envelope for private messages/receipts
|
||||||
var messageId = UUID().uuidString
|
guard packet.type == MessageType.noiseEncrypted.rawValue else {
|
||||||
var messageContent = content
|
SecureLogger.log("Unsupported embedded packet type: \(packet.type)", category: SecureLogger.session, level: .warning)
|
||||||
var extractedNickname: String? = nil
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if content.hasPrefix("MSG:") {
|
// Validate recipient
|
||||||
let parts = content.split(separator: ":", maxSplits: 3)
|
if let rid = packet.recipientID {
|
||||||
if parts.count >= 4 {
|
let ridHex = rid.map { String(format: "%02x", $0) }.joined()
|
||||||
// New format with nickname: MSG:ID:NICKNAME:CONTENT
|
if ridHex != meshService.myPeerID {
|
||||||
messageId = String(parts[1])
|
return
|
||||||
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])
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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)
|
let senderNoiseKey = findNoiseKey(for: senderPubkey)
|
||||||
|
let actualSenderNoiseKey = senderNoiseKey // may be nil
|
||||||
|
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTimestamp))
|
||||||
|
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))
|
||||||
|
|
||||||
// If we can't find the Noise key, try to match by nickname from the message content
|
switch noisePayload.type {
|
||||||
// This can happen when receiving messages from someone not yet in favorites
|
case .privateMessage:
|
||||||
if senderNoiseKey == nil {
|
guard let (messageId, messageContent) = Self.decodePrivateMessageTLV(noisePayload.data) else { return }
|
||||||
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
|
// Favorite/unfavorite notifications embedded as private messages
|
||||||
handleNostrMessageFromUnknownSender(
|
if messageContent.hasPrefix("[FAVORITED]") || messageContent.hasPrefix("[UNFAVORITED]") {
|
||||||
messageId: messageId,
|
if let key = actualSenderNoiseKey {
|
||||||
content: messageContent,
|
handleFavoriteNotificationFromMesh(messageContent, from: key.hexEncodedString(), senderNickname: senderNickname)
|
||||||
senderPubkey: senderPubkey,
|
}
|
||||||
senderNickname: extractedNickname,
|
|
||||||
timestamp: Date(timeIntervalSince1970: TimeInterval(rumorTimestamp))
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// We have the sender's Noise key, proceed normally
|
// Check for duplicate
|
||||||
guard let actualSenderNoiseKey = senderNoiseKey else { return }
|
|
||||||
|
|
||||||
let senderNoiseKeyHex = actualSenderNoiseKey.hexEncodedString()
|
|
||||||
let senderNickname = FavoritesPersistenceService.shared.getFavoriteStatus(for: actualSenderNoiseKey)?.peerNickname ?? "Unknown"
|
|
||||||
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
|
var messageExistsLocally = false
|
||||||
|
|
||||||
// Check stable key location
|
|
||||||
if privateChats[targetPeerID]?.contains(where: { $0.id == messageId }) == true {
|
if privateChats[targetPeerID]?.contains(where: { $0.id == messageId }) == true {
|
||||||
messageExistsLocally = true
|
messageExistsLocally = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check all ephemeral peer locations
|
|
||||||
if !messageExistsLocally {
|
if !messageExistsLocally {
|
||||||
for (_, messages) in privateChats {
|
for (_, messages) in privateChats {
|
||||||
if messages.contains(where: { $0.id == messageId }) {
|
if messages.contains(where: { $0.id == messageId }) { messageExistsLocally = true; break }
|
||||||
messageExistsLocally = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if messageExistsLocally { return }
|
||||||
|
|
||||||
// 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)
|
let wasReadBefore = sentReadReceipts.contains(messageId)
|
||||||
|
|
||||||
// Check if message is recent (for notification purposes)
|
// Is viewing?
|
||||||
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
|
var isViewingThisChat = false
|
||||||
if selectedPrivateChatPeer == targetPeerID {
|
if selectedPrivateChatPeer == targetPeerID {
|
||||||
isViewingThisChat = true
|
isViewingThisChat = true
|
||||||
} else if let selectedPeer = selectedPrivateChatPeer,
|
} else if let selectedPeer = selectedPrivateChatPeer,
|
||||||
let selectedPeerData = unifiedPeerService.getPeer(by: selectedPeer),
|
let selectedPeerData = unifiedPeerService.getPeer(by: selectedPeer),
|
||||||
selectedPeerData.noisePublicKey == actualSenderNoiseKey {
|
let key = actualSenderNoiseKey,
|
||||||
// We're viewing this chat via ephemeral ID
|
selectedPeerData.noisePublicKey == key {
|
||||||
isViewingThisChat = true
|
isViewingThisChat = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine if this should be marked as unread BEFORE adding to chats
|
// Recency check
|
||||||
// Only mark as unread if: not previously read AND not currently viewing
|
let isRecentMessage = Date().timeIntervalSince(messageTimestamp) < 30
|
||||||
// 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)
|
let shouldMarkAsUnread = !wasReadBefore && !isViewingThisChat && (isRecentMessage || !isStartupPhase)
|
||||||
|
|
||||||
// Handle based on read status
|
|
||||||
|
|
||||||
// Create message
|
|
||||||
let message = BitchatMessage(
|
let message = BitchatMessage(
|
||||||
id: messageId,
|
id: messageId,
|
||||||
sender: senderNickname,
|
sender: senderNickname,
|
||||||
@@ -2894,88 +2940,82 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
isRelay: false,
|
isRelay: false,
|
||||||
originalSender: nil,
|
originalSender: nil,
|
||||||
isPrivate: true,
|
isPrivate: true,
|
||||||
recipientNickname: nickname, // We are the recipient
|
recipientNickname: nickname,
|
||||||
senderPeerID: targetPeerID,
|
senderPeerID: targetPeerID,
|
||||||
mentions: nil,
|
mentions: nil,
|
||||||
deliveryStatus: .delivered(to: nickname, at: Date())
|
deliveryStatus: .delivered(to: nickname, at: Date())
|
||||||
)
|
)
|
||||||
|
|
||||||
// Add to private chat (not public messages!)
|
if privateChats[targetPeerID] == nil { privateChats[targetPeerID] = [] }
|
||||||
if privateChats[targetPeerID] == nil {
|
|
||||||
privateChats[targetPeerID] = []
|
|
||||||
}
|
|
||||||
privateChats[targetPeerID]?.append(message)
|
privateChats[targetPeerID]?.append(message)
|
||||||
trimPrivateChatMessagesIfNeeded(for: targetPeerID)
|
trimPrivateChatMessagesIfNeeded(for: targetPeerID)
|
||||||
|
|
||||||
// IMPORTANT: Also add to ephemeral peer chat if one is open
|
// Mirror to ephemeral if applicable
|
||||||
// Find any ephemeral peer ID that maps to this stable Noise key
|
if let key = actualSenderNoiseKey,
|
||||||
if let ephemeralPeerID = unifiedPeerService.peers.first(where: { peer in
|
let ephemeralPeerID = unifiedPeerService.peers.first(where: { $0.noisePublicKey == key })?.id,
|
||||||
peer.noisePublicKey == actualSenderNoiseKey
|
ephemeralPeerID != targetPeerID {
|
||||||
})?.id, ephemeralPeerID != targetPeerID {
|
if privateChats[ephemeralPeerID] == nil { privateChats[ephemeralPeerID] = [] }
|
||||||
// Also add the message to the ephemeral peer's chat
|
|
||||||
if privateChats[ephemeralPeerID] == nil {
|
|
||||||
privateChats[ephemeralPeerID] = []
|
|
||||||
}
|
|
||||||
// Check if message doesn't already exist to avoid duplicates
|
|
||||||
if !privateChats[ephemeralPeerID]!.contains(where: { $0.id == messageId }) {
|
if !privateChats[ephemeralPeerID]!.contains(where: { $0.id == messageId }) {
|
||||||
privateChats[ephemeralPeerID]?.append(message)
|
privateChats[ephemeralPeerID]?.append(message)
|
||||||
trimPrivateChatMessagesIfNeeded(for: ephemeralPeerID)
|
trimPrivateChatMessagesIfNeeded(for: ephemeralPeerID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send delivery acknowledgment via Nostr only if we haven't read it before
|
// Send delivery ack via Nostr embedded if not previously read and we know sender's Noise key
|
||||||
// If we already read it, we've already sent DELIVERED (and probably READ) ACKs
|
if !wasReadBefore, let key = actualSenderNoiseKey {
|
||||||
if !wasReadBefore {
|
sendNostrAcknowledgment(messageId: messageId, to: senderPubkey, type: "DELIVERED", recipientPeerID: key.hexEncodedString())
|
||||||
sendNostrAcknowledgment(messageId: messageId, to: senderPubkey, type: "DELIVERED")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle notifications and read receipts
|
|
||||||
if wasReadBefore {
|
if wasReadBefore {
|
||||||
// Message was read in a previous session - don't mark as unread or notify
|
// do nothing
|
||||||
// Just add to chat history silently
|
|
||||||
// Not marking previously-read message as unread
|
|
||||||
} else if isViewingThisChat {
|
} else if isViewingThisChat {
|
||||||
// We're viewing this chat - mark as read and send read receipt
|
|
||||||
unreadPrivateMessages.remove(targetPeerID)
|
unreadPrivateMessages.remove(targetPeerID)
|
||||||
// Also remove ephemeral ID from unread if present
|
if let key = actualSenderNoiseKey,
|
||||||
if let ephemeralPeerID = unifiedPeerService.peers.first(where: { peer in
|
let ephemeralPeerID = unifiedPeerService.peers.first(where: { $0.noisePublicKey == key })?.id {
|
||||||
peer.noisePublicKey == actualSenderNoiseKey
|
|
||||||
})?.id {
|
|
||||||
unreadPrivateMessages.remove(ephemeralPeerID)
|
unreadPrivateMessages.remove(ephemeralPeerID)
|
||||||
}
|
}
|
||||||
// Send read acknowledgment
|
if !sentReadReceipts.contains(messageId), let key = actualSenderNoiseKey {
|
||||||
if !sentReadReceipts.contains(messageId) {
|
sendNostrAcknowledgment(messageId: messageId, to: senderPubkey, type: "READ", recipientPeerID: key.hexEncodedString())
|
||||||
sendNostrAcknowledgment(messageId: messageId, to: senderPubkey, type: "READ")
|
|
||||||
sentReadReceipts.insert(messageId)
|
sentReadReceipts.insert(messageId)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Not viewing and not previously read
|
|
||||||
// Use pre-calculated shouldMarkAsUnread to avoid UI flicker
|
|
||||||
if shouldMarkAsUnread {
|
if shouldMarkAsUnread {
|
||||||
unreadPrivateMessages.insert(targetPeerID)
|
unreadPrivateMessages.insert(targetPeerID)
|
||||||
|
if let key = actualSenderNoiseKey,
|
||||||
// Also mark ephemeral peer ID as unread if present
|
let ephemeralPeerID = unifiedPeerService.peers.first(where: { $0.noisePublicKey == key })?.id,
|
||||||
if let ephemeralPeerID = unifiedPeerService.peers.first(where: { peer in
|
ephemeralPeerID != targetPeerID {
|
||||||
peer.noisePublicKey == actualSenderNoiseKey
|
|
||||||
})?.id, ephemeralPeerID != targetPeerID {
|
|
||||||
unreadPrivateMessages.insert(ephemeralPeerID)
|
unreadPrivateMessages.insert(ephemeralPeerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only notify if it's a recent message
|
|
||||||
if isRecentMessage {
|
if isRecentMessage {
|
||||||
NotificationService.shared.sendPrivateMessageNotification(
|
NotificationService.shared.sendPrivateMessageNotification(
|
||||||
from: senderNickname,
|
from: senderNickname,
|
||||||
message: messageContent,
|
message: messageContent,
|
||||||
peerID: targetPeerID
|
peerID: targetPeerID
|
||||||
)
|
)
|
||||||
} else {
|
|
||||||
// Not notifying for old message
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
objectWillChange.send()
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
} catch {
|
} catch {
|
||||||
SecureLogger.log("Failed to decrypt Nostr message: \(error)", category: SecureLogger.session, level: .error)
|
SecureLogger.log("Failed to decrypt Nostr message: \(error)", category: SecureLogger.session, level: .error)
|
||||||
}
|
}
|
||||||
@@ -3051,6 +3091,37 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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..<offset+length]
|
||||||
|
offset += length
|
||||||
|
if type == 0x00 {
|
||||||
|
messageID = String(data: value, encoding: .utf8)
|
||||||
|
} else if type == 0x01 {
|
||||||
|
content = String(data: value, encoding: .utf8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let id = messageID, let c = content { return (id, c) }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func handleFavoriteNotificationFromMesh(_ content: String, from peerID: String, senderNickname: String) {
|
private func handleFavoriteNotificationFromMesh(_ content: String, from peerID: String, senderNickname: String) {
|
||||||
// Parse the message format: "[FAVORITED]:npub..." or "[UNFAVORITED]:npub..."
|
// Parse the message format: "[FAVORITED]:npub..." or "[UNFAVORITED]:npub..."
|
||||||
@@ -3178,10 +3249,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
privateChats[tempPeerID]?.append(message)
|
privateChats[tempPeerID]?.append(message)
|
||||||
trimPrivateChatMessagesIfNeeded(for: tempPeerID)
|
trimPrivateChatMessagesIfNeeded(for: tempPeerID)
|
||||||
|
|
||||||
// Send delivery acknowledgment only if we haven't read it before
|
// For unknown senders (no Noise key), skip sending Nostr ACKs
|
||||||
if !wasReadBefore {
|
|
||||||
sendNostrAcknowledgment(messageId: messageId, to: senderPubkey, type: "DELIVERED")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle based on read status
|
// Handle based on read status
|
||||||
if wasReadBefore {
|
if wasReadBefore {
|
||||||
@@ -3189,10 +3257,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
// Not marking previously-read message as unread
|
// Not marking previously-read message as unread
|
||||||
} else if isViewingThisChat {
|
} else if isViewingThisChat {
|
||||||
// Viewing this chat - mark as read
|
// Viewing this chat - mark as read
|
||||||
if !sentReadReceipts.contains(messageId) {
|
// No read ACKs for unknown senders
|
||||||
sendNostrAcknowledgment(messageId: messageId, to: senderPubkey, type: "READ")
|
|
||||||
sentReadReceipts.insert(messageId)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Not viewing and not previously read
|
// Not viewing and not previously read
|
||||||
// Use pre-calculated shouldMarkAsUnread to avoid UI flicker
|
// Use pre-calculated shouldMarkAsUnread to avoid UI flicker
|
||||||
@@ -3272,20 +3337,20 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
|
|
||||||
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
|
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
|
||||||
|
|
||||||
let content = isFavorite ? "FAVORITED:\(senderIdentity.npub)" : "UNFAVORITED:\(senderIdentity.npub)"
|
let content = isFavorite ? "[FAVORITED]:\(senderIdentity.npub)" : "[UNFAVORITED]:\(senderIdentity.npub)"
|
||||||
|
let recipientPeerID = noisePublicKey.hexEncodedString()
|
||||||
if let event = try? NostrProtocol.createPrivateMessage(
|
guard let embedded = meshService.buildNostrEmbeddedPrivateMessageContent(content: content, to: recipientPeerID, messageID: UUID().uuidString),
|
||||||
content: content,
|
let event = try? NostrProtocol.createPrivateMessage(
|
||||||
|
content: embedded,
|
||||||
recipientPubkey: recipientNostrPubkey,
|
recipientPubkey: recipientNostrPubkey,
|
||||||
senderIdentity: senderIdentity
|
senderIdentity: senderIdentity
|
||||||
) {
|
) else { return }
|
||||||
if let relayManager = nostrRelayManager {
|
if let relayManager = nostrRelayManager {
|
||||||
relayManager.sendEvent(event)
|
relayManager.sendEvent(event)
|
||||||
SecureLogger.log("Sent favorite notification via Nostr", category: SecureLogger.session, level: .debug)
|
SecureLogger.log("Sent favorite notification via Nostr", category: SecureLogger.session, level: .debug)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func sendFavoriteNotification(to peerID: String, isFavorite: Bool) {
|
func sendFavoriteNotification(to peerID: String, isFavorite: Bool) {
|
||||||
@@ -3305,7 +3370,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
// Try mesh first for connected peers
|
// Try mesh first for connected peers
|
||||||
if meshService.isPeerConnected(peerID) {
|
if meshService.isPeerConnected(peerID) {
|
||||||
meshService.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
|
meshService.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
|
||||||
SecureLogger.log("📤 Sent favorite notification via BLE to \(peerID)", category: SecureLogger.session, level: .info)
|
SecureLogger.log("📤 Sent favorite notification via BLE to \(peerID)", category: SecureLogger.session, level: .debug)
|
||||||
} else if let key = noiseKey,
|
} else if let key = noiseKey,
|
||||||
let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: key),
|
let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: key),
|
||||||
let recipientNostrPubkey = favoriteStatus.peerNostrPublicKey {
|
let recipientNostrPubkey = favoriteStatus.peerNostrPublicKey {
|
||||||
@@ -3315,22 +3380,23 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let content = isFavorite ? "FAVORITED:\(senderIdentity.npub)" : "UNFAVORITED:\(senderIdentity.npub)"
|
let content = isFavorite ? "[FAVORITED]:\(senderIdentity.npub)" : "[UNFAVORITED]:\(senderIdentity.npub)"
|
||||||
|
let recipientPeerID = key.hexEncodedString()
|
||||||
if let event = try? NostrProtocol.createPrivateMessage(
|
guard let embedded = meshService.buildNostrEmbeddedPrivateMessageContent(content: content, to: recipientPeerID, messageID: UUID().uuidString),
|
||||||
content: content,
|
let event = try? NostrProtocol.createPrivateMessage(
|
||||||
|
content: embedded,
|
||||||
recipientPubkey: recipientNostrPubkey,
|
recipientPubkey: recipientNostrPubkey,
|
||||||
senderIdentity: senderIdentity
|
senderIdentity: senderIdentity
|
||||||
) {
|
) else {
|
||||||
|
SecureLogger.log("❌ Failed to create Nostr message for favorite notification", category: SecureLogger.session, level: .error)
|
||||||
|
return
|
||||||
|
}
|
||||||
if let relayManager = nostrRelayManager {
|
if let relayManager = nostrRelayManager {
|
||||||
relayManager.sendEvent(event)
|
relayManager.sendEvent(event)
|
||||||
SecureLogger.log("📤 Sent favorite notification via Nostr to \(favoriteStatus.peerNickname)", category: SecureLogger.session, level: .info)
|
SecureLogger.log("📤 Sent favorite notification via Nostr to \(favoriteStatus.peerNickname)", category: SecureLogger.session, level: .debug)
|
||||||
} else {
|
} else {
|
||||||
SecureLogger.log("❌ NostrRelayManager is nil - cannot send favorite notification", category: SecureLogger.session, level: .error)
|
SecureLogger.log("❌ NostrRelayManager is nil - cannot send favorite notification", category: SecureLogger.session, level: .error)
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
SecureLogger.log("❌ Failed to create Nostr message for favorite notification", category: SecureLogger.session, level: .error)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
SecureLogger.log("⚠️ Cannot send favorite notification - peer not connected and no Nostr pubkey", category: SecureLogger.session, level: .warning)
|
SecureLogger.log("⚠️ Cannot send favorite notification - peer not connected and no Nostr pubkey", category: SecureLogger.session, level: .warning)
|
||||||
}
|
}
|
||||||
@@ -3471,7 +3537,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
/// Handle incoming private message
|
/// Handle incoming private message
|
||||||
@MainActor
|
@MainActor
|
||||||
private func handlePrivateMessage(_ message: BitchatMessage) {
|
private func handlePrivateMessage(_ message: BitchatMessage) {
|
||||||
SecureLogger.log("📥 handlePrivateMessage called for message from \(message.sender)", category: SecureLogger.session, level: .info)
|
SecureLogger.log("📥 handlePrivateMessage called for message from \(message.sender)", category: SecureLogger.session, level: .debug)
|
||||||
let senderPeerID = message.senderPeerID ?? getPeerIDForNickname(message.sender)
|
let senderPeerID = message.senderPeerID ?? getPeerIDForNickname(message.sender)
|
||||||
|
|
||||||
guard let peerID = senderPeerID else {
|
guard let peerID = senderPeerID else {
|
||||||
@@ -3685,5 +3751,5 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} // End of ChatViewModel class
|
// End of ChatViewModel class
|
||||||
|
|||||||
@@ -949,21 +949,29 @@ struct ContentView: View {
|
|||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func privateHeaderContent(for privatePeerID: String) -> some View {
|
private func privateHeaderContent(for privatePeerID: String) -> some View {
|
||||||
// Try to resolve to current peer ID if this is an old one
|
// Prefer short (mesh) ID when mesh-connected (radio). Only use full Noise key when not connected (globe).
|
||||||
// resolveCurrentPeerID not implemented
|
let headerPeerID: String = {
|
||||||
let currentPeerID: String = privatePeerID
|
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 ??
|
let privatePeerNick = peer?.displayName ??
|
||||||
viewModel.meshService.getPeerNicknames()[currentPeerID] ??
|
viewModel.meshService.getPeerNicknames()[headerPeerID] ??
|
||||||
FavoritesPersistenceService.shared.getFavoriteStatus(for: Data(hexString: privatePeerID) ?? Data())?.peerNickname ??
|
FavoritesPersistenceService.shared.getFavoriteStatus(for: Data(hexString: headerPeerID) ?? Data())?.peerNickname ??
|
||||||
// getFavoriteStatusByNostrKey not implemented
|
// getFavoriteStatusByNostrKey not implemented
|
||||||
// FavoritesPersistenceService.shared.getFavoriteStatusByNostrKey(privatePeerID)?.peerNickname ??
|
// FavoritesPersistenceService.shared.getFavoriteStatusByNostrKey(privatePeerID)?.peerNickname ??
|
||||||
"Unknown"
|
"Unknown"
|
||||||
let isNostrAvailable: Bool = {
|
let isNostrAvailable: Bool = {
|
||||||
guard let connectionState = peer?.connectionState else {
|
guard let connectionState = peer?.connectionState else {
|
||||||
// Check if we can reach this peer via Nostr even if not in allPeers
|
// 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),
|
let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
|
||||||
favoriteStatus.isMutual {
|
favoriteStatus.isMutual {
|
||||||
return true
|
return true
|
||||||
@@ -981,7 +989,7 @@ struct ContentView: View {
|
|||||||
ZStack {
|
ZStack {
|
||||||
// Center content - always perfectly centered
|
// Center content - always perfectly centered
|
||||||
Button(action: {
|
Button(action: {
|
||||||
viewModel.showFingerprint(for: privatePeerID)
|
viewModel.showFingerprint(for: headerPeerID)
|
||||||
}) {
|
}) {
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
// Show transport icon based on connection state (like peer list)
|
// Show transport icon based on connection state (like peer list)
|
||||||
@@ -1009,6 +1017,12 @@ struct ContentView: View {
|
|||||||
.font(.system(size: 14))
|
.font(.system(size: 14))
|
||||||
.foregroundColor(.purple)
|
.foregroundColor(.purple)
|
||||||
.accessibilityLabel("Available via Nostr")
|
.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)")
|
Text("\(privatePeerNick)")
|
||||||
@@ -1016,7 +1030,7 @@ struct ContentView: View {
|
|||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
|
|
||||||
// Dynamic encryption status icon
|
// Dynamic encryption status icon
|
||||||
let encryptionStatus = viewModel.getEncryptionStatus(for: privatePeerID)
|
let encryptionStatus = viewModel.getEncryptionStatus(for: headerPeerID)
|
||||||
if let icon = encryptionStatus.icon {
|
if let icon = encryptionStatus.icon {
|
||||||
Image(systemName: icon)
|
Image(systemName: icon)
|
||||||
.font(.system(size: 14))
|
.font(.system(size: 14))
|
||||||
@@ -1052,11 +1066,11 @@ struct ContentView: View {
|
|||||||
|
|
||||||
// Favorite button
|
// Favorite button
|
||||||
Button(action: {
|
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))
|
.font(.system(size: 16))
|
||||||
.foregroundColor(viewModel.isFavorite(peerID: privatePeerID) ? Color.yellow : textColor)
|
.foregroundColor(viewModel.isFavorite(peerID: headerPeerID) ? Color.yellow : textColor)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.accessibilityLabel(viewModel.isFavorite(peerID: privatePeerID) ? "Remove from favorites" : "Add to favorites")
|
.accessibilityLabel(viewModel.isFavorite(peerID: privatePeerID) ? "Remove from favorites" : "Add to favorites")
|
||||||
|
|||||||
Reference in New Issue
Block a user