mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 22:45:19 +00:00
* Centralize UI theme colors into semantic ThemePalette tokens Introduce AppTheme/ThemePalette (Utils/Theme.swift) with an environment key and @ThemedPalette property wrapper, persisted via @AppStorage. Views now resolve background/primary/secondary/accentBlue/alertRed/ divider tokens from the environment instead of computing colors inline, removing the backgroundColor/textColor/secondaryTextColor prop-drilling through the header, composer, and people-sheet hierarchy. Matrix theme output is pixel-identical; this is groundwork for a user-selectable theme switcher. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Add liquid glass theme with in-app appearance switcher Add a .liquidGlass AppTheme alongside the matrix terminal theme, selectable from a one-line APPEARANCE row in the app info sheet (persisted via @AppStorage, applies live). Liquid glass renders system fonts and colors over a subtle gradient backdrop, with the header and composer floating as Liquid Glass panels (real .glassEffect() on iOS/macOS 26, compiler-gated with an ultraThinMaterial fallback for older SDKs). The message list scrolls underneath the chrome via safe-area insets, and all sheets share the same backdrop and surface language. The matrix theme is unchanged. Details: - Theme is threaded through ChatMessageFormatter so message AttributedStrings switch font design per theme; the per-message format cache gains a variant key so themes never serve each other's cached strings - New palette tokens: accent (interactive tint) and locationAccent (geohash green), replacing scattered hardcoded greens/blues in the voice note, waveform, verification, and people-sheet views - Header controls get full-height tap targets and the people count becomes a real Button (previously a tap gesture on the cluster) - CommandSuggestionsView renders nothing when empty instead of a zero-height view that pushed the composer input off-center - New appearance strings localized for all 29 catalog languages Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: jack <jackjackbits@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1599 lines
62 KiB
Swift
1599 lines
62 KiB
Swift
//
|
||
// 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")
|
||
|
||
// 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 read receipt tracking
|
||
sentReadReceipts.removeAll()
|
||
deduplicationService.clearAll()
|
||
|
||
// IMPORTANT: Clear Nostr-related state
|
||
// Disconnect from Nostr relays and clear subscriptions
|
||
nostrRelayManager?.disconnect()
|
||
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
|
||
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
|
||
nostrRelayManager = NostrRelayManager()
|
||
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
|