// // PrivateChatManager.swift // bitchat // // Manages private chat sessions and messages // This is free and unencumbered software released into the public domain. // import BitLogger import BitFoundation import Foundation import SwiftUI /// Manages all private chat functionality final class PrivateChatManager: ObservableObject { @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 weak var meshService: Transport? // Route acks/receipts via MessageRouter (chooses mesh or Nostr) weak var messageRouter: MessageRouter? // Peer service for looking up peer info during consolidation weak var unifiedPeerService: UnifiedPeerService? init(meshService: Transport? = nil) { self.meshService = meshService } // Cap for messages stored per private chat private let privateChatCap = TransportConfig.privateChatCap // MARK: - Message Consolidation /// Consolidates messages from different peer ID representations into a single chat. /// This ensures messages from stable Noise keys and temporary Nostr peer IDs are merged. /// - Parameters: /// - peerID: The target peer ID to consolidate messages into /// - peerNickname: The peer's display name (lowercased for matching) /// - persistedReadReceipts: The persisted read receipts set from ChatViewModel (UserDefaults-backed) /// - Returns: True if any unread messages were found during consolidation @MainActor func consolidateMessages(for peerID: PeerID, peerNickname: String, persistedReadReceipts: Set) -> Bool { guard let meshService = meshService else { return false } var hasUnreadMessages = false // 1. Consolidate from stable Noise key (64-char hex) if let peer = unifiedPeerService?.getPeer(by: peerID) { let noiseKeyHex = PeerID(hexData: peer.noisePublicKey) if noiseKeyHex != peerID, let nostrMessages = privateChats[noiseKeyHex], !nostrMessages.isEmpty { if privateChats[peerID] == nil { privateChats[peerID] = [] } let existingMessageIds = Set(privateChats[peerID]?.map { $0.id } ?? []) for message in nostrMessages { if !existingMessageIds.contains(message.id) { // Update senderPeerID for correct read receipts let updatedMessage = BitchatMessage( id: message.id, sender: message.sender, content: message.content, timestamp: message.timestamp, isRelay: message.isRelay, originalSender: message.originalSender, isPrivate: message.isPrivate, recipientNickname: message.recipientNickname, senderPeerID: message.senderPeerID == meshService.myPeerID ? meshService.myPeerID : peerID, mentions: message.mentions, deliveryStatus: message.deliveryStatus ) privateChats[peerID]?.append(updatedMessage) // Check for recent unread messages (< 60s, not sent by us, not already read) // Use persistedReadReceipts to correctly identify already-read messages after app restart if message.senderPeerID != meshService.myPeerID { let messageAge = Date().timeIntervalSince(message.timestamp) if messageAge < 60 && !persistedReadReceipts.contains(message.id) { hasUnreadMessages = true } } } } privateChats[peerID]?.sort { $0.timestamp < $1.timestamp } if hasUnreadMessages { unreadMessages.insert(peerID) } else if unreadMessages.contains(noiseKeyHex) { unreadMessages.remove(noiseKeyHex) } privateChats.removeValue(forKey: noiseKeyHex) } } // 2. Consolidate from temporary Nostr peer IDs (nostr_* prefixed) let normalizedNickname = peerNickname.lowercased() var tempPeerIDsToConsolidate: [PeerID] = [] for (storedPeerID, messages) in privateChats { if storedPeerID.isGeoDM && storedPeerID != peerID { let nicknamesMatch = messages.allSatisfy { $0.sender.lowercased() == normalizedNickname } if nicknamesMatch && !messages.isEmpty { tempPeerIDsToConsolidate.append(storedPeerID) } } } if !tempPeerIDsToConsolidate.isEmpty { if privateChats[peerID] == nil { privateChats[peerID] = [] } let existingMessageIds = Set(privateChats[peerID]?.map { $0.id } ?? []) var consolidatedCount = 0 var hadUnreadTemp = false for tempPeerID in tempPeerIDsToConsolidate { if unreadMessages.contains(tempPeerID) { hadUnreadTemp = true } if let tempMessages = privateChats[tempPeerID] { for message in tempMessages { if !existingMessageIds.contains(message.id) { let updatedMessage = BitchatMessage( id: message.id, sender: message.sender, content: message.content, timestamp: message.timestamp, isRelay: message.isRelay, originalSender: message.originalSender, isPrivate: message.isPrivate, recipientNickname: message.recipientNickname, senderPeerID: peerID, mentions: message.mentions, deliveryStatus: message.deliveryStatus ) privateChats[peerID]?.append(updatedMessage) consolidatedCount += 1 } } privateChats.removeValue(forKey: tempPeerID) unreadMessages.remove(tempPeerID) } } if hadUnreadTemp { unreadMessages.insert(peerID) hasUnreadMessages = true SecureLogger.debug("📬 Transferred unread status from temp peer IDs to \(peerID)", category: .session) } if consolidatedCount > 0 { privateChats[peerID]?.sort { $0.timestamp < $1.timestamp } SecureLogger.info("📥 Consolidated \(consolidatedCount) Nostr messages from temporary peer IDs to \(peerNickname)", category: .session) } } return hasUnreadMessages } /// Syncs the read receipt tracking between manager and view model for sent messages @MainActor func syncReadReceiptsForSentMessages(peerID: PeerID, nickname: String, externalReceipts: inout Set) { guard let messages = privateChats[peerID] else { return } for message in messages { if message.sender == nickname { if let status = message.deliveryStatus { switch status { case .read, .delivered: externalReceipts.insert(message.id) sentReadReceipts.insert(message.id) case .failed, .partiallyDelivered, .sending, .sent: break } } } } } /// Start a private chat with a peer func startChat(with peerID: PeerID) { selectedPeer = peerID // Store fingerprint for persistence across reconnections if let fingerprint = meshService?.getFingerprint(for: peerID) { selectedPeerFingerprint = fingerprint } // Mark messages as read markAsRead(from: peerID) // Initialize chat if needed if privateChats[peerID] == nil { privateChats[peerID] = [] } } /// End the current private chat func endChat() { selectedPeer = nil selectedPeerFingerprint = nil } /// Remove duplicate messages by ID and keep chronological order func sanitizeChat(for peerID: PeerID) { guard let arr = privateChats[peerID] else { return } if arr.count <= 1 { return } var indexByID: [String: Int] = [:] indexByID.reserveCapacity(arr.count) var deduped: [BitchatMessage] = [] deduped.reserveCapacity(arr.count) for msg in arr.sorted(by: { $0.timestamp < $1.timestamp }) { if let existing = indexByID[msg.id] { deduped[existing] = msg } else { indexByID[msg.id] = deduped.count deduped.append(msg) } } privateChats[peerID] = deduped } /// Mark messages from a peer as read func markAsRead(from peerID: PeerID) { unreadMessages.remove(peerID) // Send read receipts for unread messages that haven't been sent yet if let messages = privateChats[peerID] { for message in messages { if message.senderPeerID == peerID && !message.isRelay && !sentReadReceipts.contains(message.id) { sendReadReceipt(for: message) } } } } // MARK: - Private Methods private func sendReadReceipt(for message: BitchatMessage) { guard !sentReadReceipts.contains(message.id), let senderPeerID = message.senderPeerID else { return } sentReadReceipts.insert(message.id) // Create read receipt using the simplified method let receipt = ReadReceipt( originalMessageID: message.id, readerID: meshService?.myPeerID ?? PeerID(str: ""), readerNickname: meshService?.myNickname ?? "" ) // Route via MessageRouter to avoid handshakeRequired spam when session isn't established if let router = messageRouter { SecureLogger.debug("PrivateChatManager: sending READ ack for \(message.id.prefix(8))… to \(senderPeerID.id.prefix(8))… via router", category: .session) Task { @MainActor in router.sendReadReceipt(receipt, to: senderPeerID) } } else { // Fallback: preserve previous behavior meshService?.sendReadReceipt(receipt, to: senderPeerID) } } }