import BitFoundation import BitLogger import CoreBluetooth import Foundation /// The narrow surface `ChatPeerIdentityCoordinator` needs from its owner. /// /// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the /// minimal context it actually uses instead of holding an `unowned` back-ref /// to the whole `ChatViewModel`. This keeps the coordinator independently /// testable (see `ChatPeerIdentityCoordinatorContextTests`) and makes its true /// dependencies explicit. Several members are flattened service accesses — /// this coordinator implements the `ChatViewModel`-level peer-identity API, so /// its context members deliberately sit one level below those wrappers /// (`unifiedIsBlocked(_:)` vs `isPeerBlocked(_:)`, `unifiedFingerprint(for:)` /// vs `getFingerprint(for:)`, …) to avoid call cycles. @MainActor protocol ChatPeerIdentityContext: AnyObject { // MARK: Conversation state var privateChats: [PeerID: [BitchatMessage]] { get set } var unreadPrivateMessages: Set { get set } var selectedPrivateChatPeer: PeerID? { get set } var selectedPrivateChatFingerprint: String? { get set } var nickname: String { get } var myPeerID: PeerID { get } var activeChannel: ChannelID { get } /// Signals that message state changed so observers refresh (e.g. `objectWillChange.send()`). func notifyUIChanged() func addSystemMessage(_ content: String) // MARK: Private chat session lifecycle /// Merges messages stored under alternate peer-ID representations into `peerID`'s chat. /// Returns `true` when unread messages were discovered during consolidation. @discardableResult func consolidatePrivateMessages(for peerID: PeerID, peerNickname: String) -> Bool /// Marks read receipts as sent for own messages already delivered/read in /// `peerID`'s chat. (Single mutation path into the owner's /// `sentReadReceipts`; this coordinator never touches the raw set.) func syncReadReceiptsForSentMessages(for peerID: PeerID) /// Re-targets the private chat session in the chat manager (no store-sync side effects). func beginPrivateChatSession(with peerID: PeerID) func synchronizePrivateConversationStore() func synchronizeConversationSelectionStore() func markPrivateMessagesAsRead(from peerID: PeerID) // MARK: Unified peer service var connectedPeers: Set { get } /// The peer's current entry in the unified peer service, if known. func unifiedPeer(for peerID: PeerID) -> BitchatPeer? func unifiedIsBlocked(_ peerID: PeerID) -> Bool func unifiedToggleFavorite(_ peerID: PeerID) func unifiedFingerprint(for peerID: PeerID) -> String? func unifiedPeerID(forNickname nickname: String) -> PeerID? /// Resolves the ephemeral (short) peer ID for a known Noise public key, if connected. func ephemeralPeerID(forNoiseKey noiseKey: Data) -> PeerID? // MARK: Mesh & Noise sessions func peerNickname(for peerID: PeerID) -> String? func meshPeerNicknames() -> [PeerID: String] func noiseSessionState(for peerID: PeerID) -> LazyHandshakeState func triggerHandshake(with peerID: PeerID) func hasEstablishedNoiseSession(with peerID: PeerID) -> Bool func hasNoiseSession(with peerID: PeerID) -> Bool /// Our own Noise identity fingerprint. func noiseIdentityFingerprint() -> String // MARK: Identity store (fingerprints & encryption status) func setStoredFingerprint(_ fingerprint: String, for peerID: PeerID) /// Moves the stored fingerprint mapping from `oldPeerID` to `newPeerID`, /// falling back to `fallback` when none was stored. Returns the migrated fingerprint. func migrateFingerprintMapping(from oldPeerID: PeerID, to newPeerID: PeerID, fallback: String?) -> String? func isVerifiedFingerprint(_ fingerprint: String) -> Bool func setEncryptionStatus(_ status: EncryptionStatus?, for peerID: PeerID) func cachedEncryptionStatus(for peerID: PeerID) -> EncryptionStatus? func setCachedEncryptionStatus(_ status: EncryptionStatus, for peerID: PeerID) func invalidateStoredEncryptionCache(for peerID: PeerID?) func socialIdentity(forFingerprint fingerprint: String) -> SocialIdentity? // MARK: Geohash & Nostr var geoNicknames: [String: String] { get } func visibleGeohashPeople() -> [GeoPerson] /// Records the Nostr pubkey behind a (possibly virtual) peer ID. func registerNostrKeyMapping(_ pubkey: String, for peerID: PeerID) func bridgedNostrPublicKey(for noiseKey: Data) -> String? func sendFavoriteNotificationViaNostr(noisePublicKey: Data, isFavorite: Bool) } extension ChatViewModel: ChatPeerIdentityContext { // `privateChats`, `unreadPrivateMessages`, `selectedPrivateChatPeer`, // `selectedPrivateChatFingerprint`, `nickname`, `myPeerID`, // `activeChannel`, `connectedPeers`, `geoNicknames`, `notifyUIChanged()`, // `addSystemMessage(_:)`, `peerNickname(for:)`, `meshPeerNicknames()`, // `ephemeralPeerID(forNoiseKey:)`, `unifiedPeer(for:)`, // `registerNostrKeyMapping(_:for:)`, `visibleGeohashPeople()`, // `markPrivateMessagesAsRead(from:)`, `sendFavoriteNotificationViaNostr`, // and the conversation-store sync methods are shared requirements with // the other contexts or satisfied by existing `ChatViewModel` members. // The single-writer intent op `syncReadReceiptsForSentMessages(for:)` // lives next to its backing state in `ChatViewModel`. The members below // flatten nested service accesses into intent-named calls. @discardableResult func consolidatePrivateMessages(for peerID: PeerID, peerNickname: String) -> Bool { privateChatManager.consolidateMessages( for: peerID, peerNickname: peerNickname, persistedReadReceipts: sentReadReceipts ) } func beginPrivateChatSession(with peerID: PeerID) { privateChatManager.startChat(with: peerID) } func unifiedIsBlocked(_ peerID: PeerID) -> Bool { unifiedPeerService.isBlocked(peerID) } func unifiedToggleFavorite(_ peerID: PeerID) { unifiedPeerService.toggleFavorite(peerID) } func unifiedFingerprint(for peerID: PeerID) -> String? { unifiedPeerService.getFingerprint(for: peerID) } func unifiedPeerID(forNickname nickname: String) -> PeerID? { unifiedPeerService.getPeerID(for: nickname) } func noiseSessionState(for peerID: PeerID) -> LazyHandshakeState { meshService.getNoiseSessionState(for: peerID) } func triggerHandshake(with peerID: PeerID) { meshService.triggerHandshake(with: peerID) } func hasEstablishedNoiseSession(with peerID: PeerID) -> Bool { meshService.getNoiseService().hasEstablishedSession(with: peerID) } func hasNoiseSession(with peerID: PeerID) -> Bool { meshService.getNoiseService().hasSession(with: peerID) } func noiseIdentityFingerprint() -> String { meshService.getNoiseService().getIdentityFingerprint() } func setStoredFingerprint(_ fingerprint: String, for peerID: PeerID) { peerIdentityStore.setFingerprint(fingerprint, for: peerID) } func migrateFingerprintMapping(from oldPeerID: PeerID, to newPeerID: PeerID, fallback: String?) -> String? { peerIdentityStore.migrateFingerprintMapping(from: oldPeerID, to: newPeerID, fallback: fallback) } func isVerifiedFingerprint(_ fingerprint: String) -> Bool { peerIdentityStore.isVerified(fingerprint) } func setEncryptionStatus(_ status: EncryptionStatus?, for peerID: PeerID) { peerIdentityStore.setEncryptionStatus(status, for: peerID) } func cachedEncryptionStatus(for peerID: PeerID) -> EncryptionStatus? { peerIdentityStore.cachedEncryptionStatus(for: peerID) } func setCachedEncryptionStatus(_ status: EncryptionStatus, for peerID: PeerID) { peerIdentityStore.setCachedEncryptionStatus(status, for: peerID) } func invalidateStoredEncryptionCache(for peerID: PeerID?) { peerIdentityStore.invalidateEncryptionCache(for: peerID) } func socialIdentity(forFingerprint fingerprint: String) -> SocialIdentity? { identityManager.getSocialIdentity(for: fingerprint) } func bridgedNostrPublicKey(for noiseKey: Data) -> String? { idBridge.getNostrPublicKey(for: noiseKey) } } final class ChatPeerIdentityCoordinator { private unowned let context: any ChatPeerIdentityContext init(context: any ChatPeerIdentityContext) { self.context = context } @MainActor func openMostRelevantPrivateChat() { let unreadSorted = context.unreadPrivateMessages .map { ($0, context.privateChats[$0]?.last?.timestamp ?? Date.distantPast) } .sorted { $0.1 > $1.1 } if let target = unreadSorted.first?.0 { startPrivateChat(with: target) return } let recent = context.privateChats .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) } } @MainActor func isPeerBlocked(_ peerID: PeerID) -> Bool { context.unifiedIsBlocked(peerID) } @MainActor func hasUnreadMessages(for peerID: PeerID) -> Bool { var noiseKeyPeerID: PeerID? var nostrPeerID: PeerID? if let peer = context.unifiedPeer(for: peerID) { noiseKeyPeerID = PeerID(hexData: peer.noisePublicKey) if let nostrHex = peer.nostrPublicKey { nostrPeerID = PeerID(nostr_: nostrHex) } } let unreadContext = ChatUnreadPeerContext( peerID: peerID, noiseKeyPeerID: noiseKeyPeerID, nostrPeerID: nostrPeerID, nickname: context.peerNickname(for: peerID) ) return ChatUnreadStateResolver.hasUnreadMessages( for: unreadContext, unreadPrivateMessages: context.unreadPrivateMessages, privateChats: context.privateChats ) } @MainActor func toggleFavorite(peerID: PeerID) { if let noisePublicKey = peerID.noiseKey { toggleFavoriteForNoiseKey(noisePublicKey, peerID: peerID) return } context.unifiedToggleFavorite(peerID) context.notifyUIChanged() } @MainActor func isFavorite(peerID: PeerID) -> Bool { if let noisePublicKey = peerID.noiseKey { return FavoritesPersistenceService.shared.getFavoriteStatus(for: noisePublicKey)?.isFavorite ?? false } return context.unifiedPeer(for: peerID)?.isFavorite ?? false } @MainActor func updatePrivateChatPeerIfNeeded() { guard let chatFingerprint = context.selectedPrivateChatFingerprint, let currentPeerID = currentPeerID(forFingerprint: chatFingerprint) else { return } if let oldPeerID = context.selectedPrivateChatPeer, oldPeerID != currentPeerID { migrateChatState(from: oldPeerID, to: currentPeerID) context.selectedPrivateChatPeer = currentPeerID } else if context.selectedPrivateChatPeer == nil { context.selectedPrivateChatPeer = currentPeerID } var unread = context.unreadPrivateMessages unread.remove(currentPeerID) context.unreadPrivateMessages = unread } @MainActor func startPrivateChat(with peerID: PeerID) { guard peerID != context.myPeerID else { return } let peerNickname = context.peerNickname(for: peerID) ?? "unknown" if context.unifiedIsBlocked(peerID) { context.addSystemMessage( String( format: String( localized: "system.chat.blocked", comment: "System message when starting chat fails because peer is blocked" ), locale: .current, peerNickname ) ) return } if let peer = context.unifiedPeer(for: peerID), peer.isFavorite && !peer.theyFavoritedUs && !peer.isConnected { context.addSystemMessage( String( format: String( localized: "system.chat.requires_favorite", comment: "System message when mutual favorite requirement blocks chat" ), locale: .current, peerNickname ) ) return } _ = context.consolidatePrivateMessages(for: peerID, peerNickname: peerNickname) if !peerID.isGeoDM && !peerID.isGeoChat { switch context.noiseSessionState(for: peerID) { case .none, .failed: context.triggerHandshake(with: peerID) case .handshakeQueued, .handshaking, .established: break } } else { SecureLogger.debug("GeoDM: skipping mesh handshake for virtual peerID=\(peerID)", category: .session) } context.syncReadReceiptsForSentMessages(for: peerID) if let fingerprint = getFingerprint(for: peerID) { context.setStoredFingerprint(fingerprint, for: peerID) context.selectedPrivateChatFingerprint = fingerprint } else { context.selectedPrivateChatFingerprint = nil } context.beginPrivateChatSession(with: peerID) context.synchronizePrivateConversationStore() context.synchronizeConversationSelectionStore() context.markPrivateMessagesAsRead(from: peerID) } @MainActor func endPrivateChat() { context.selectedPrivateChatPeer = nil context.selectedPrivateChatFingerprint = nil } @MainActor func handlePeerStatusUpdate() { updatePrivateChatPeerIfNeeded() } func handleFavoriteStatusChanged(_ notification: Notification) { guard let peerPublicKey = notification.userInfo?["peerPublicKey"] as? Data else { return } Task { @MainActor [weak context = self.context] in guard let context else { return } if let isKeyUpdate = notification.userInfo?["isKeyUpdate"] as? Bool, isKeyUpdate, let oldKey = notification.userInfo?["oldPeerPublicKey"] as? Data { migrateNoiseKeyUpdate( oldPeerID: PeerID(hexData: oldKey), newPeerID: PeerID(hexData: peerPublicKey) ) } updatePrivateChatPeerIfNeeded() if let isFavorite = notification.userInfo?["isFavorite"] as? Bool { let peerID = PeerID(hexData: peerPublicKey) let action = isFavorite ? "favorited" : "unfavorited" let peerNickname = favoriteNotificationNickname(for: peerID, peerPublicKey: peerPublicKey) context.addSystemMessage("\(peerNickname) \(action) you") } } } @MainActor func updateEncryptionStatusForPeers() { for peerID in context.connectedPeers { updateEncryptionStatus(for: peerID) } } @MainActor func updateEncryptionStatus(for peerID: PeerID) { if context.hasEstablishedNoiseSession(with: peerID) { context.setEncryptionStatus(verifiedEncryptionStatus(for: peerID), for: peerID) } else if context.hasNoiseSession(with: peerID) { context.setEncryptionStatus(.noiseHandshaking, for: peerID) } else { context.setEncryptionStatus(nil, for: peerID) } invalidateEncryptionCache(for: peerID) } @MainActor func getEncryptionStatus(for peerID: PeerID) -> EncryptionStatus { if let cachedStatus = context.cachedEncryptionStatus(for: peerID) { return cachedStatus } let hasEverEstablishedSession = getFingerprint(for: peerID) != nil let sessionState = context.noiseSessionState(for: peerID) let status: EncryptionStatus switch sessionState { case .established: status = verifiedEncryptionStatus(for: peerID) case .handshaking, .handshakeQueued: status = hasEverEstablishedSession ? verifiedEncryptionStatus(for: peerID) : .noiseHandshaking case .none: status = hasEverEstablishedSession ? verifiedEncryptionStatus(for: peerID) : .noHandshake case .failed: status = hasEverEstablishedSession ? verifiedEncryptionStatus(for: peerID) : .none } context.setCachedEncryptionStatus(status, for: peerID) return status } @MainActor func invalidateEncryptionCache(for peerID: PeerID? = nil) { context.invalidateStoredEncryptionCache(for: peerID) } @MainActor func getFingerprint(for peerID: PeerID) -> String? { context.unifiedFingerprint(for: peerID) } @MainActor func resolveNickname(for peerID: PeerID) -> String { guard !peerID.isEmpty else { return "unknown" } if !peerID.isHex { return peerID.id } if let nickname = context.meshPeerNicknames()[peerID] { return nickname } if let fingerprint = getFingerprint(for: peerID), let identity = context.socialIdentity(forFingerprint: fingerprint) { if let petname = identity.localPetname { return petname } return identity.claimedNickname } let prefixLength = min(4, peerID.id.count) let prefix = String(peerID.id.prefix(prefixLength)) return prefix.starts(with: "anon") ? "peer\(prefix)" : "anon\(prefix)" } @MainActor func getMyFingerprint() -> String { context.noiseIdentityFingerprint() } @MainActor func getPeerIDForNickname(_ nickname: String) -> PeerID? { switch context.activeChannel { case .location: if nickname.contains("#"), let person = context.visibleGeohashPeople() .first(where: { $0.displayName == nickname }) { let conversationKey = PeerID(nostr_: person.id) context.registerNostrKeyMapping(person.id, for: conversationKey) return conversationKey } let base = nickname .split(separator: "#", maxSplits: 1, omittingEmptySubsequences: false) .first .map(String.init)? .lowercased() ?? nickname.lowercased() if let pubkey = context.geoNicknames.first(where: { $0.value.lowercased() == base })?.key { let conversationKey = PeerID(nostr_: pubkey) context.registerNostrKeyMapping(pubkey, for: conversationKey) return conversationKey } case .mesh: break } return context.unifiedPeerID(forNickname: nickname) } @MainActor func nicknameForPeer(_ peerID: PeerID) -> String { if let name = context.peerNickname(for: peerID) { return name } if let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID), !favorite.peerNickname.isEmpty { return favorite.peerNickname } if let noiseKey = Data(hexString: peerID.id), let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey), !favorite.peerNickname.isEmpty { return favorite.peerNickname } return "user" } } private extension ChatPeerIdentityCoordinator { @MainActor func currentPeerID(forFingerprint fingerprint: String) -> PeerID? { for peerID in context.connectedPeers where getFingerprint(for: peerID) == fingerprint { return peerID } return nil } @MainActor func migrateChatState(from oldPeerID: PeerID, to newPeerID: PeerID) { if let oldMessages = context.privateChats[oldPeerID] { var chats = context.privateChats chats[newPeerID, default: []].append(contentsOf: oldMessages) chats[newPeerID]?.sort { $0.timestamp < $1.timestamp } var seenMessageIDs = Set() chats[newPeerID] = chats[newPeerID]?.filter { message in if seenMessageIDs.contains(message.id) { return false } seenMessageIDs.insert(message.id) return true } chats.removeValue(forKey: oldPeerID) context.privateChats = chats } var unread = context.unreadPrivateMessages if unread.contains(oldPeerID) { unread.remove(oldPeerID) unread.insert(newPeerID) context.unreadPrivateMessages = unread } } @MainActor func migrateNoiseKeyUpdate(oldPeerID: PeerID, newPeerID: PeerID) { if context.selectedPrivateChatPeer == oldPeerID { SecureLogger.info("📱 Updating private chat peer ID due to key change: \(oldPeerID) -> \(newPeerID)", category: .session) } else if context.privateChats[oldPeerID] != nil { SecureLogger.debug("📱 Migrating private chat messages from \(oldPeerID) to \(newPeerID)", category: .session) } migrateChatState(from: oldPeerID, to: newPeerID) if context.selectedPrivateChatPeer == oldPeerID { context.selectedPrivateChatPeer = newPeerID } if let fingerprint = context.migrateFingerprintMapping( from: oldPeerID, to: newPeerID, fallback: getFingerprint(for: newPeerID) ) { if context.selectedPrivateChatPeer == newPeerID { context.selectedPrivateChatFingerprint = fingerprint } } } @MainActor func favoriteNotificationNickname(for peerID: PeerID, peerPublicKey: Data) -> String { if let nickname = context.peerNickname(for: peerID) { return nickname } if let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(for: peerPublicKey) { return favorite.peerNickname } return "Unknown" } @MainActor func verifiedEncryptionStatus(for peerID: PeerID) -> EncryptionStatus { if let fingerprint = getFingerprint(for: peerID), context.isVerifiedFingerprint(fingerprint) { return .noiseVerified } return .noiseSecured } @MainActor func toggleFavoriteForNoiseKey(_ noisePublicKey: Data, peerID: PeerID) { if let ephemeralID = context.ephemeralPeerID(forNoiseKey: noisePublicKey) { context.unifiedToggleFavorite(ephemeralID) context.notifyUIChanged() return } let currentStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noisePublicKey) let fallbackNickname = context.privateChats[peerID]?.first { $0.senderPeerID == peerID }?.sender let plan = ChatFavoriteTogglePolicy.plan( currentStatus: currentStatus.map(ChatFavoriteStatusSnapshot.init), fallbackNickname: fallbackNickname, bridgedNostrKey: context.bridgedNostrPublicKey(for: noisePublicKey) ) switch plan.persistenceAction { case .add(let nickname, let nostrKey): FavoritesPersistenceService.shared.addFavorite( peerNoisePublicKey: noisePublicKey, peerNostrPublicKey: nostrKey, peerNickname: nickname ) case .remove: FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noisePublicKey) } context.notifyUIChanged() if case .send(let isFavorite) = plan.notification { context.sendFavoriteNotificationViaNostr( noisePublicKey: noisePublicKey, isFavorite: isFavorite ) } } }