diff --git a/bitchat/Models/PeerID.swift b/bitchat/Models/PeerID.swift index b627ade1..5861156f 100644 --- a/bitchat/Models/PeerID.swift +++ b/bitchat/Models/PeerID.swift @@ -161,9 +161,14 @@ extension PeerID { id.rangeOfCharacter(from: validCharset.inverted) == nil } + /// Returns true if the `bare` id is all hex + var isHex: Bool { + bare.allSatisfy { $0.isHexDigit } + } + /// Short routing IDs (exact 16-hex) var isShort: Bool { - bare.count == Constants.hexIDLength && Data(hexString: bare) != nil + bare.count == Constants.hexIDLength && isHex } /// Full Noise key hex (exact 64-hex) diff --git a/bitchat/Services/CommandProcessor.swift b/bitchat/Services/CommandProcessor.swift index 01e7c849..0c6a73ba 100644 --- a/bitchat/Services/CommandProcessor.swift +++ b/bitchat/Services/CommandProcessor.swift @@ -42,7 +42,7 @@ final class CommandProcessor { case .location: return true } }() - let inGeoDM = (chatViewModel?.selectedPrivateChatPeer?.hasPrefix("nostr_") == true) + let inGeoDM = chatViewModel?.selectedPrivateChatPeer?.isGeoDM == true switch cmd { case "/m", "/msg": diff --git a/bitchat/Services/PrivateChatManager.swift b/bitchat/Services/PrivateChatManager.swift index 254d0946..4214519b 100644 --- a/bitchat/Services/PrivateChatManager.swift +++ b/bitchat/Services/PrivateChatManager.swift @@ -12,9 +12,9 @@ import SwiftUI /// Manages all private chat functionality final class PrivateChatManager: ObservableObject { - @Published var privateChats: [String: [BitchatMessage]] = [:] - @Published var selectedPeer: String? = nil - @Published var unreadMessages: Set = [] + @Published var privateChats: [PeerID: [BitchatMessage]] = [:] + @Published var selectedPeer: PeerID? = nil + @Published var unreadMessages: Set = [] private var selectedPeerFingerprint: String? = nil var sentReadReceipts: Set = [] // Made accessible for ChatViewModel @@ -31,11 +31,11 @@ final class PrivateChatManager: ObservableObject { private let privateChatCap = TransportConfig.privateChatCap /// Start a private chat with a peer - func startChat(with peerID: String) { + func startChat(with peerID: PeerID) { selectedPeer = peerID // Store fingerprint for persistence across reconnections - if let fingerprint = meshService?.getFingerprint(for: PeerID(str: peerID)) { + if let fingerprint = meshService?.getFingerprint(for: peerID) { selectedPeerFingerprint = fingerprint } @@ -55,7 +55,7 @@ final class PrivateChatManager: ObservableObject { } /// Remove duplicate messages by ID and keep chronological order - func sanitizeChat(for peerID: String) { + func sanitizeChat(for peerID: PeerID) { guard let arr = privateChats[peerID] else { return } if arr.count <= 1 { return @@ -79,7 +79,7 @@ final class PrivateChatManager: ObservableObject { } /// Mark messages from a peer as read - func markAsRead(from peerID: String) { + func markAsRead(from peerID: PeerID) { unreadMessages.remove(peerID) // Send read receipts for unread messages that haven't been sent yet diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 2a1a5062..4a39de8b 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -253,21 +253,21 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { @MainActor var connectedPeers: [String] { Array(unifiedPeerService.connectedPeerIDs.map(\.id)) } @Published var allPeers: [BitchatPeer] = [] - var privateChats: [String: [BitchatMessage]] { + var privateChats: [PeerID: [BitchatMessage]] { get { privateChatManager.privateChats } set { privateChatManager.privateChats = newValue } } - var selectedPrivateChatPeer: String? { + var selectedPrivateChatPeer: PeerID? { get { privateChatManager.selectedPeer } set { - if let peer = newValue { - privateChatManager.startChat(with: peer) + if let peerID = newValue { + privateChatManager.startChat(with: peerID) } else { privateChatManager.endChat() } } } - var unreadPrivateMessages: Set { + var unreadPrivateMessages: Set { get { privateChatManager.unreadMessages } set { privateChatManager.unreadMessages = newValue } } @@ -286,7 +286,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { .map { ($0, privateChats[$0]?.last?.timestamp ?? Date.distantPast) } .sorted { $0.1 > $1.1 } if let target = unreadSorted.first?.0 { - startPrivateChat(with: target) + startPrivateChat(with: target.id) return } // Otherwise pick most recent private chat overall @@ -294,7 +294,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { .map { (id: $0.key, ts: $0.value.last?.timestamp ?? Date.distantPast) } .sorted { $0.ts > $1.ts } if let target = recent.first?.id { - startPrivateChat(with: target) + startPrivateChat(with: target.id) } } @@ -1071,6 +1071,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { ) { guard let pm = PrivateMessagePacket.decode(from: payload.data) else { return } let messageId = pm.messageID + let convKey = PeerID(str: convKey) sendDeliveryAckIfNeeded(to: messageId, senderPubKey: senderPubkey, from: id) @@ -1092,7 +1093,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { isRelay: false, isPrivate: true, recipientNickname: nickname, - senderPeerID: PeerID(str: convKey), + senderPeerID: convKey, deliveryStatus: .delivered(to: nickname, at: Date()) ) @@ -1119,7 +1120,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { NotificationService.shared.sendPrivateMessageNotification( from: senderName, message: pm.content, - peerID: convKey + peerID: convKey.id ) } } @@ -1183,20 +1184,21 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { /// Check if a peer has unread messages, including messages stored under stable Noise keys and temporary Nostr peer IDs @MainActor func hasUnreadMessages(for peerID: String) -> Bool { + let peerID = PeerID(str: peerID) // First check direct unread messages if unreadPrivateMessages.contains(peerID) { return true } // Check if messages are stored under the stable Noise key hex - if let peer = unifiedPeerService.getPeer(by: PeerID(str: peerID)) { - let noiseKeyHex = peer.noisePublicKey.hexEncodedString() + if let peer = unifiedPeerService.getPeer(by: peerID) { + let noiseKeyHex = PeerID(hexData: peer.noisePublicKey) if unreadPrivateMessages.contains(noiseKeyHex) { return true } // Also check for geohash (Nostr) DM conv key if this peer has a known Nostr pubkey if let nostrHex = peer.nostrPublicKey { - let convKey = "nostr_" + String(nostrHex.prefix(TransportConfig.nostrConvKeyPrefixLength)) + let convKey = PeerID(nostr_: nostrHex) if unreadPrivateMessages.contains(convKey) { return true } @@ -1204,11 +1206,11 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { } // Get the peer's nickname to check for temporary Nostr peer IDs - let peerNickname = meshService.peerNickname(peerID: PeerID(str: peerID))?.lowercased() ?? "" + let peerNickname = meshService.peerNickname(peerID: peerID)?.lowercased() ?? "" // Check if any temporary Nostr peer IDs have unread messages from this nickname for unreadPeerID in unreadPrivateMessages { - if unreadPeerID.hasPrefix("nostr_") { + if unreadPeerID.isGeoDM { // Check if messages from this temporary peer match the nickname if let messages = privateChats[unreadPeerID], let firstMessage = messages.first, @@ -1226,8 +1228,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { // Distinguish between ephemeral peer IDs (16 hex chars) and Noise public keys (64 hex chars) // Ephemeral peer IDs are 8 bytes = 16 hex characters // Noise public keys are 32 bytes = 64 hex characters + let peerID = PeerID(str: peerID) - if peerID.count == 64, let noisePublicKey = Data(hexString: peerID) { + if let noisePublicKey = peerID.noiseKey { // This is a stable Noise key hex (used in private chats) // Find the ephemeral peer ID for this Noise key let ephemeralPeerID = unifiedPeerService.peers.first { peer in @@ -1281,7 +1284,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { } } else { // This is an ephemeral peer ID (16 hex chars), use normal toggle - unifiedPeerService.toggleFavorite(PeerID(str: peerID)) + unifiedPeerService.toggleFavorite(peerID) // Trigger UI update objectWillChange.send() } @@ -1331,7 +1334,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { guard let chatFingerprint = selectedPrivateChatFingerprint else { return } // Find current peer ID for the fingerprint - if let currentPeerID = getCurrentPeerIDForFingerprint(chatFingerprint) { + if let currentPeerID = PeerID(str: getCurrentPeerIDForFingerprint(chatFingerprint)) { // Update the selected peer if it's different if let oldPeerID = selectedPrivateChatPeer, oldPeerID != currentPeerID { @@ -1413,7 +1416,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { updatePrivateChatPeerIfNeeded() if let selectedPeer = selectedPrivateChatPeer { - sendPrivateMessage(content, to: selectedPeer) + sendPrivateMessage(content, to: selectedPeer.id) } return } @@ -1767,6 +1770,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { ) { guard let pm = PrivateMessagePacket.decode(from: payload.data) else { return } let messageId = pm.messageID + let convKey = PeerID(str: convKey) SecureLogger.info("GeoDM: recv PM <- sender=\(senderPubkey.prefix(8))… mid=\(messageId.prefix(8))…", category: .session) @@ -1789,7 +1793,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { isRelay: false, isPrivate: true, recipientNickname: nickname, - senderPeerID: PeerID(str: convKey), + senderPeerID: convKey, deliveryStatus: .delivered(to: nickname, at: Date()) ) @@ -1819,7 +1823,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { NotificationService.shared.sendPrivateMessageNotification( from: senderName, message: pm.content, - peerID: convKey + peerID: convKey.id ) } @@ -1828,6 +1832,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { private func handleDelivered(_ payload: NoisePayload, senderPubkey: String, convKey: String) { guard let messageID = String(data: payload.data, encoding: .utf8) else { return } + let convKey = PeerID(str: convKey) if let idx = privateChats[convKey]?.firstIndex(where: { $0.id == messageID }) { privateChats[convKey]?[idx].deliveryStatus = .delivered(to: displayNameForNostrPubkey(senderPubkey), at: Date()) @@ -1840,6 +1845,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { private func handleReadReceipt(_ payload: NoisePayload, senderPubkey: String, convKey: String) { guard let messageID = String(data: payload.data, encoding: .utf8) else { return } + let convKey = PeerID(str: convKey) if let idx = privateChats[convKey]?.firstIndex(where: { $0.id == messageID }) { privateChats[convKey]?[idx].deliveryStatus = .read(by: displayNameForNostrPubkey(senderPubkey), at: Date()) @@ -2001,7 +2007,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { } // Remove geohash DM conversation if exists - let convKey = "nostr_" + String(hex.prefix(TransportConfig.nostrConvKeyPrefixLength)) + let convKey = PeerID(nostr_: hex) if privateChats[convKey] != nil { privateChats.removeValue(forKey: convKey) unreadPrivateMessages.remove(convKey) @@ -2207,10 +2213,11 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { @MainActor func sendPrivateMessage(_ content: String, to peerID: String) { guard !content.isEmpty else { return } + let peerID = PeerID(str: peerID) // Check if blocked - if unifiedPeerService.isBlocked(PeerID(str: peerID)) { - let nickname = meshService.peerNickname(peerID: PeerID(str: peerID)) ?? "user" + if unifiedPeerService.isBlocked(peerID) { + let nickname = meshService.peerNickname(peerID: peerID) ?? "user" addSystemMessage( String( format: String(localized: "system.dm.blocked_recipient", comment: "System message when attempting to message a blocked user"), @@ -2222,20 +2229,20 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { } // Geohash DM routing: conversation keys start with "nostr_" - if peerID.hasPrefix("nostr_") { - sendGeohashDM(content, to: peerID) + if peerID.isGeoDM { + sendGeohashDM(content, to: peerID.id) } // Determine routing method and recipient nickname - guard let noiseKey = Data(hexString: peerID) else { return } - let isConnected = meshService.isPeerConnected(PeerID(str: peerID)) - let isReachable = meshService.isPeerReachable(PeerID(str: peerID)) + guard let noiseKey = Data(hexString: peerID.id) else { return } + let isConnected = meshService.isPeerConnected(peerID) + let isReachable = meshService.isPeerReachable(peerID) let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey) let isMutualFavorite = favoriteStatus?.isMutual ?? false let hasNostrKey = favoriteStatus?.peerNostrPublicKey != nil // Get nickname from various sources - var recipientNickname = meshService.peerNickname(peerID: PeerID(str: peerID)) + var recipientNickname = meshService.peerNickname(peerID: peerID) if recipientNickname == nil && favoriteStatus != nil { recipientNickname = favoriteStatus?.peerNickname } @@ -2270,7 +2277,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { // Send via appropriate transport (BLE if connected/reachable, else Nostr when possible) if isConnected || isReachable || (isMutualFavorite && hasNostrKey) { - messageRouter.sendPrivate(content, to: PeerID(str: peerID), recipientNickname: recipientNickname ?? "user", messageID: messageID) + messageRouter.sendPrivate(content, to: peerID, recipientNickname: recipientNickname ?? "user", messageID: messageID) // Optimistically mark as sent for both transports; delivery/read will update subsequently if let idx = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) { privateChats[peerID]?[idx].deliveryStatus = .sent @@ -2301,6 +2308,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { return } let messageID = UUID().uuidString + let peerID = PeerID(str: peerID) // Local echo in the DM thread let message = BitchatMessage( @@ -2323,7 +2331,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { objectWillChange.send() // Resolve recipient hex from mapping - guard let recipientHex = nostrKeyMapping[peerID] else { + guard let recipientHex = nostrKeyMapping[peerID.id] else { if let msgIdx = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) { privateChats[peerID]?[msgIdx].deliveryStatus = .failed( reason: String(localized: "content.delivery.reason.unknown_recipient", comment: "Failure reason when the recipient is unknown") @@ -2376,8 +2384,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { // MARK: - Geohash DMs initiation @MainActor func startGeohashDM(withPubkeyHex hex: String) { - let convKey = "nostr_" + String(hex.prefix(TransportConfig.nostrConvKeyPrefixLength)) - nostrKeyMapping[convKey] = hex + let convKey = PeerID(nostr_: hex) + nostrKeyMapping[convKey.id] = hex selectedPrivateChatPeer = convKey } @@ -2401,6 +2409,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { /// Add a local system message to a private chat (no network send) @MainActor func addLocalPrivateSystemMessage(_ content: String, to peerID: String) { + let peerID = PeerID(str: peerID) let systemMessage = BitchatMessage( sender: "system", content: content, @@ -2408,7 +2417,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { isRelay: false, originalSender: nil, isPrivate: true, - recipientNickname: meshService.peerNickname(peerID: PeerID(str: peerID)), + recipientNickname: meshService.peerNickname(peerID: peerID), senderPeerID: meshService.myPeerID ) if privateChats[peerID] == nil { privateChats[peerID] = [] } @@ -2457,11 +2466,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { if peerID == meshService.myPeerID { return } + let peerID = PeerID(str: peerID) - let peerNickname = meshService.peerNickname(peerID: PeerID(str: peerID)) ?? "unknown" + let peerNickname = meshService.peerNickname(peerID: peerID) ?? "unknown" // Check if the peer is blocked - if unifiedPeerService.isBlocked(PeerID(str: peerID)) { + if unifiedPeerService.isBlocked(peerID) { addSystemMessage( String( format: String(localized: "system.chat.blocked", comment: "System message when starting chat fails because peer is blocked"), @@ -2473,7 +2483,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { } // Check mutual favorites for offline messaging - if let peer = unifiedPeerService.getPeer(by: PeerID(str: peerID)), + if let peer = unifiedPeerService.getPeer(by: peerID), peer.isFavorite && !peer.theyFavoritedUs && !peer.isConnected { addSystemMessage( String( @@ -2487,8 +2497,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { // Consolidate messages from stable Noise key if needed // This ensures Nostr messages appear when opening a chat with an ephemeral peer ID - if let peer = unifiedPeerService.getPeer(by: PeerID(str: peerID)) { - let noiseKeyHex = peer.noisePublicKey.hexEncodedString() + if let peer = unifiedPeerService.getPeer(by: peerID) { + let noiseKeyHex = PeerID(hexData: peer.noisePublicKey) // If we have messages stored under the stable Noise key hex but not under the ephemeral ID, // or if we need to merge them, do so now @@ -2518,7 +2528,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { originalSender: message.originalSender, isPrivate: message.isPrivate, recipientNickname: message.recipientNickname, - senderPeerID: message.senderPeerID == meshService.myPeerID ? meshService.myPeerID : PeerID(str: peerID), // Update peer ID if it's from them + senderPeerID: message.senderPeerID == meshService.myPeerID ? meshService.myPeerID : peerID, // Update peer ID if it's from them mentions: message.mentions, deliveryStatus: message.deliveryStatus ) @@ -2561,11 +2571,11 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { // These are messages received via Nostr when we didn't know the sender's Noise key // They're stored under "nostr_" + first 16 chars of Nostr pubkey let currentPeerNickname = peerNickname.lowercased() - var tempPeerIDsToConsolidate: [String] = [] + var tempPeerIDsToConsolidate: [PeerID] = [] // Find all temporary Nostr peer IDs that have messages from the same nickname for (storedPeerID, messages) in privateChats { - if storedPeerID.hasPrefix("nostr_") && storedPeerID != peerID { + if storedPeerID.isGeoDM && storedPeerID != peerID { // Check if ALL messages from this temporary peer have the same sender nickname // This is more reliable than just checking the first message let nicknamesMatch = messages.allSatisfy { msg in @@ -2606,7 +2616,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { originalSender: message.originalSender, isPrivate: message.isPrivate, recipientNickname: message.recipientNickname, - senderPeerID: PeerID(str: peerID), // Update to match current peer + senderPeerID: peerID, // Update to match current peer mentions: message.mentions, deliveryStatus: message.deliveryStatus ) @@ -2635,11 +2645,11 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { } // Trigger handshake if needed (mesh peers only). Skip for Nostr geohash conv keys. - if !peerID.hasPrefix("nostr_") && !peerID.hasPrefix("nostr:") { - let sessionState = meshService.getNoiseSessionState(for: PeerID(str: peerID)) + if !peerID.isGeoDM && !peerID.isGeoChat { + let sessionState = meshService.getNoiseSessionState(for: peerID) switch sessionState { case .none, .failed: - meshService.triggerHandshake(with: PeerID(str: peerID)) + meshService.triggerHandshake(with: peerID) default: break } @@ -2672,7 +2682,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { // Also mark messages as read for Nostr ACKs // This ensures read receipts are sent even for consolidated messages - markPrivateMessagesAsRead(from: peerID) + markPrivateMessagesAsRead(from: peerID.id) } func endPrivateChat() { @@ -2744,8 +2754,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { if let isKeyUpdate = notification.userInfo?["isKeyUpdate"] as? Bool, isKeyUpdate, let oldKey = notification.userInfo?["oldPeerPublicKey"] as? Data { - let oldPeerID = oldKey.hexEncodedString() - let newPeerID = peerPublicKey.hexEncodedString() + let oldPeerID = PeerID(hexData: oldKey) + let newPeerID = PeerID(hexData: peerPublicKey) // If we have a private chat open with the old peer ID, update it to the new one if selectedPrivateChatPeer == oldPeerID { @@ -2769,9 +2779,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { selectedPrivateChatPeer = newPeerID // Update fingerprint tracking if needed - if let fingerprint = peerIDToPublicKeyFingerprint[oldPeerID] { - peerIDToPublicKeyFingerprint.removeValue(forKey: oldPeerID) - peerIDToPublicKeyFingerprint[newPeerID] = fingerprint + if let fingerprint = peerIDToPublicKeyFingerprint[oldPeerID.id] { + peerIDToPublicKeyFingerprint.removeValue(forKey: oldPeerID.id) + peerIDToPublicKeyFingerprint[newPeerID.id] = fingerprint selectedPrivateChatFingerprint = fingerprint } @@ -2794,9 +2804,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { } // Update fingerprint mapping - if let fingerprint = peerIDToPublicKeyFingerprint[oldPeerID] { - peerIDToPublicKeyFingerprint.removeValue(forKey: oldPeerID) - peerIDToPublicKeyFingerprint[newPeerID] = fingerprint + if let fingerprint = peerIDToPublicKeyFingerprint[oldPeerID.id] { + peerIDToPublicKeyFingerprint.removeValue(forKey: oldPeerID.id) + peerIDToPublicKeyFingerprint[newPeerID.id] = fingerprint } } } @@ -2855,10 +2865,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { // When app becomes active, send read receipts for visible private chat if let peerID = selectedPrivateChatPeer { // Try immediately - self.markPrivateMessagesAsRead(from: peerID) + self.markPrivateMessagesAsRead(from: peerID.id) // And again with a delay DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiAnimationMediumSeconds) { - self.markPrivateMessagesAsRead(from: peerID) + self.markPrivateMessagesAsRead(from: peerID.id) } } // Subscriptions will be resent after connections come back up @@ -2882,14 +2892,14 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { if let peerID = selectedPrivateChatPeer { // In private chat - send to the other person - if let peerNickname = meshService.peerNickname(peerID: PeerID(str: peerID)) { + if let peerNickname = meshService.peerNickname(peerID: peerID) { // Only send screenshot notification if we have an established session // This prevents triggering handshake requests for screenshot notifications - let sessionState = meshService.getNoiseSessionState(for: PeerID(str: peerID)) + let sessionState = meshService.getNoiseSessionState(for: peerID) switch sessionState { case .established: // Send the message directly without going through sendPrivateMessage to avoid local echo - messageRouter.sendPrivate(screenshotMessage, to: PeerID(str: peerID), recipientNickname: peerNickname, messageID: UUID().uuidString) + messageRouter.sendPrivate(screenshotMessage, to: peerID, recipientNickname: peerNickname, messageID: UUID().uuidString) default: // Don't send screenshot notification if no session exists SecureLogger.debug("Skipping screenshot notification to \(peerID) - no established session", category: .security) @@ -2904,7 +2914,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { isRelay: false, originalSender: nil, isPrivate: true, - recipientNickname: meshService.peerNickname(peerID: PeerID(str: peerID)), + recipientNickname: meshService.peerNickname(peerID: peerID), senderPeerID: meshService.myPeerID ) var chats = privateChats @@ -3017,11 +3027,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { @MainActor func markPrivateMessagesAsRead(from peerID: String) { + let peerID = PeerID(str: peerID) privateChatManager.markAsRead(from: peerID) // Handle GeoDM (nostr_*) read receipts directly via per-geohash identity - if peerID.hasPrefix("nostr_"), - let recipientHex = nostrKeyMapping[peerID], + if peerID.isGeoDM, + let recipientHex = nostrKeyMapping[peerID.id], case .location(let ch) = LocationChannelManager.shared.selectedChannel, let id = try? idBridge.deriveIdentity(forGeohash: ch.geohash) { let messages = privateChats[peerID] ?? [] @@ -3038,18 +3049,18 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { } // Get the peer's Noise key to check for Nostr messages - var noiseKeyHex: String? = nil + var noiseKeyHex: PeerID? = nil var peerNostrPubkey: String? = nil // First check if peerID is already a hex Noise key - if let noiseKey = Data(hexString: peerID), + if let noiseKey = Data(hexString: peerID.id), let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey) { noiseKeyHex = peerID peerNostrPubkey = favoriteStatus.peerNostrPublicKey } // Otherwise get the Noise key from the peer info - else if let peer = unifiedPeerService.getPeer(by: PeerID(str: peerID)) { - noiseKeyHex = peer.noisePublicKey.hexEncodedString() + else if let peer = unifiedPeerService.getPeer(by: peerID) { + noiseKeyHex = PeerID(hexData: peer.noisePublicKey) let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: peer.noisePublicKey) peerNostrPubkey = favoriteStatus?.peerNostrPublicKey @@ -3062,7 +3073,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { // Send Nostr read ACKs if peer has Nostr capability if peerNostrPubkey != nil { // Check messages under both ephemeral peer ID and stable Noise key - let messagesToAck = getPrivateChatMessages(for: peerID) + let messagesToAck = getPrivateChatMessages(for: peerID.id) for message in messagesToAck { // Only send read ACKs for messages from the peer (not our own) @@ -3071,9 +3082,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { // Skip if we already sent an ACK for this message if !sentReadReceipts.contains(message.id) { // Use stable Noise key hex if available; else fall back to peerID - let recipPeer = (Data(hexString: peerID) != nil) ? peerID : (unifiedPeerService.getPeer(by: PeerID(str: peerID))?.noisePublicKey.hexEncodedString() ?? peerID) + let recipPeer = peerID.isHex ? peerID : (unifiedPeerService.getPeer(by: peerID)?.peerID ?? peerID) let receipt = ReadReceipt(originalMessageID: message.id, readerID: meshService.myPeerID.id, readerNickname: nickname) - messageRouter.sendReadReceipt(receipt, to: PeerID(str: recipPeer)) + messageRouter.sendReadReceipt(receipt, to: recipPeer) sentReadReceipts.insert(message.id) } } @@ -3084,6 +3095,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { @MainActor func getPrivateChatMessages(for peerID: String) -> [BitchatMessage] { var combined: [BitchatMessage] = [] + let peerID = PeerID(str: peerID) // Gather messages under the ephemeral peer ID if let ephemeralMessages = privateChats[peerID] { @@ -3091,8 +3103,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { } // Also include messages stored under the stable Noise key (Nostr path) - if let peer = unifiedPeerService.getPeer(by: PeerID(str: peerID)) { - let noiseKeyHex = peer.noisePublicKey.hexEncodedString() + if let peer = unifiedPeerService.getPeer(by: peerID) { + let noiseKeyHex = PeerID(hexData: peer.noisePublicKey) if noiseKeyHex != peerID, let nostrMessages = privateChats[noiseKeyHex] { combined.append(contentsOf: nostrMessages) } @@ -4478,8 +4490,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { case .delivered: guard let messageID = String(data: payload, encoding: .utf8) else { return } if let name = unifiedPeerService.getPeer(by: peerID)?.nickname { - if let messages = privateChats[peerID.id], let idx = messages.firstIndex(where: { $0.id == messageID }) { - privateChats[peerID.id]?[idx].deliveryStatus = .delivered(to: name, at: Date()) + if let messages = privateChats[peerID], let idx = messages.firstIndex(where: { $0.id == messageID }) { + privateChats[peerID]?[idx].deliveryStatus = .delivered(to: name, at: Date()) objectWillChange.send() } } @@ -4487,8 +4499,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { case .readReceipt: guard let messageID = String(data: payload, encoding: .utf8) else { return } if let name = unifiedPeerService.getPeer(by: peerID)?.nickname { - if let messages = privateChats[peerID.id], let idx = messages.firstIndex(where: { $0.id == messageID }) { - privateChats[peerID.id]?[idx].deliveryStatus = .read(by: name, at: Date()) + if let messages = privateChats[peerID], let idx = messages.firstIndex(where: { $0.id == messageID }) { + privateChats[peerID]?[idx].deliveryStatus = .read(by: name, at: Date()) objectWillChange.send() } } @@ -4667,9 +4679,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { } if let current = selectedPrivateChatPeer, current == peerID, - let stableKeyHex = derivedStableKeyHex { + let stableKeyHex = PeerID(str: derivedStableKeyHex) { // Migrate messages view context to stable key so header shows favorite + Nostr globe - if let messages = privateChats[peerID.id] { + 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) { @@ -4682,17 +4694,17 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { originalSender: msg.originalSender, isPrivate: msg.isPrivate, recipientNickname: msg.recipientNickname, - senderPeerID: msg.senderPeerID == meshService.myPeerID ? meshService.myPeerID : PeerID(str: stableKeyHex), + 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.id) + privateChats.removeValue(forKey: peerID) } - if unreadPrivateMessages.contains(peerID.id) { - unreadPrivateMessages.remove(peerID.id) + if unreadPrivateMessages.contains(peerID) { + unreadPrivateMessages.remove(peerID) unreadPrivateMessages.insert(stableKeyHex) } selectedPrivateChatPeer = stableKeyHex @@ -4707,7 +4719,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { // Clear sent read receipts for this peer since they'll need to be resent after reconnection // Only clear receipts for messages from this specific peer - if let messages = privateChats[peerID.id] { + if let messages = privateChats[peerID] { for message in messages { // Remove read receipts for messages FROM this peer (not TO this peer) if message.senderPeerID == peerID { @@ -4715,8 +4727,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { } } } - - // } func didUpdatePeerList(_ peers: [PeerID]) { @@ -4790,14 +4800,14 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { /// Clean up stale unread peer IDs that no longer exist in the peer list @MainActor private func cleanupStaleUnreadPeerIDs() { - let currentPeerIDs = Set(unifiedPeerService.peers.map { $0.peerID.id }) + let currentPeerIDs = Set(unifiedPeerService.peers.map { $0.peerID }) let staleIDs = unreadPrivateMessages.subtracting(currentPeerIDs) if !staleIDs.isEmpty { - var idsToRemove: [String] = [] + var idsToRemove: [PeerID] = [] for staleID in staleIDs { // Don't remove temporary Nostr peer IDs that have messages - if staleID.hasPrefix("nostr_") { + if staleID.isGeoDM { // Check if we have messages from this temporary peer if let messages = privateChats[staleID], !messages.isEmpty { // Keep this ID - it has messages @@ -4807,7 +4817,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { // Don't remove stable Noise key hexes (64 char hex strings) that have messages // These are used for Nostr messages when peer is offline - if staleID.count == 64, staleID.allSatisfy({ $0.isHexDigit }) { + if staleID.isNoiseKeyHex { if let messages = privateChats[staleID], !messages.isEmpty { // Keep this ID - it's a stable key with messages continue @@ -5125,7 +5135,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { 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(TransportConfig.nostrConvKeyPrefixLength)) + let targetPeerID = PeerID(str: actualSenderNoiseKey?.hexEncodedString()) ?? PeerID(nostr_: senderPubkey) switch noisePayload.type { case .privateMessage: @@ -5133,7 +5143,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { noisePayload, actualSenderNoiseKey: actualSenderNoiseKey, senderNickname: senderNickname, - targetPeerID: targetPeerID, + targetPeerID: targetPeerID.id, messageTimestamp: messageTimestamp, senderPubkey: senderPubkey ) @@ -5196,7 +5206,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { if selectedPrivateChatPeer == targetPeerID { isViewingThisChat = true } else if let selectedPeer = selectedPrivateChatPeer, - let selectedPeerData = unifiedPeerService.getPeer(by: PeerID(str: selectedPeer)), + let selectedPeerData = unifiedPeerService.getPeer(by: selectedPeer), let key = actualSenderNoiseKey, selectedPeerData.noisePublicKey == key { isViewingThisChat = true @@ -5252,7 +5262,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { } private func isDuplicateMessage(_ messageId: String, targetPeerID: String) -> Bool { - if privateChats[targetPeerID]?.contains(where: { $0.id == messageId }) == true { + if privateChats[PeerID(str: targetPeerID)]?.contains(where: { $0.id == messageId }) == true { return true } for (_, messages) in privateChats where messages.contains(where: { $0.id == messageId }) { @@ -5262,6 +5272,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { } private func addMessageToPrivateChatsIfNeeded(_ message: BitchatMessage, targetPeerID: String) { + let targetPeerID = PeerID(str: targetPeerID) if privateChats[targetPeerID] == nil { privateChats[targetPeerID] = [] } @@ -5277,7 +5288,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { @MainActor private func mirrorToEphemeralIfNeeded(_ message: BitchatMessage, targetPeerID: String, key: Data?) { guard let key, - let ephemeralPeerID = unifiedPeerService.peers.first(where: { $0.noisePublicKey == key })?.peerID.id, + let ephemeralPeerID = unifiedPeerService.peers.first(where: { $0.noisePublicKey == key })?.peerID, ephemeralPeerID != targetPeerID else { return @@ -5312,9 +5323,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { @MainActor private func handleViewingThisChat(_ message: BitchatMessage, targetPeerID: String, key: Data?, senderPubkey: String) { + let targetPeerID = PeerID(str: targetPeerID) unreadPrivateMessages.remove(targetPeerID) if let key, - let ephemeralPeerID = unifiedPeerService.peers.first(where: { $0.noisePublicKey == key })?.peerID.id { + let ephemeralPeerID = unifiedPeerService.peers.first(where: { $0.noisePublicKey == key })?.peerID { unreadPrivateMessages.remove(ephemeralPeerID) } if !sentReadReceipts.contains(message.id) { @@ -5343,10 +5355,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { messageContent: String ) { guard shouldMarkAsUnread else { return } - + let targetPeerID = PeerID(str: targetPeerID) unreadPrivateMessages.insert(targetPeerID) if let key, - let ephemeralPeerID = unifiedPeerService.peers.first(where: { $0.noisePublicKey == key })?.peerID.id, + let ephemeralPeerID = unifiedPeerService.peers.first(where: { $0.noisePublicKey == key })?.peerID, ephemeralPeerID != targetPeerID { unreadPrivateMessages.insert(ephemeralPeerID) } @@ -5354,7 +5366,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { NotificationService.shared.sendPrivateMessageNotification( from: senderNickname, message: messageContent, - peerID: targetPeerID + peerID: targetPeerID.id ) } } @@ -5414,7 +5426,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { } messageFound = true - SecureLogger.info("✅ Updated message \(messageId.prefix(16))... status to \(ackType) in chat \(chatPeerID.prefix(16))...", category: .session) + SecureLogger.info("✅ Updated message \(messageId.prefix(16))... status to \(ackType) in chat \(chatPeerID.id.prefix(16))...", category: .session) // Don't break - continue to update in all chats where this message exists } } @@ -5515,7 +5527,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { // For now, create a temporary peer ID based on Nostr pubkey // This allows the message to be displayed even without Noise key mapping - let tempPeerID = "nostr_" + senderPubkey.prefix(TransportConfig.nostrConvKeyPrefixLength) + let tempPeerID = PeerID(nostr_: senderPubkey) // Check if we're viewing this unknown sender's chat let isViewingThisChat = selectedPrivateChatPeer == tempPeerID @@ -5554,7 +5566,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { originalSender: nil, isPrivate: true, recipientNickname: nickname, - senderPeerID: PeerID(str: tempPeerID), + senderPeerID: tempPeerID, mentions: nil, deliveryStatus: .delivered(to: nickname, at: Date()) ) @@ -5585,7 +5597,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { NotificationService.shared.sendPrivateMessageNotification( from: finalSenderNickname, message: content, - peerID: tempPeerID + peerID: tempPeerID.id ) } else { // Not notifying for old message @@ -5729,17 +5741,17 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { @MainActor private func migratePrivateChatsIfNeeded(for peerID: String, senderNickname: String) { let currentFingerprint = getFingerprint(for: peerID) - + let peerID = PeerID(str: peerID) if privateChats[peerID] == nil || privateChats[peerID]?.isEmpty == true { var migratedMessages: [BitchatMessage] = [] - var oldPeerIDsToRemove: [String] = [] + var oldPeerIDsToRemove: [PeerID] = [] // Only migrate messages from the last 24 hours to prevent old messages from flooding let cutoffTime = Date().addingTimeInterval(-TransportConfig.uiMigrationCutoffSeconds) for (oldPeerID, messages) in privateChats { if oldPeerID != peerID { - let oldFingerprint = peerIDToPublicKeyFingerprint[oldPeerID] + let oldFingerprint = peerIDToPublicKeyFingerprint[oldPeerID.id] // Filter messages to only recent ones let recentMessages = messages.filter { $0.timestamp > cutoffTime } @@ -5825,7 +5837,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { @MainActor private func handlePrivateMessage(_ message: BitchatMessage) { SecureLogger.debug("📥 handlePrivateMessage called for message from \(message.sender)", category: .session) - let senderPeerID = message.senderPeerID?.id ?? getPeerIDForNickname(message.sender) + let senderPeerID = message.senderPeerID ?? PeerID(str: getPeerIDForNickname(message.sender)) guard let peerID = senderPeerID else { SecureLogger.warning("⚠️ Could not get peer ID for sender \(message.sender)", category: .session) @@ -5834,18 +5846,18 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { // Check if this is a favorite/unfavorite notification if message.content.hasPrefix("[FAVORITED]") || message.content.hasPrefix("[UNFAVORITED]") { - handleFavoriteNotificationFromMesh(message.content, from: peerID, senderNickname: message.sender) + handleFavoriteNotificationFromMesh(message.content, from: peerID.id, senderNickname: message.sender) return // Don't store as a regular message } // Migrate chats if needed - migratePrivateChatsIfNeeded(for: peerID, senderNickname: message.sender) + migratePrivateChatsIfNeeded(for: peerID.id, senderNickname: message.sender) // IMPORTANT: Also consolidate messages from stable Noise key if this is an ephemeral peer // This ensures Nostr messages appear in BLE chats - if peerID.count == 16 { // This is an ephemeral peer ID (8 bytes = 16 hex chars) - if let peer = unifiedPeerService.getPeer(by: PeerID(str: peerID)) { - let stableKeyHex = peer.noisePublicKey.hexEncodedString() + if peerID.id.count == 16 { // This is an ephemeral peer ID (8 bytes = 16 hex chars) + if let peer = unifiedPeerService.getPeer(by: peerID) { + let stableKeyHex = PeerID(hexData: peer.noisePublicKey) // If we have messages stored under the stable key, merge them if stableKeyHex != peerID, let nostrMessages = privateChats[stableKeyHex], !nostrMessages.isEmpty { @@ -5900,7 +5912,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { // Handle fingerprint-based chat updates if let chatFingerprint = selectedPrivateChatFingerprint, - let senderFingerprint = peerIDToPublicKeyFingerprint[peerID], + let senderFingerprint = peerIDToPublicKeyFingerprint[peerID.id], chatFingerprint == senderFingerprint && selectedPrivateChatPeer != peerID { selectedPrivateChatPeer = peerID } @@ -5916,7 +5928,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { NotificationService.shared.sendPrivateMessageNotification( from: message.sender, message: message.content, - peerID: peerID + peerID: peerID.id ) } } else { @@ -5935,25 +5947,25 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { readerNickname: nickname ) - let recipientID = message.senderPeerID?.id ?? peerID + let recipientID = message.senderPeerID ?? peerID Task { @MainActor in var originalTransport: String? = nil - if let noiseKey = Data(hexString: recipientID), + if let noiseKey = Data(hexString: recipientID.id), let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey), favoriteStatus.peerNostrPublicKey != nil, - self.meshService.peerNickname(peerID: PeerID(str: recipientID)) == nil { + self.meshService.peerNickname(peerID: recipientID) == nil { originalTransport = "nostr" } - self.sendReadReceipt(receipt, to: recipientID, originalTransport: originalTransport) + self.sendReadReceipt(receipt, to: recipientID.id, originalTransport: originalTransport) } sentReadReceipts.insert(message.id) } // Mark other messages as read DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiReadReceiptRetryShortSeconds) { [weak self] in - self?.markPrivateMessagesAsRead(from: peerID) + self?.markPrivateMessagesAsRead(from: peerID.id) } } } diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index 57b39fe7..f12c198e 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -531,7 +531,7 @@ struct ContentView: View { } .onChange(of: viewModel.privateChats) { _ in if let peerID = privatePeer, - let messages = viewModel.privateChats[peerID], + let messages = viewModel.privateChats[PeerID(str: peerID)], !messages.isEmpty { // If the newest private message is from me, always scroll let lastMsg = messages.last! @@ -663,7 +663,7 @@ struct ContentView: View { (["/w"], nil, "see who's online") ] let isGeoPublic: Bool = { if case .location = locationManager.selectedChannel { return true }; return false }() - let isGeoDM: Bool = (viewModel.selectedPrivateChatPeer?.hasPrefix("nostr_") == true) + let isGeoDM = viewModel.selectedPrivateChatPeer?.isGeoDM == true let favInfo: [(commands: [String], syntax: String?, description: String)] = [ (["/fav"], "", "add to favorites"), (["/unfav"], "", "remove from favorites") @@ -747,7 +747,7 @@ struct ContentView: View { if case .location = locationManager.selectedChannel { return true } return false }() - let isGeoDM: Bool = (viewModel.selectedPrivateChatPeer?.hasPrefix("nostr_") == true) + let isGeoDM = viewModel.selectedPrivateChatPeer?.isGeoDM == true var commandDescriptions = [ ("/block", String(localized: "content.commands.block", comment: "Description for /block command")), ("/clear", String(localized: "content.commands.clear", comment: "Description for /clear command")), @@ -955,7 +955,7 @@ struct ContentView: View { private var privateChatSheetView: some View { VStack(spacing: 0) { - if let privatePeerID = viewModel.selectedPrivateChatPeer { + if let privatePeerID = viewModel.selectedPrivateChatPeer?.id { let headerContext = makePrivateHeaderContext(for: privatePeerID) HStack(spacing: 12) { @@ -1021,7 +1021,7 @@ struct ContentView: View { .background(backgroundColor) } - messagesView(privatePeer: viewModel.selectedPrivateChatPeer, isAtBottom: $isAtBottomPrivate) + messagesView(privatePeer: viewModel.selectedPrivateChatPeer?.id, isAtBottom: $isAtBottomPrivate) .background(backgroundColor) .frame(maxWidth: .infinity, maxHeight: .infinity) Divider() diff --git a/bitchat/Views/VerificationViews.swift b/bitchat/Views/VerificationViews.swift index 40ac301e..f5c007d7 100644 --- a/bitchat/Views/VerificationViews.swift +++ b/bitchat/Views/VerificationViews.swift @@ -373,7 +373,7 @@ struct VerificationSheetView: View { } // Optional: Remove verification for selected peer (if verified) - if let pid = viewModel.selectedPrivateChatPeer, + if let pid = viewModel.selectedPrivateChatPeer?.id, let fp = viewModel.getFingerprint(for: pid), viewModel.verifiedFingerprints.contains(fp) { Button(action: { viewModel.unverifyFingerprint(for: pid) }) {