Files
bitchat/bitchat/ViewModels/ChatViewModel.swift
T

1626 lines
64 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// ChatViewModel.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
///
/// # 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 <name>`: Change nickname
/// - `/msg <user> <message>`: Send private message
/// - `/who`: List connected peers
/// - `/slap <user>`: 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<PeerID> { 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<PeerID> {
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<String> { unifiedPeerService.favoritePeers }
@MainActor
var blockedUsers: Set<String> { unifiedPeerService.blockedUsers }
// MARK: - Encryption and Security
// Noise Protocol encryption status
var peerEncryptionStatus: [PeerID: EncryptionStatus] {
get { peerIdentityStore.encryptionStatuses }
set { peerIdentityStore.replaceEncryptionStatuses(newValue) }
}
var verifiedFingerprints: Set<String> {
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<String> {
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<AnyCancellable>()
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<String> = [] { // 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<String> = []
// 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<String>) -> 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/<bundle_id>/
#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/<bundle_id>/
/// 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