mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 22:45:19 +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)
|
||||
build/
|
||||
DerivedData/
|
||||
.DerivedData/
|
||||
*.moved-aside
|
||||
*.pbxuser
|
||||
!default.pbxuser
|
||||
@@ -65,3 +66,7 @@ __pycache__/
|
||||
## Temporary files
|
||||
*.tmp
|
||||
*.temp
|
||||
|
||||
# Local build results
|
||||
.Result*/
|
||||
.Result*.xcresult/
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)")
|
||||
|
||||
|
||||
@@ -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..<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
|
||||
private func handleFavoriteNotificationFromMesh(_ content: String, from peerID: String, senderNickname: String) {
|
||||
@@ -3178,10 +3249,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
privateChats[tempPeerID]?.append(message)
|
||||
trimPrivateChatMessagesIfNeeded(for: tempPeerID)
|
||||
|
||||
// Send delivery acknowledgment only if we haven't read it before
|
||||
if !wasReadBefore {
|
||||
sendNostrAcknowledgment(messageId: messageId, to: senderPubkey, type: "DELIVERED")
|
||||
}
|
||||
// For unknown senders (no Noise key), skip sending Nostr ACKs
|
||||
|
||||
// Handle based on read status
|
||||
if wasReadBefore {
|
||||
@@ -3189,10 +3257,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
// Not marking previously-read message as unread
|
||||
} else if isViewingThisChat {
|
||||
// Viewing this chat - mark as read
|
||||
if !sentReadReceipts.contains(messageId) {
|
||||
sendNostrAcknowledgment(messageId: messageId, to: senderPubkey, type: "READ")
|
||||
sentReadReceipts.insert(messageId)
|
||||
}
|
||||
// No read ACKs for unknown senders
|
||||
} else {
|
||||
// Not viewing and not previously read
|
||||
// Use pre-calculated shouldMarkAsUnread to avoid UI flicker
|
||||
@@ -3272,17 +3337,17 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
|
||||
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
|
||||
|
||||
let content = isFavorite ? "FAVORITED:\(senderIdentity.npub)" : "UNFAVORITED:\(senderIdentity.npub)"
|
||||
|
||||
if let event = try? NostrProtocol.createPrivateMessage(
|
||||
content: content,
|
||||
recipientPubkey: recipientNostrPubkey,
|
||||
senderIdentity: senderIdentity
|
||||
) {
|
||||
if let relayManager = nostrRelayManager {
|
||||
relayManager.sendEvent(event)
|
||||
SecureLogger.log("Sent favorite notification via Nostr", category: SecureLogger.session, level: .debug)
|
||||
}
|
||||
let content = isFavorite ? "[FAVORITED]:\(senderIdentity.npub)" : "[UNFAVORITED]:\(senderIdentity.npub)"
|
||||
let recipientPeerID = noisePublicKey.hexEncodedString()
|
||||
guard let embedded = meshService.buildNostrEmbeddedPrivateMessageContent(content: content, to: recipientPeerID, messageID: UUID().uuidString),
|
||||
let event = try? NostrProtocol.createPrivateMessage(
|
||||
content: embedded,
|
||||
recipientPubkey: recipientNostrPubkey,
|
||||
senderIdentity: senderIdentity
|
||||
) else { return }
|
||||
if let relayManager = nostrRelayManager {
|
||||
relayManager.sendEvent(event)
|
||||
SecureLogger.log("Sent favorite notification via Nostr", category: SecureLogger.session, level: .debug)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3305,7 +3370,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
// Try mesh first for connected peers
|
||||
if meshService.isPeerConnected(peerID) {
|
||||
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,
|
||||
let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: key),
|
||||
let recipientNostrPubkey = favoriteStatus.peerNostrPublicKey {
|
||||
@@ -3315,21 +3380,22 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
return
|
||||
}
|
||||
|
||||
let content = isFavorite ? "FAVORITED:\(senderIdentity.npub)" : "UNFAVORITED:\(senderIdentity.npub)"
|
||||
|
||||
if let event = try? NostrProtocol.createPrivateMessage(
|
||||
content: content,
|
||||
recipientPubkey: recipientNostrPubkey,
|
||||
senderIdentity: senderIdentity
|
||||
) {
|
||||
if let relayManager = nostrRelayManager {
|
||||
relayManager.sendEvent(event)
|
||||
SecureLogger.log("📤 Sent favorite notification via Nostr to \(favoriteStatus.peerNickname)", category: SecureLogger.session, level: .info)
|
||||
} else {
|
||||
SecureLogger.log("❌ NostrRelayManager is nil - cannot send favorite notification", category: SecureLogger.session, level: .error)
|
||||
}
|
||||
} else {
|
||||
let content = isFavorite ? "[FAVORITED]:\(senderIdentity.npub)" : "[UNFAVORITED]:\(senderIdentity.npub)"
|
||||
let recipientPeerID = key.hexEncodedString()
|
||||
guard let embedded = meshService.buildNostrEmbeddedPrivateMessageContent(content: content, to: recipientPeerID, messageID: UUID().uuidString),
|
||||
let event = try? NostrProtocol.createPrivateMessage(
|
||||
content: embedded,
|
||||
recipientPubkey: recipientNostrPubkey,
|
||||
senderIdentity: senderIdentity
|
||||
) else {
|
||||
SecureLogger.log("❌ Failed to create Nostr message for favorite notification", category: SecureLogger.session, level: .error)
|
||||
return
|
||||
}
|
||||
if let relayManager = nostrRelayManager {
|
||||
relayManager.sendEvent(event)
|
||||
SecureLogger.log("📤 Sent favorite notification via Nostr to \(favoriteStatus.peerNickname)", category: SecureLogger.session, level: .debug)
|
||||
} else {
|
||||
SecureLogger.log("❌ NostrRelayManager is nil - cannot send favorite notification", category: SecureLogger.session, level: .error)
|
||||
}
|
||||
} else {
|
||||
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
|
||||
@MainActor
|
||||
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)
|
||||
|
||||
guard let peerID = senderPeerID else {
|
||||
@@ -3685,5 +3751,5 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
} // End of ChatViewModel class
|
||||
}
|
||||
// End of ChatViewModel class
|
||||
|
||||
@@ -949,21 +949,29 @@ struct ContentView: View {
|
||||
|
||||
@ViewBuilder
|
||||
private func privateHeaderContent(for privatePeerID: String) -> 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")
|
||||
|
||||
Reference in New Issue
Block a user