// // ChatViewModel.swift // bitchat // // This is free and unencumbered software released into the public domain. // For more information, see // /// /// # ChatViewModel /// /// The central business logic and state management component for BitChat. /// Coordinates between the UI layer and the networking/encryption services. /// /// ## Overview /// ChatViewModel implements the MVVM pattern, serving as the binding layer between /// SwiftUI views and the underlying BitChat services. It manages: /// - Message state and delivery /// - Peer connections and presence /// - Private chat sessions /// - Command processing /// - UI state like autocomplete and notifications /// /// ## Architecture /// The ViewModel acts as: /// - **BitchatDelegate**: Receives messages and events from BLEService /// - **State Manager**: Maintains all UI-relevant state with @Published properties /// - **Command Processor**: Handles IRC-style commands (/msg, /who, etc.) /// - **Message Router**: Directs messages to appropriate chats (public/private) /// /// ## Key Features /// /// ### Message Management /// - Efficient message handling with duplicate detection /// - Maintains separate public and private message queues /// - Limits message history to prevent memory issues (1337 messages) /// - Tracks delivery and read receipts /// /// ### Privacy Features /// - Ephemeral by design - no persistent message storage /// - Supports verified fingerprints for secure communication /// - Blocks messages from blocked users /// - Emergency wipe capability (triple-tap) /// /// ### User Experience /// - Smart autocomplete for mentions and commands /// - Unread message indicators /// - Connection status tracking /// - Favorite peers management /// /// ## Command System /// Supports IRC-style commands: /// - `/nick `: Change nickname /// - `/msg `: Send private message /// - `/who`: List connected peers /// - `/slap `: Fun interaction /// - `/clear`: Clear message history /// - `/help`: Show available commands /// /// ## Performance Optimizations /// - SwiftUI automatically optimizes UI updates /// - Caches expensive computations (encryption status) /// - Debounces autocomplete suggestions /// - Efficient peer list management /// /// ## Thread Safety /// - All @Published properties trigger UI updates on main thread /// - Background operations use proper queue management /// - Atomic operations for critical state updates /// /// ## Usage Example /// ```swift /// let viewModel = ChatViewModel() /// viewModel.nickname = "Alice" /// viewModel.startServices() /// viewModel.sendMessage("Hello, mesh network!") /// ``` /// import BitLogger import BitFoundation import Foundation import SwiftUI import Combine import CommonCrypto import CoreBluetooth import Tor #if os(iOS) import UIKit #endif import UniformTypeIdentifiers /// Manages the application state and business logic for BitChat. /// Acts as the primary coordinator between UI components and backend services, /// implementing the BitchatDelegate protocol to handle network events. final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDelegate, CommandContextProvider, GeohashParticipantContext, MessageFormattingContext { // Use MessageFormattingEngine.Patterns for regex matching (shared, precompiled) typealias Patterns = MessageFormattingEngine.Patterns typealias GeoOutgoingContext = (channel: GeohashChannel, event: NostrEvent, identity: NostrIdentity, teleported: Bool) @MainActor var canSendMediaInCurrentContext: Bool { if let peer = selectedPrivateChatPeer { return !(peer.isGeoDM || peer.isGeoChat) } switch activeChannel { case .mesh: return true case .location: return false } } var publicRateLimiter = MessageRateLimiter( senderCapacity: TransportConfig.uiSenderRateBucketCapacity, senderRefillPerSec: TransportConfig.uiSenderRateBucketRefillPerSec, contentCapacity: TransportConfig.uiContentRateBucketCapacity, contentRefillPerSec: TransportConfig.uiContentRateBucketRefillPerSec ) // MARK: - Published Properties /// Read-only derived view of the ACTIVE public channel's conversation in /// the single-writer `ConversationStore`. SwiftUI renders through /// `PublicChatModel` (which observes the `Conversation` object directly); /// this view serves the coordinators/commands that need "the visible /// timeline" plus tests. Hot enough that the array is cached and /// invalidated from the store's `changes` subject (filtered to the /// active conversation) and on channel switches. `objectWillChange` /// fires on every store change via the sink in `init`. @MainActor var messages: [BitchatMessage] { if let cached = visibleMessagesCache { return cached } // Read-only lookup (never creates the conversation): this getter // runs during SwiftUI renders, where mutating the store's // `@Published` collections would publish mid-view-update. let current = conversations.conversationsByID[ConversationID(channelID: activeChannel)]?.messages ?? [] visibleMessagesCache = current return current } private var visibleMessagesCache: [BitchatMessage]? @Published var currentColorScheme: ColorScheme = .light @Published var currentTheme: AppTheme = .matrix @Published var isConnected = false @Published var nickname: String = "" { didSet { // Trim whitespace whenever nickname is set; whitespace-only becomes "" let trimmed = nickname.trimmedOrNilIfEmpty ?? "" if trimmed != nickname { nickname = trimmed return } // Update mesh service nickname if it's initialized if !meshService.myPeerID.isEmpty { meshService.setNickname(nickname) } } } // MARK: - Service Delegates let commandProcessor: CommandProcessor let messageRouter: MessageRouter let privateChatManager: PrivateChatManager let unifiedPeerService: UnifiedPeerService let autocompleteService: AutocompleteService let deduplicationService: MessageDeduplicationService // internal for test access private lazy var outgoingCoordinator = ChatOutgoingCoordinator(context: self) private lazy var lifecycleCoordinator = ChatLifecycleCoordinator(context: self) private lazy var transportEventCoordinator = ChatTransportEventCoordinator(context: self) private lazy var peerListCoordinator = ChatPeerListCoordinator(context: self) private lazy var messageFormatter = ChatMessageFormatter(viewModel: self) lazy var peerIdentityCoordinator = ChatPeerIdentityCoordinator(context: self) lazy var deliveryCoordinator = ChatDeliveryCoordinator(context: self) lazy var composerCoordinator = ChatComposerCoordinator(context: self) lazy var publicConversationCoordinator = ChatPublicConversationCoordinator(context: self) lazy var privateConversationCoordinator = ChatPrivateConversationCoordinator(context: self) lazy var nostrCoordinator = ChatNostrCoordinator(context: self) lazy var mediaTransferCoordinator = ChatMediaTransferCoordinator(context: self) lazy var verificationCoordinator = ChatVerificationCoordinator(context: self) // Computed properties for compatibility @MainActor var connectedPeers: Set { unifiedPeerService.connectedPeerIDs } @Published var allPeers: [BitchatPeer] = [] /// Read-only derived view of all direct conversations in the /// `ConversationStore`, keyed by routing peer ID. Serves the coordinator /// reads that genuinely need the whole dictionary (migration scans, /// unread resolution); simple per-peer reads go through /// `privateMessages(for:)` instead. All mutations go through the /// private-chat intent ops below. Rebuilt per access — /// O(#conversations) thanks to COW message arrays; measured equal to a /// change-invalidated cache on `pipeline.privateIngest`, so the simpler /// form wins. @MainActor var privateChats: [PeerID: [BitchatMessage]] { conversations.directMessagesByRoutingPeerID() } @MainActor var selectedPrivateChatPeer: PeerID? { get { privateChatManager.selectedPeer } set { if let peerID = newValue { privateChatManager.startChat(with: peerID) } else { privateChatManager.endChat() } } } /// Read-only derived view of the store's unread direct conversations. /// Mutate via `markPrivateChatUnread(_:)` / `markPrivateChatRead(_:)`. @MainActor var unreadPrivateMessages: Set { conversations.unreadDirectRoutingPeerIDs() } /// Check if there are any unread messages (including from temporary Nostr peer IDs) @MainActor var hasAnyUnreadMessages: Bool { !unreadPrivateMessages.isEmpty } /// Open the most relevant private chat when tapping the toolbar unread icon. /// Prefers the most recently active unread conversation, otherwise the most recent PM. @MainActor func openMostRelevantPrivateChat() { peerIdentityCoordinator.openMostRelevantPrivateChat() } // var peerIDToPublicKeyFingerprint: [PeerID: String] { get { peerIdentityStore.peerFingerprintsByPeerID } set { peerIdentityStore.replaceFingerprintMappings(newValue) } } var selectedPrivateChatFingerprint: String? { get { peerIdentityStore.selectedPrivateChatFingerprint } set { peerIdentityStore.setSelectedPrivateChatFingerprint(newValue) } } // Resolve full Noise key for a peer's short ID (used by UI header rendering) @MainActor private func getNoiseKeyForShortID(_ shortPeerID: PeerID) -> PeerID? { if let mapped = peerIdentityStore.stablePeerID(forShortID: shortPeerID) { return mapped } // Fallback: derive from active Noise session if available if shortPeerID.id.count == 16, let key = meshService.noiseSessionPublicKeyData(for: shortPeerID) { let stable = PeerID(hexData: key) peerIdentityStore.setStablePeerID(stable, forShortID: shortPeerID) return stable } return nil } // Resolve short mesh ID (16-hex) from a full Noise public key hex (64-hex) @MainActor func getShortIDForNoiseKey(_ fullNoiseKeyHex: PeerID) -> PeerID { guard fullNoiseKeyHex.id.count == 64 else { return fullNoiseKeyHex } // Check known peers for a noise key match if let match = allPeers.first(where: { PeerID(hexData: $0.noisePublicKey) == fullNoiseKeyHex }) { return match.peerID } // Also search cache mapping if let shortPeerID = peerIdentityStore.shortPeerID(forStablePeerID: fullNoiseKeyHex) { return shortPeerID } return fullNoiseKeyHex } @MainActor func cacheStablePeerID(_ stablePeerID: PeerID, for shortPeerID: PeerID) { peerIdentityStore.setStablePeerID(stablePeerID, forShortID: shortPeerID) } @MainActor func cachedStablePeerID(for shortPeerID: PeerID) -> PeerID? { peerIdentityStore.stablePeerID(forShortID: shortPeerID) } var hasTrackedPrivateChatSelection: Bool { selectedPrivateChatFingerprint != nil } var peerIndex: [PeerID: BitchatPeer] = [:] // MARK: - Autocomplete Properties @Published var autocompleteSuggestions: [String] = [] @Published var showAutocomplete: Bool = false @Published var autocompleteRange: NSRange? = nil @Published var selectedAutocompleteIndex: Int = 0 // MARK: - Services and Storage let meshService: Transport let idBridge: NostrIdentityBridge let identityManager: SecureIdentityStateManagerProtocol /// Single source of truth for conversation message state and selection /// (docs/CONVERSATION-STORE-DESIGN.md). Owned by `AppRuntime` and passed /// through. let conversations: ConversationStore let peerIdentityStore: PeerIdentityStore let locationPresenceStore: LocationPresenceStore let locationManager: LocationChannelManager var nostrRelayManager: NostrRelayManager? private let userDefaults = UserDefaults.standard let keychain: KeychainManagerProtocol private let nicknameKey = "bitchat.nickname" // Location channel state (macOS supports manual geohash selection) var activeChannel: ChannelID { get { conversations.activeChannel } set { guard conversations.activeChannel != newValue else { return } conversations.setActiveChannel(newValue) visibleMessagesCache = nil objectWillChange.send() } } // Single-writer: mutate only via `setGeoChatSubscriptionID(_:)` / `setGeoDmSubscriptionID(_:)` below. private(set) var geoSubscriptionID: String? = nil private(set) var geoDmSubscriptionID: String? = nil var currentGeohash: String? { get { locationPresenceStore.currentGeohash } set { locationPresenceStore.setCurrentGeohash(newValue) } } var cachedGeohashIdentity: (geohash: String, identity: NostrIdentity)? = nil // Cache current geohash identity var geoNicknames: [String: String] { get { locationPresenceStore.geoNicknames } set { locationPresenceStore.replaceGeoNicknames(newValue) } } // pubkeyHex(lowercased) -> nickname // Show Tor status once per app launch var torStatusAnnounced = false // Track whether a Tor restart is pending so we only announce // "tor restarted" after an actual restart, not the first launch. var torRestartPending: Bool = false // Ensure we set up DM subscription only once per app session var nostrHandlersSetup: Bool = false var geoChannelCoordinator: GeoChannelCoordinator? // MARK: - Caches // MARK: - Social Features (Delegated to PeerStateManager) @MainActor var favoritePeers: Set { unifiedPeerService.favoritePeers } @MainActor var blockedUsers: Set { unifiedPeerService.blockedUsers } // MARK: - Encryption and Security // Noise Protocol encryption status var peerEncryptionStatus: [PeerID: EncryptionStatus] { get { peerIdentityStore.encryptionStatuses } set { peerIdentityStore.replaceEncryptionStatuses(newValue) } } var verifiedFingerprints: Set { get { peerIdentityStore.verifiedFingerprints } set { peerIdentityStore.setVerifiedFingerprints(newValue) } } // Set of verified fingerprints // Bluetooth state management @Published var showBluetoothAlert = false @Published var bluetoothAlertMessage = "" @Published var bluetoothState: CBManagerState = .unknown private func performDeliveryUpdate(_ update: @escaping @MainActor (ChatDeliveryCoordinator) -> Void) { if Thread.isMainThread { MainActor.assumeIsolated { update(deliveryCoordinator) } return } Task { @MainActor [weak self] in guard let self else { return } update(self.deliveryCoordinator) } } // Channel activity tracking for background nudges var lastPublicActivityAt: [String: Date] = [:] // channelKey -> last activity time // Geohash participant tracker let participantTracker = GeohashParticipantTracker(activityCutoff: -TransportConfig.uiRecentCutoffFiveMinutesSeconds) // Participants who indicated they teleported (by tag in their events) var teleportedGeo: Set { get { locationPresenceStore.teleportedGeo } set { locationPresenceStore.replaceTeleportedGeo(newValue) } } // lowercased pubkey hex // Sampling subscriptions for multiple geohashes (when channel sheet is open) // Single-writer: mutate only via `addGeoSamplingSub` / `removeGeoSamplingSub` / `clearGeoSamplingSubs` below. private(set) var geoSamplingSubs: [String: String] = [:] // subID -> geohash var lastGeoNotificationAt: [String: Date] = [:] // geohash -> last notify time // MARK: - Message Delivery Tracking var cancellables = Set() var transferIdToMessageIDs: [String: [String]] { mediaTransferCoordinator.transferIdToMessageIDs } var messageIDToTransferId: [String: String] { mediaTransferCoordinator.messageIDToTransferId } // MARK: - Public message batching (UI perf) let publicMessagePipeline: PublicMessagePipeline // Single-writer: mutate only via `setPublicBatching(_:)` below. @Published private(set) var isBatchingPublic: Bool = false // Backing store for `sentReadReceipts` persistence. `.standard` in // production; injectable so tests can use a scratch suite that does not // leak state between runs. let readReceiptsDefaults: UserDefaults /// Default read-receipt persistence store. Production uses `.standard`. /// Under test, a dedicated scratch suite is used instead — wiped at first /// use per process — so back-to-back local test runs never see each /// other's persisted receipts (and tests never pollute `.standard`). static let defaultReadReceiptsDefaults: UserDefaults = { guard TestEnvironment.isRunningTests else { return .standard } let suiteName = "chat.bitchat.tests.readReceipts" guard let scratch = UserDefaults(suiteName: suiteName) else { return .standard } scratch.removePersistentDomain(forName: suiteName) return scratch }() // Track sent read receipts to avoid duplicates (persisted across launches) // Note: Persistence happens automatically in didSet, no lifecycle observers needed var sentReadReceipts: Set = [] { // messageID set didSet { // Only persist if there are changes guard oldValue != sentReadReceipts else { return } // Persist whenever it changes (no manual synchronize/verify re-read) if let data = try? JSONEncoder().encode(Array(sentReadReceipts)) { readReceiptsDefaults.set(data, forKey: "sentReadReceipts") } else { SecureLogger.error("❌ Failed to encode read receipts for persistence", category: .session) } } } // Track which GeoDM messages we've already sent a delivery ACK for (by messageID) // Single-writer: mutate only via `markGeoDeliveryAckSent(_:)` below. private(set) var sentGeoDeliveryAcks: Set = [] // Track app startup phase to prevent marking old messages as unread var isStartupPhase = true // ConversationStore field audit bookkeeping (see auditConversationStore()): // runs on the read-receipt cleanup cadence, heartbeat sampled first + // every `TransportConfig.conversationStoreAuditLogInterval`th audit. private var storeAuditCount = 0 private var storeAuditLastAppendCount = 0 // Announce Tor initial readiness once per launch to avoid duplicates var torInitialReadyAnnounced: Bool = false // Track Nostr pubkey mappings for unknown senders // Single-writer: mutate only via `registerNostrKeyMapping` / `removeNostrKeyMappings` below. private(set) var nostrKeyMapping: [PeerID: String] = [:] // senderPeerID -> nostrPubkey // MARK: - Single-Writer Intent Operations // Owner-side mutation paths for state the coordinator contexts may read // but not write directly. Each op is the sole way to mutate its backing // state, so check-then-mutate races between coordinators cannot occur. /// Records the Nostr pubkey behind a (possibly virtual) peer ID. @MainActor func registerNostrKeyMapping(_ pubkey: String, for peerID: PeerID) { nostrKeyMapping[peerID] = pubkey } /// Drops every key mapping that resolves to the given (lowercased) Nostr pubkey. @MainActor func removeNostrKeyMappings(matchingPubkeyHexLowercased hex: String) { for (key, value) in nostrKeyMapping where value.lowercased() == hex { nostrKeyMapping.removeValue(forKey: key) } } /// Records that a read receipt is being sent for `messageID`. /// Returns `false` when one was already recorded — the caller must skip sending. @MainActor @discardableResult func markReadReceiptSent(_ messageID: String) -> Bool { sentReadReceipts.insert(messageID).inserted } /// Records that a GeoDM delivery ACK is being sent for `messageID`. /// Returns `false` when one was already recorded — the caller must skip sending. @MainActor @discardableResult func markGeoDeliveryAckSent(_ messageID: String) -> Bool { sentGeoDeliveryAcks.insert(messageID).inserted } /// Forgets that read receipts were sent for `ids` so READ acks can be /// re-sent after the peer reconnects. @MainActor func unmarkReadReceiptsSent(_ ids: [String]) { sentReadReceipts.subtract(ids) } /// Marks read receipts as sent for own messages already delivered/read in /// `peerID`'s chat, syncing the chat manager's tracking with the persisted /// set. (Wraps the manager's `inout` sync so the raw set never leaks.) @MainActor func syncReadReceiptsForSentMessages(for peerID: PeerID) { privateChatManager.syncReadReceiptsForSentMessages( peerID: peerID, nickname: nickname, externalReceipts: &sentReadReceipts ) } /// Drops every recorded read receipt whose message ID is no longer valid. /// Returns the number of receipts removed. @MainActor func pruneSentReadReceipts(keeping validMessageIDs: Set) -> Int { let oldCount = sentReadReceipts.count sentReadReceipts = sentReadReceipts.intersection(validMessageIDs) return oldCount - sentReadReceipts.count } /// Publishes the public-timeline batching state (UI animation suppression). @MainActor func setPublicBatching(_ isBatching: Bool) { isBatchingPublic = isBatching } @MainActor func setGeoChatSubscriptionID(_ id: String?) { geoSubscriptionID = id } @MainActor func setGeoDmSubscriptionID(_ id: String?) { geoDmSubscriptionID = id } @MainActor func addGeoSamplingSub(_ subID: String, forGeohash geohash: String) { geoSamplingSubs[subID] = geohash } @MainActor func removeGeoSamplingSub(_ subID: String) { geoSamplingSubs.removeValue(forKey: subID) } /// Clears all sampling subscriptions and returns the removed subscription IDs /// so the caller can unsubscribe them from the relay manager. @MainActor func clearGeoSamplingSubs() -> [String] { let subIDs = Array(geoSamplingSubs.keys) geoSamplingSubs.removeAll() return subIDs } /// Moves the open private chat to `newPeerID` when the current selection is /// one of the peer IDs being migrated away (side-effectful: re-targets the /// private chat session — fingerprint refresh, read receipts). /// /// Note: when this runs after a store `migrateConversation`, the store has /// already handed the selection itself off to `newPeerID` (and the manager /// mirrors it), so a selection that reads `newPeerID` is also re-targeted /// to run the session side effects. Selections on unrelated peers are /// untouched. @MainActor func handOffSelectedPrivateChat(from oldPeerIDs: [PeerID], to newPeerID: PeerID) { guard oldPeerIDs.contains(where: { selectedPrivateChatPeer == $0 }) || selectedPrivateChatPeer == newPeerID else { return } selectedPrivateChatPeer = newPeerID } // MARK: - Private Conversation Store Intents // The sole mutation paths for private (direct) message state. Each op // forwards to the single-writer `ConversationStore` // (docs/CONVERSATION-STORE-DESIGN.md); the read-only `privateChats` / // `unreadPrivateMessages` views above are derived from the same store. /// Appends a private message in timestamp order. Returns `false` when a /// message with the same ID is already in that chat (O(1) dedup via the /// conversation's ID index). @MainActor @discardableResult func appendPrivateMessage(_ message: BitchatMessage, to peerID: PeerID) -> Bool { conversations.append(message, to: .directPeer(peerID)) } /// Replace-or-append a private message by ID (media progress, mirrored /// copies); an existing message keeps its timeline position. @MainActor func upsertPrivateMessage(_ message: BitchatMessage, in peerID: PeerID) { conversations.upsertByID(message, in: .directPeer(peerID)) } /// Applies a delivery status to a private message by ID. Returns `false` /// when the message is unknown or the update would downgrade the status /// (read beats delivered beats sent). @MainActor @discardableResult func setPrivateDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String, peerID: PeerID) -> Bool { conversations.setDeliveryStatus(status, forMessageID: messageID, in: .directPeer(peerID)) } /// Flags the peer's chat as unread (store unread state). @MainActor func markPrivateChatUnread(_ peerID: PeerID) { conversations.markUnread(.directPeer(peerID)) } /// Clears the peer's unread flag (store unread state only; read-receipt /// sending stays in `PrivateChatManager.markAsRead`). @MainActor func markPrivateChatRead(_ peerID: PeerID) { conversations.markRead(.directPeer(peerID)) } /// Empties the peer's chat but keeps the conversation alive (`/clear`). @MainActor func clearPrivateChat(_ peerID: PeerID) { conversations.clear(.directPeer(peerID)) } /// Removes the peer's chat entirely, including unread state. @MainActor func removePrivateChat(_ peerID: PeerID) { conversations.removeConversation(.directPeer(peerID)) } /// Moves all messages from `oldPeerID`'s chat into `newPeerID`'s chat /// (ephemeral↔stable peer-ID handoff): dedups by ID, preserves order, /// carries unread state, removes the old chat. @MainActor func migratePrivateChat(from oldPeerID: PeerID, to newPeerID: PeerID) { conversations.migrateConversation(from: .directPeer(oldPeerID), to: .directPeer(newPeerID)) } /// A single private chat's timeline, read straight from the store — /// an O(1) lookup that skips the `privateChats` dictionary build. The /// context protocols' simple per-peer reads dispatch here. @MainActor func privateMessages(for peerID: PeerID) -> [BitchatMessage] { conversations.conversationsByID[.directPeer(peerID)]?.messages ?? [] } /// `true` when any private chat contains a message with `messageID` /// (O(1) per conversation via the store's ID indexes). @MainActor func privateChatsContainMessage(withID messageID: String) -> Bool { conversations.directConversationsContainMessage(withID: messageID) } /// `true` when `peerID`'s chat contains a message with `messageID`. @MainActor func privateChat(_ peerID: PeerID, containsMessageWithID messageID: String) -> Bool { conversations.conversationsByID[.directPeer(peerID)]?.containsMessage(withID: messageID) ?? false } /// Removes a message by ID from every private chat that contains it, /// dropping chats that become empty. Returns the removed message, if any. @MainActor @discardableResult func removePrivateMessage(withID messageID: String) -> BitchatMessage? { var removed: BitchatMessage? for (id, conversation) in conversations.conversationsByID { guard case .direct = id, conversation.containsMessage(withID: messageID) else { continue } let message = conversations.removeMessage(withID: messageID, from: id) removed = removed ?? message if conversation.messages.isEmpty { conversations.removeConversation(id) } } return removed } // MARK: - Public Conversation Store Intents // The sole mutation paths for public (mesh/geohash) message state, // mirroring the private intents above. The store's per-conversation cap // and timestamp-ordered insert replace `PublicTimelineStore`'s trim and // the pipeline's late-insert positioning; the read-only `messages` shim // above is derived from the same store. /// Appends a public message in timestamp order. Returns `false` when a /// message with the same ID is already in that conversation (O(1) dedup /// via the conversation's ID index). @MainActor @discardableResult func appendPublicMessage(_ message: BitchatMessage, to conversationID: ConversationID) -> Bool { conversations.append(message, to: conversationID) } /// Appends a geohash message if absent. Returns `true` when stored /// (the legacy `PublicTimelineStore.appendIfAbsent` contract). @MainActor @discardableResult func appendGeohashMessageIfAbsent(_ message: BitchatMessage, toGeohash geohash: String) -> Bool { conversations.append(message, to: .geohash(geohash.lowercased())) } /// A public (mesh/geohash) channel's full timeline. @MainActor func publicMessages(for channel: ChannelID) -> [BitchatMessage] { conversations.conversation(for: ConversationID(channelID: channel)).messages } /// `true` when the conversation contains a message with `messageID`. @MainActor func publicConversationContainsMessage(withID messageID: String, in conversationID: ConversationID) -> Bool { conversations.conversationsByID[conversationID]?.containsMessage(withID: messageID) ?? false } /// Removes a message by ID from whichever public conversation contains /// it. Returns the removed message, if any. @MainActor @discardableResult func removePublicMessage(withID messageID: String) -> BitchatMessage? { conversations.removePublicMessage(withID: messageID) } /// Removes every message matching `predicate` from a geohash /// conversation (block-user purge). @MainActor func removePublicMessages(fromGeohash geohash: String, where predicate: (BitchatMessage) -> Bool) { conversations.removeMessages(from: .geohash(geohash.lowercased()), where: predicate) } /// Empties a public conversation's timeline (`/clear`). @MainActor func clearPublicConversation(_ conversationID: ConversationID) { conversations.clear(conversationID) } /// Queues a system message for the next geohash channel visit. (Tiny /// UI-flow queue formerly on `PublicTimelineStore`; it is notice text, /// not conversation state, so it stays on the owner.) @MainActor func queueGeohashSystemMessage(_ content: String) { pendingGeohashSystemMessages.append(content) } /// Drains the queued geohash system messages (single consumer: /// `GeohashSubscriptionManager.switchLocationChannel`). @MainActor func drainPendingGeohashSystemMessages() -> [String] { defer { pendingGeohashSystemMessages.removeAll(keepingCapacity: false) } return pendingGeohashSystemMessages } // Single-writer: mutate only via `queueGeohashSystemMessage(_:)` / // `drainPendingGeohashSystemMessages()` above. private var pendingGeohashSystemMessages: [String] = [] // MARK: - Initialization @MainActor convenience init( keychain: KeychainManagerProtocol, idBridge: NostrIdentityBridge, identityManager: SecureIdentityStateManagerProtocol, conversations: ConversationStore? = nil, peerIdentityStore: PeerIdentityStore? = nil, locationPresenceStore: LocationPresenceStore? = nil, locationManager: LocationChannelManager = .shared ) { self.init( keychain: keychain, idBridge: idBridge, identityManager: identityManager, transport: BLEService(keychain: keychain, idBridge: idBridge, identityManager: identityManager), conversations: conversations, peerIdentityStore: peerIdentityStore ?? PeerIdentityStore(), locationPresenceStore: locationPresenceStore ?? LocationPresenceStore(), locationManager: locationManager ) } /// Testable initializer that accepts a Transport dependency. /// Use this initializer for unit testing with MockTransport. @MainActor init( keychain: KeychainManagerProtocol, idBridge: NostrIdentityBridge, identityManager: SecureIdentityStateManagerProtocol, transport: Transport, conversations: ConversationStore? = nil, peerIdentityStore: PeerIdentityStore? = nil, locationPresenceStore: LocationPresenceStore? = nil, locationManager: LocationChannelManager = .shared, readReceiptsDefaults: UserDefaults? = nil ) { let conversations = conversations ?? ConversationStore() let peerIdentityStore = peerIdentityStore ?? PeerIdentityStore() let locationPresenceStore = locationPresenceStore ?? LocationPresenceStore() let services = ChatViewModelServiceBundle( keychain: keychain, idBridge: idBridge, identityManager: identityManager, meshService: transport ) self.keychain = keychain self.idBridge = idBridge self.identityManager = identityManager self.conversations = conversations self.peerIdentityStore = peerIdentityStore self.locationPresenceStore = locationPresenceStore self.locationManager = locationManager self.meshService = transport self.commandProcessor = services.commandProcessor self.messageRouter = services.messageRouter self.privateChatManager = services.privateChatManager self.unifiedPeerService = services.unifiedPeerService self.autocompleteService = services.autocompleteService self.deduplicationService = services.deduplicationService self.publicMessagePipeline = services.publicMessagePipeline let readReceiptsDefaults = readReceiptsDefaults ?? Self.defaultReadReceiptsDefaults self.readReceiptsDefaults = readReceiptsDefaults self.sentReadReceipts = ChatViewModelBootstrapper.loadPersistedReadReceipts(userDefaults: readReceiptsDefaults) // Republish on every store change so SwiftUI observers of the // view model refresh. This replaces the UI-update role of the old // `PrivateChatManager.@Published` dictionaries and the old // `@Published var messages`. Changes touching the ACTIVE public // conversation also invalidate the derived `messages` cache before // observers re-read it. conversations.changes .sink { [weak self] change in guard let self else { return } if self.changeAffectsActivePublicConversation(change) { self.visibleMessagesCache = nil } self.objectWillChange.send() } .store(in: &cancellables) ChatViewModelBootstrapper(viewModel: self).configure() } // MARK: - Deinitialization deinit { // No need to force UserDefaults synchronization } // MARK: - Nickname Management func loadNickname() { if let savedNickname = userDefaults.string(forKey: nicknameKey) { nickname = savedNickname.trimmed } else { nickname = "anon\(Int.random(in: 1000...9999))" saveNickname() } } func saveNickname() { userDefaults.set(nickname, forKey: nicknameKey) // Persist nickname; no need to force synchronize // Send announce with new nickname to all peers meshService.sendBroadcastAnnounce() } func validateAndSaveNickname() { nickname = nickname.trimmedOrNilIfEmpty ?? "anon\(Int.random(in: 1000...9999))" saveNickname() } // MARK: - Blocked Users Management (Delegated to PeerStateManager) /// Check if a peer has unread messages, including messages stored under stable Noise keys and temporary Nostr peer IDs @MainActor func hasUnreadMessages(for peerID: PeerID) -> Bool { peerIdentityCoordinator.hasUnreadMessages(for: peerID) } @MainActor func toggleFavorite(peerID: PeerID) { peerIdentityCoordinator.toggleFavorite(peerID: peerID) } @MainActor func isFavorite(peerID: PeerID) -> Bool { peerIdentityCoordinator.isFavorite(peerID: peerID) } // MARK: - Public Key and Identity Management @MainActor func isPeerBlocked(_ peerID: PeerID) -> Bool { peerIdentityCoordinator.isPeerBlocked(peerID) } // Helper method to update selectedPrivateChatPeer if fingerprint matches @MainActor func updatePrivateChatPeerIfNeeded() { peerIdentityCoordinator.updatePrivateChatPeerIfNeeded() } // MARK: - Message Sending /// Sends a message through the BitChat network. /// - Parameter content: The message content to send /// - Note: Automatically handles command processing if content starts with '/' /// Routes to private chat if one is selected, otherwise broadcasts @MainActor func sendMessage(_ content: String) { outgoingCoordinator.sendMessage(content) } // MARK: - Geohash Participants @MainActor func isSelfSender(peerID: PeerID?, displayName: String?) -> Bool { guard let peerID else { return false } if peerID == meshService.myPeerID { return true } guard peerID.isGeoDM || peerID.isGeoChat else { return false } if let mapped = nostrKeyMapping[peerID]?.lowercased(), let gh = currentGeohash, let myIdentity = try? idBridge.deriveIdentity(forGeohash: gh) { if mapped == myIdentity.publicKeyHex.lowercased() { return true } } if let gh = currentGeohash, let myIdentity = try? idBridge.deriveIdentity(forGeohash: gh) { if peerID == PeerID(nostr: myIdentity.publicKeyHex) { return true } let suffix = myIdentity.publicKeyHex.suffix(4) let expected = (nickname + "#" + suffix).lowercased() if let display = displayName?.lowercased(), display == expected { return true } } return false } // MARK: - Public helpers /// Published geohash people list for SwiftUI observation var geohashPeople: [GeoPerson] { participantTracker.visiblePeople } /// Return the current, pruned, sorted people list for the active geohash without mutating state. @MainActor func visibleGeohashPeople() -> [GeoPerson] { publicConversationCoordinator.visibleGeohashPeople() } /// CommandContextProvider conformance - returns visible geo participants func getVisibleGeoParticipants() -> [CommandGeoParticipant] { publicConversationCoordinator.getVisibleGeoParticipants() } /// Returns the current participant count for a specific geohash, using the 5-minute activity window. @MainActor func geohashParticipantCount(for geohash: String) -> Int { publicConversationCoordinator.geohashParticipantCount(for: geohash) } // MARK: - GeohashParticipantContext Protocol func displayNameForPubkey(_ pubkeyHex: String) -> String { publicConversationCoordinator.displayNameForPubkey(pubkeyHex) } func isBlocked(_ pubkeyHexLowercased: String) -> Bool { publicConversationCoordinator.isBlocked(pubkeyHexLowercased) } // Geohash block helpers @MainActor func isGeohashUserBlocked(pubkeyHexLowercased: String) -> Bool { publicConversationCoordinator.isGeohashUserBlocked(pubkeyHexLowercased: pubkeyHexLowercased) } @MainActor func blockGeohashUser(pubkeyHexLowercased: String, displayName: String) { publicConversationCoordinator.blockGeohashUser( pubkeyHexLowercased: pubkeyHexLowercased, displayName: displayName ) } @MainActor func unblockGeohashUser(pubkeyHexLowercased: String, displayName: String) { publicConversationCoordinator.unblockGeohashUser( pubkeyHexLowercased: pubkeyHexLowercased, displayName: displayName ) } func displayNameForNostrPubkey(_ pubkeyHex: String) -> String { publicConversationCoordinator.displayNameForNostrPubkey(pubkeyHex) } // MARK: - Media Transfers private enum MediaSendError: Error { case encodingFailed case tooLarge case copyFailed } func currentPublicSender() -> (name: String, peerID: PeerID) { publicConversationCoordinator.currentPublicSender() } @MainActor func nicknameForPeer(_ peerID: PeerID) -> String { peerIdentityCoordinator.nicknameForPeer(peerID) } @MainActor func removeMessage(withID messageID: String, cleanupFile: Bool = false) { publicConversationCoordinator.removeMessage(withID: messageID, cleanupFile: cleanupFile) } /// Add a local system message to a private chat (no network send) @MainActor func addLocalPrivateSystemMessage(_ content: String, to peerID: PeerID) { let systemMessage = BitchatMessage( sender: "system", content: content, timestamp: Date(), isRelay: false, originalSender: nil, isPrivate: true, recipientNickname: meshService.peerNickname(peerID: peerID), senderPeerID: meshService.myPeerID ) appendPrivateMessage(systemMessage, to: peerID) objectWillChange.send() } // MARK: - Bluetooth State Management /// Updates the Bluetooth state and shows appropriate alerts /// - Parameter state: The current Bluetooth manager state @MainActor func updateBluetoothState(_ state: CBManagerState) { bluetoothState = state let alertUpdate = ChatBluetoothAlertPolicy.update(for: state) showBluetoothAlert = alertUpdate.isPresented if let message = alertUpdate.message { bluetoothAlertMessage = message } } // MARK: - Private Chat Management /// Initiates a private chat session with a peer. /// - Parameter peerID: The peer's ID to start chatting with /// - Note: Switches the UI to private chat mode and loads message history @MainActor func startPrivateChat(with peerID: PeerID) { peerIdentityCoordinator.startPrivateChat(with: peerID) } @MainActor func endPrivateChat() { peerIdentityCoordinator.endPrivateChat() } @MainActor @objc func handlePeerStatusUpdate(_ notification: Notification) { peerIdentityCoordinator.handlePeerStatusUpdate() } @objc func handleFavoriteStatusChanged(_ notification: Notification) { peerIdentityCoordinator.handleFavoriteStatusChanged(notification) } // MARK: - App Lifecycle @MainActor func handleDidBecomeActive() { lifecycleCoordinator.handleDidBecomeActive() } @MainActor func handleScreenshotCaptured() { lifecycleCoordinator.handleScreenshotCaptured() } /// Save identity state without stopping services (for backgrounding) func saveIdentityState() { lifecycleCoordinator.saveIdentityState() } @objc func applicationWillTerminate() { lifecycleCoordinator.applicationWillTerminate() } @MainActor func markPrivateMessagesAsRead(from peerID: PeerID) { lifecycleCoordinator.markPrivateMessagesAsRead(from: peerID) } func getMessages(for peerID: PeerID?) -> [BitchatMessage] { lifecycleCoordinator.getMessages(for: peerID) } @MainActor func getPrivateChatMessages(for peerID: PeerID) -> [BitchatMessage] { lifecycleCoordinator.getPrivateChatMessages(for: peerID) } @MainActor func getPeerIDForNickname(_ nickname: String) -> PeerID? { peerIdentityCoordinator.getPeerIDForNickname(nickname) } // MARK: - Emergency Functions // PANIC: Emergency data clearing for activist safety @MainActor func panicClearAllData() { // Messages are processed immediately - nothing to flush // Clear all messages (public timelines and private chats live in the // single-writer ConversationStore; the derived `messages` view and // the legacy mirror empty with it) conversations.clearAll() pendingGeohashSystemMessages.removeAll() // Delete all keychain data (including Noise and Nostr keys) _ = keychain.deleteAllKeychainData() // Clear UserDefaults identity data userDefaults.removeObject(forKey: "bitchat.noiseIdentityKey") userDefaults.removeObject(forKey: "bitchat.messageRetentionKey") // Wipe persisted location state (selected channel, teleport set, // bookmarks). For an activist-safety wipe, where the user has been is // exactly the data an adversary inspecting the device wants. LocationStateManager.shared.panicWipe() // Reset nickname to anonymous nickname = "anon\(Int.random(in: 1000...9999))" saveNickname() // Clear favorites and peer mappings // Clear through SecureIdentityStateManager instead of directly identityManager.clearAllIdentityData() peerIdentityStore.clearAll() locationPresenceStore.reset() // Clear persistent favorites from keychain FavoritesPersistenceService.shared.clearAllFavorites() // Identity manager has cleared persisted identity data above // Clear autocomplete state autocompleteSuggestions.removeAll() showAutocomplete = false autocompleteRange = nil selectedAutocompleteIndex = 0 // Clear selected private chat selectedPrivateChatPeer = nil // Clear live location/geohash session state. Persisted location state // was wiped above, but the running view model can still be scoped to a // geohash channel and hold subscriptions tied to the old Nostr identity. activeChannel = .mesh setGeoChatSubscriptionID(nil) setGeoDmSubscriptionID(nil) _ = clearGeoSamplingSubs() cachedGeohashIdentity = nil nostrKeyMapping.removeAll() // Clear read receipt tracking sentReadReceipts.removeAll() deduplicationService.clearAll() // IMPORTANT: Clear Nostr-related state // Drop relay subscriptions, handlers, pending sends, and replay state. // Geohash DM handlers can capture pre-wipe Nostr identities, so a plain // disconnect is not enough here. NostrRelayManager.shared.resetForPanicWipe() nostrRelayManager = nil // Clear Nostr identity associations idBridge.clearAllAssociations() // Disconnect from all peers and clear persistent identity // This will force creation of a new identity (new fingerprint) on next launch meshService.emergencyDisconnectAll() if let bleService = meshService as? BLEService { bleService.resetIdentityForPanic(currentNickname: nickname) } // No need to force UserDefaults synchronization // Reinitialize Nostr with new identity // This will generate new Nostr keys derived from new Noise keys. // Skipped under tests: connecting the shared relay singleton starts // real network/reconnect work that never completes and would keep the // test process alive (the singleton, unlike a discardable instance, is // never deallocated to cancel it). if !TestEnvironment.isRunningTests { Task { @MainActor in // Small delay to ensure cleanup completes try? await Task.sleep(nanoseconds: TransportConfig.uiAsyncShortSleepNs) // 0.1 seconds // Reinitialize Nostr relay manager with new identity. Reuse the // shared singleton — every other component (NostrTransport, geohash // subscriptions, AppRuntime observers) is bound to `.shared`, so // creating a fresh instance here would split relay state and leave // sends running against a disconnected manager. nostrRelayManager = NostrRelayManager.shared setupNostrMessageHandling() nostrRelayManager?.connect() } } // Delete ALL media files (incoming and outgoing) in background Task.detached(priority: .utility) { do { let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true) let filesDir = base.appendingPathComponent("files", isDirectory: true) // Delete the entire files directory and recreate it if FileManager.default.fileExists(atPath: filesDir.path) { try FileManager.default.removeItem(at: filesDir) SecureLogger.info("🗑️ Deleted all media files during panic clear", category: .session) } // Recreate empty directory structure try FileManager.default.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: nil) try FileManager.default.createDirectory(at: filesDir.appendingPathComponent("voicenotes/incoming", isDirectory: true), withIntermediateDirectories: true, attributes: nil) try FileManager.default.createDirectory(at: filesDir.appendingPathComponent("voicenotes/outgoing", isDirectory: true), withIntermediateDirectories: true, attributes: nil) try FileManager.default.createDirectory(at: filesDir.appendingPathComponent("images/incoming", isDirectory: true), withIntermediateDirectories: true, attributes: nil) try FileManager.default.createDirectory(at: filesDir.appendingPathComponent("images/outgoing", isDirectory: true), withIntermediateDirectories: true, attributes: nil) try FileManager.default.createDirectory(at: filesDir.appendingPathComponent("files/incoming", isDirectory: true), withIntermediateDirectories: true, attributes: nil) try FileManager.default.createDirectory(at: filesDir.appendingPathComponent("files/outgoing", isDirectory: true), withIntermediateDirectories: true, attributes: nil) } catch { SecureLogger.error("Failed to clear media files during panic: \(error)", category: .session) } // BCH-01-013: Clear iOS app switcher snapshots // These are stored in Library/Caches/Snapshots// #if os(iOS) Self.clearAppSwitcherSnapshots() #endif } // Force immediate UI update for panic mode // UI updates immediately - no flushing needed } /// BCH-01-013: Clear iOS app switcher snapshots during panic mode /// iOS stores preview screenshots in Library/Caches/Snapshots// /// These could reveal sensitive information visible in the app at the time #if os(iOS) private nonisolated static func clearAppSwitcherSnapshots() { do { let cacheDir = try FileManager.default.url(for: .cachesDirectory, in: .userDomainMask, appropriateFor: nil, create: false) let snapshotsDir = cacheDir.appendingPathComponent("Snapshots", isDirectory: true) // Clear all snapshots (iOS stores them in subdirectories by bundle ID and scene) if FileManager.default.fileExists(atPath: snapshotsDir.path) { let contents = try FileManager.default.contentsOfDirectory(at: snapshotsDir, includingPropertiesForKeys: nil) for item in contents { try FileManager.default.removeItem(at: item) } SecureLogger.info("🗑️ Cleared app switcher snapshots during panic clear", category: .session) } } catch { SecureLogger.error("Failed to clear app switcher snapshots: \(error)", category: .session) } } #endif // MARK: - Autocomplete func updateAutocomplete(for text: String, cursorPosition: Int) { composerCoordinator.updateAutocomplete(for: text, cursorPosition: cursorPosition) } func completeNickname(_ nickname: String, in text: inout String) -> Int { composerCoordinator.completeNickname(nickname, in: &text) } // MARK: - Message Formatting @MainActor func formatMessageAsText(_ message: BitchatMessage, colorScheme: ColorScheme, theme: AppTheme? = nil) -> AttributedString { messageFormatter.formatMessageAsText(message, colorScheme: colorScheme, theme: theme ?? currentTheme) } @MainActor func formatMessageHeader(_ message: BitchatMessage, colorScheme: ColorScheme, theme: AppTheme? = nil) -> AttributedString { messageFormatter.formatMessageHeader(message, colorScheme: colorScheme, theme: theme ?? currentTheme) } // MARK: - Noise Protocol Support @MainActor func updateEncryptionStatusForPeers() { peerIdentityCoordinator.updateEncryptionStatusForPeers() } @MainActor func updateEncryptionStatus(for peerID: PeerID) { peerIdentityCoordinator.updateEncryptionStatus(for: peerID) } @MainActor func getEncryptionStatus(for peerID: PeerID) -> EncryptionStatus { peerIdentityCoordinator.getEncryptionStatus(for: peerID) } // Clear caches when data changes @MainActor func invalidateEncryptionCache(for peerID: PeerID? = nil) { peerIdentityCoordinator.invalidateEncryptionCache(for: peerID) } // MARK: - Message Handling /// Invalidates the derived `messages` cache and notifies observers. /// (Formerly pulled the channel's timeline into a stored `messages` /// array; `messages` is now derived from the `ConversationStore`, so /// only the invalidation remains. The `channel` parameter is kept for /// call-site compatibility — every caller passes the active channel.) @MainActor func refreshVisibleMessages(from channel: ChannelID? = nil) { visibleMessagesCache = nil objectWillChange.send() } /// `true` when a store change touches the active public conversation /// (so the derived `messages` cache must be invalidated). @MainActor private func changeAffectsActivePublicConversation(_ change: ConversationChange) -> Bool { let activeID = ConversationID(channelID: activeChannel) switch change { case .appended(let id, _), .updated(let id, _), .statusChanged(let id, _, _), .messageRemoved(let id, _), .cleared(let id), .removed(let id), .unreadChanged(let id, _): return id == activeID case .migrated(let source, let destination): return source == activeID || destination == activeID } } @MainActor private func peerColor(for message: BitchatMessage, isDark: Bool) -> Color { messageFormatter.senderColor(for: message, isDark: isDark) } // MARK: - MessageFormattingContext Protocol @MainActor func isSelfMessage(_ message: BitchatMessage) -> Bool { messageFormatter.isSelfMessage(message) } @MainActor func senderColor(for message: BitchatMessage, isDark: Bool) -> Color { peerColor(for: message, isDark: isDark) } @MainActor func peerURL(for peerID: PeerID) -> URL? { messageFormatter.peerURL(for: peerID) } // Public helpers for views to color peers consistently in lists @MainActor func colorForNostrPubkey(_ pubkeyHexLowercased: String, isDark: Bool) -> Color { messageFormatter.colorForNostrPubkey(pubkeyHexLowercased, isDark: isDark) } @MainActor func colorForMeshPeer(id peerID: PeerID, isDark: Bool) -> Color { messageFormatter.colorForMeshPeer(id: peerID, isDark: isDark) } // Clear the current public channel's timeline (visible + persistent buffer) @MainActor func clearCurrentPublicTimeline() { publicConversationCoordinator.clearCurrentPublicTimeline() } // MARK: - Peer Lookup Helpers func getPeer(byID peerID: PeerID) -> BitchatPeer? { return peerIndex[peerID] } @MainActor func getFingerprint(for peerID: PeerID) -> String? { peerIdentityCoordinator.getFingerprint(for: peerID) } /// Helper to resolve nickname for a peer ID through various sources @MainActor func resolveNickname(for peerID: PeerID) -> String { peerIdentityCoordinator.resolveNickname(for: peerID) } @MainActor func getMyFingerprint() -> String { peerIdentityCoordinator.getMyFingerprint() } @MainActor func verifyFingerprint(for peerID: PeerID) { verificationCoordinator.verifyFingerprint(for: peerID) } @MainActor func unverifyFingerprint(for peerID: PeerID) { verificationCoordinator.unverifyFingerprint(for: peerID) } @MainActor func loadVerifiedFingerprints() { verificationCoordinator.loadVerifiedFingerprints() } func setupNoiseCallbacks() { verificationCoordinator.setupNoiseCallbacks() } // MARK: - BitchatDelegate Methods // MARK: - Command Handling /// Processes IRC-style commands starting with '/'. /// - Parameter command: The full command string including the leading slash /// - Note: Supports commands like /nick, /msg, /who, /slap, /clear, /help @MainActor func handleCommand(_ command: String) { let result = commandProcessor.process(command) switch result { case .success(let message): if let msg = message { addSystemMessage(msg) } case .error(let message): addSystemMessage(message) case .handled: // Command was handled, no message needed break } } // MARK: - Message Reception @MainActor func didReceiveTransportEvent(_ event: TransportEvent) { receiveTransportEvent(event) } func didReceiveMessage(_ message: BitchatMessage) { transportEventCoordinator.didReceiveMessage(message) } // Low-level BLE events func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) { transportEventCoordinator.didReceiveNoisePayload( from: peerID, type: type, payload: payload, timestamp: timestamp ) } func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) { transportEventCoordinator.didReceivePublicMessage( from: peerID, nickname: nickname, content: content, timestamp: timestamp, messageID: messageID ) } // MARK: - QR Verification API @MainActor func beginQRVerification(with qr: VerificationService.VerificationQR) -> Bool { verificationCoordinator.beginQRVerification(with: qr) } // Mention parsing moved from BLE – use the existing non-optional helper below // MARK: - Bluetooth State Monitoring func didUpdateBluetoothState(_ state: CBManagerState) { Task { @MainActor in updateBluetoothState(state) } } // MARK: - Peer Connection Events func didConnectToPeer(_ peerID: PeerID) { transportEventCoordinator.didConnectToPeer(peerID) } func didDisconnectFromPeer(_ peerID: PeerID) { transportEventCoordinator.didDisconnectFromPeer(peerID) } func didUpdatePeerList(_ peers: [PeerID]) { peerListCoordinator.didUpdatePeerList(peers) } @MainActor func cleanupOldReadReceipts() { deliveryCoordinator.cleanupOldReadReceipts() auditConversationStore() } /// Periodic on-device verification of the `ConversationStore`'s /// correctness invariants, piggybacked on the read-receipt cleanup /// cadence (peer-list updates) so no extra timer exists. Loud on /// violation (one error line each), near-silent when healthy (sampled /// heartbeat: first + every Nth audit). The audit is O(total messages) /// and allocation-free while healthy — measured ~0.5 ms at 5k messages /// (see `PerformanceBaselineTests.testConversationStoreAudit`), cheap /// relative to its cadence, so it always runs. @MainActor private func auditConversationStore() { storeAuditCount += 1 let violations = conversations.auditInvariants() guard violations.isEmpty else { for violation in violations { SecureLogger.error("🚨 ConversationStore invariant violated: \(violation)", category: .session) } return } let appendCount = conversations.appendCount if storeAuditCount == 1 || storeAuditCount.isMultiple(of: TransportConfig.conversationStoreAuditLogInterval) { SecureLogger.debug( "Store audit OK: \(conversations.conversationsByID.count) conversations, \(conversations.totalMessageCount) messages, map=\(conversations.messageIDMapCount), appends since last audit=\(appendCount - storeAuditLastAppendCount)", category: .session ) } storeAuditLastAppendCount = appendCount } func parseMentions(from content: String) -> [String] { composerCoordinator.parseMentions(from: content) } func isFavorite(fingerprint: String) -> Bool { return identityManager.isFavorite(fingerprint: fingerprint) } // MARK: - Delivery Tracking func didReceiveReadReceipt(_ receipt: ReadReceipt) { performDeliveryUpdate { coordinator in coordinator.didReceiveReadReceipt(receipt) } } func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) { performDeliveryUpdate { coordinator in coordinator.didUpdateMessageDeliveryStatus(messageID, status: status) } } func updateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) { performDeliveryUpdate { coordinator in coordinator.updateMessageDeliveryStatus(messageID, status: status) } } // MARK: - Helper for System Messages func addSystemMessage(_ content: String, timestamp: Date = Date()) { publicConversationCoordinator.addSystemMessage(content, timestamp: timestamp) } /// Add a system message to the mesh timeline only (never geohash). /// If mesh is currently active, also append to the visible `messages`. @MainActor func addMeshOnlySystemMessage(_ content: String) { publicConversationCoordinator.addMeshOnlySystemMessage(content) } /// Public helper to add a system message to the public chat timeline. /// Also persists the message into the active channel's backing store so it survives timeline rebinds. @MainActor func addPublicSystemMessage(_ content: String) { publicConversationCoordinator.addPublicSystemMessage(content) } /// Add a system message only if viewing a geohash location channel (never post to mesh). @MainActor func addGeohashOnlySystemMessage(_ content: String) { publicConversationCoordinator.addGeohashOnlySystemMessage(content) } // Send a public message without adding a local user echo. // Used for emotes where we want a local system-style confirmation instead. @MainActor func sendPublicRaw(_ content: String) { publicConversationCoordinator.sendPublicRaw(content) } /// Handle incoming public message @MainActor func handlePublicMessage(_ message: BitchatMessage) { publicConversationCoordinator.handlePublicMessage(message) } /// Check for mentions and send notifications func checkForMentions(_ message: BitchatMessage) { publicConversationCoordinator.checkForMentions(message) } /// Send haptic feedback for special messages (iOS only) func sendHapticFeedback(for message: BitchatMessage) { publicConversationCoordinator.sendHapticFeedback(for: message) } } // End of ChatViewModel class