diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 21b21077..2975765b 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -5049,8 +5049,11 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { return // Ignoring old message } - // Processing Nostr message - + processNostrMessage(giftWrap) + } + + @MainActor + private func processNostrMessage(_ giftWrap: NostrEvent) { guard let currentIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return } do { @@ -5101,141 +5104,15 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { switch noisePayload.type { case .privateMessage: - guard let pm = PrivateMessagePacket.decode(from: noisePayload.data) else { return } - let messageId = pm.messageID - let messageContent = pm.content - - // 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 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 - } - - // 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()) + handlePrivateMessage( + noisePayload, + actualSenderNoiseKey: actualSenderNoiseKey, + senderNickname: senderNickname, + targetPeerID: targetPeerID, + messageTimestamp: messageTimestamp, + senderPubkey: senderPubkey ) - if privateChats[targetPeerID] == nil { privateChats[targetPeerID] = [] } - if let idx = privateChats[targetPeerID]?.firstIndex(where: { $0.id == messageId }) { - privateChats[targetPeerID]?[idx] = message - } else { - privateChats[targetPeerID]?.append(message) - } - // Sanitize to avoid duplicate IDs - privateChatManager.sanitizeChat(for: targetPeerID) - 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 let idx = privateChats[ephemeralPeerID]?.firstIndex(where: { $0.id == messageId }) { - privateChats[ephemeralPeerID]?[idx] = message - } else { - privateChats[ephemeralPeerID]?.append(message) - trimPrivateChatMessagesIfNeeded(for: ephemeralPeerID) - } - privateChatManager.sanitizeChat(for: ephemeralPeerID) - } - - // Send delivery ack via Nostr embedded - if !wasReadBefore { - if let key = actualSenderNoiseKey { - SecureLogger.debug("Sending DELIVERED ack for \(messageId.prefix(8))… via router", category: .session) - messageRouter.sendDeliveryAck(messageId, to: key.hexEncodedString()) - } else if let id = try? NostrIdentityBridge.getCurrentNostrIdentity() { - // Fallback: no Noise mapping yet — send directly to sender's Nostr pubkey - let nt = NostrTransport(keychain: keychain) - nt.senderPeerID = meshService.myPeerID - nt.sendDeliveryAckGeohash(for: messageId, toRecipientHex: senderPubkey, from: id) - SecureLogger.debug("Sent DELIVERED ack directly to Nostr pub=\(senderPubkey.prefix(8))… for mid=\(messageId.prefix(8))…", category: .session) - } - } - - 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) { - if let key = actualSenderNoiseKey { - let receipt = ReadReceipt(originalMessageID: messageId, readerID: meshService.myPeerID, readerNickname: nickname) - SecureLogger.debug("Viewing chat; sending READ ack for \(messageId.prefix(8))… via router", category: .session) - messageRouter.sendReadReceipt(receipt, to: key.hexEncodedString()) - sentReadReceipts.insert(messageId) - } else if let id = try? NostrIdentityBridge.getCurrentNostrIdentity() { - let nt = NostrTransport(keychain: keychain) - nt.senderPeerID = meshService.myPeerID - nt.sendReadReceiptGeohash(messageId, toRecipientHex: senderPubkey, from: id) - sentReadReceipts.insert(messageId) - SecureLogger.debug("Viewing chat; sent READ ack directly to Nostr pub=\(senderPubkey.prefix(8))… for mid=\(messageId.prefix(8))…", category: .session) - } - } - } 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 @@ -5262,6 +5139,203 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { } } + @MainActor + private func handlePrivateMessage( + _ payload: NoisePayload, + actualSenderNoiseKey: Data?, + senderNickname: String, + targetPeerID: String, + messageTimestamp: Date, + senderPubkey: String + ) { + guard let pm = PrivateMessagePacket.decode(from: payload.data) else { return } + let messageId = pm.messageID + let messageContent = pm.content + + // 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 + } + + if isDuplicateMessage(messageId, targetPeerID: targetPeerID) { + 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 + } + + // 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, + isPrivate: true, + recipientNickname: nickname, + senderPeerID: targetPeerID, + deliveryStatus: .delivered(to: nickname, at: Date()) + ) + + addMessageToPrivateChatsIfNeeded(message, targetPeerID: targetPeerID) + mirrorToEphemeralIfNeeded(message, targetPeerID: targetPeerID, key: actualSenderNoiseKey) + + sendDeliveryAckViaNostrEmbedded( + message, + wasReadBefore: wasReadBefore, + senderPubkey: senderPubkey, + key: actualSenderNoiseKey + ) + + if wasReadBefore { + // do nothing + } else if isViewingThisChat { + handleViewingThisChat( + message, + targetPeerID: targetPeerID, + key: actualSenderNoiseKey, + senderPubkey: senderPubkey + ) + } else { + markAsUnreadIfNeeded( + shouldMarkAsUnread: shouldMarkAsUnread, + targetPeerID: targetPeerID, + key: actualSenderNoiseKey, + isRecentMessage: isRecentMessage, + senderNickname: senderNickname, + messageContent: messageContent + ) + } + + objectWillChange.send() + } + + private func isDuplicateMessage(_ messageId: String, targetPeerID: String) -> Bool { + if privateChats[targetPeerID]?.contains(where: { $0.id == messageId }) == true { + return true + } + for (_, messages) in privateChats where messages.contains(where: { $0.id == messageId }) { + return true + } + return false + } + + private func addMessageToPrivateChatsIfNeeded(_ message: BitchatMessage, targetPeerID: String) { + if privateChats[targetPeerID] == nil { + privateChats[targetPeerID] = [] + } + if let idx = privateChats[targetPeerID]?.firstIndex(where: { $0.id == message.id }) { + privateChats[targetPeerID]?[idx] = message + } else { + privateChats[targetPeerID]?.append(message) + } + // Sanitize to avoid duplicate IDs + privateChatManager.sanitizeChat(for: targetPeerID) + trimPrivateChatMessagesIfNeeded(for: targetPeerID) + } + + @MainActor + private func mirrorToEphemeralIfNeeded(_ message: BitchatMessage, targetPeerID: String, key: Data?) { + guard let key, + let ephemeralPeerID = unifiedPeerService.peers.first(where: { $0.noisePublicKey == key })?.id, + ephemeralPeerID != targetPeerID + else { + return + } + + if privateChats[ephemeralPeerID] == nil { + privateChats[ephemeralPeerID] = [] + } + if let idx = privateChats[ephemeralPeerID]?.firstIndex(where: { $0.id == message.id }) { + privateChats[ephemeralPeerID]?[idx] = message + } else { + privateChats[ephemeralPeerID]?.append(message) + trimPrivateChatMessagesIfNeeded(for: ephemeralPeerID) + } + privateChatManager.sanitizeChat(for: ephemeralPeerID) + } + + @MainActor + private func sendDeliveryAckViaNostrEmbedded(_ message: BitchatMessage, wasReadBefore: Bool, senderPubkey: String, key: Data?) { + guard !wasReadBefore else { return } + + if let key { + SecureLogger.debug("Sending DELIVERED ack for \(message.id.prefix(8))… via router", category: .session) + messageRouter.sendDeliveryAck(message.id, to: key.hexEncodedString()) + } else if let id = try? NostrIdentityBridge.getCurrentNostrIdentity() { + // Fallback: no Noise mapping yet — send directly to sender's Nostr pubkey + let nt = NostrTransport(keychain: keychain) + nt.senderPeerID = meshService.myPeerID + nt.sendDeliveryAckGeohash(for: message.id, toRecipientHex: senderPubkey, from: id) + SecureLogger.debug("Sent DELIVERED ack directly to Nostr pub=\(senderPubkey.prefix(8))… for mid=\(message.id.prefix(8))…", category: .session) + } + } + + @MainActor + private func handleViewingThisChat(_ message: BitchatMessage, targetPeerID: String, key: Data?, senderPubkey: String) { + unreadPrivateMessages.remove(targetPeerID) + if let key, + let ephemeralPeerID = unifiedPeerService.peers.first(where: { $0.noisePublicKey == key })?.id { + unreadPrivateMessages.remove(ephemeralPeerID) + } + if !sentReadReceipts.contains(message.id) { + if let key { + let receipt = ReadReceipt(originalMessageID: message.id, readerID: meshService.myPeerID, readerNickname: nickname) + SecureLogger.debug("Viewing chat; sending READ ack for \(message.id.prefix(8))… via router", category: .session) + messageRouter.sendReadReceipt(receipt, to: key.hexEncodedString()) + sentReadReceipts.insert(message.id) + } else if let id = try? NostrIdentityBridge.getCurrentNostrIdentity() { + let nt = NostrTransport(keychain: keychain) + nt.senderPeerID = meshService.myPeerID + nt.sendReadReceiptGeohash(message.id, toRecipientHex: senderPubkey, from: id) + sentReadReceipts.insert(message.id) + SecureLogger.debug("Viewing chat; sent READ ack directly to Nostr pub=\(senderPubkey.prefix(8))… for mid=\(message.id.prefix(8))…", category: .session) + } + } + } + + @MainActor + private func markAsUnreadIfNeeded( + shouldMarkAsUnread: Bool, + targetPeerID: String, + key: Data?, + isRecentMessage: Bool, + senderNickname: String, + messageContent: String + ) { + guard shouldMarkAsUnread else { return } + + unreadPrivateMessages.insert(targetPeerID) + if let key, + 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 + ) + } + } + @MainActor private func handleFavoriteNotification(content: String, from nostrPubkey: String) { let isFavorite = content.hasPrefix("FAVORITED")