mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 00:45:21 +00:00
* Friend-courier store-and-forward: mutual favorites carry sealed messages to offline peers When a private message has no reachable transport, the router now seals it to the recipient's Noise static key (new one-way Noise X pattern) and hands the envelope to up to three connected mutual favorites. Couriers store the opaque ciphertext under strict quotas (20 total, 5 per depositor, 16 KiB, 24 h) and hand it over when the recipient's announce matches a rotating HMAC recipient tag; the recipient opens it and the message flows through the normal private-message pipeline, so dedup and delivery acks just work. - CourierEnvelope TLV + courierEnvelope (0x04) message type in BitFoundation - Noise X one-way pattern reusing the existing handshake machinery, domain-separated by a courier prologue; sender identity authenticated via the ss DH (no forward secrecy - documented tradeoff) - CourierStore with eviction, file persistence, and panic-wipe integration - Rotating recipient tags (HMAC over epoch day) so carried envelopes don't correlate for observers who don't already know the recipient's key - New "carried" delivery status with figure.walk glyph; header indicator while carrying mail for others - Three-node end-to-end test ferrying packets through real BLEService instances, plus codec/crypto/store/router suites (986 tests green) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Fix courier handoff verification and directed sends * Authenticate courier deposits by ingress peer * Gate courier handover on direct announces and isolate store test Envelopes are removed from the courier store optimistically, so releasing them on a relayed (multi-hop) announce risks losing carried mail to a speculative flood that never reaches the recipient. Handover now also requires the announce to have arrived directly (full TTL), i.e. an actual encounter with a live link; regression test builds a relayed copy of a genuinely signed announce (TTL is excluded from announce signatures). Also make CourierStore's on-disk location injectable so the persistence test round-trips through a temp directory instead of wiping the real Application Support store, and reattach BLEAnnounceHandler's doc comment to the class it describes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Use Xcode-bundled Swift in CI instead of a standalone toolchain The unpinned setup-swift action installs Swift 6.1, which refuses the SDK on runner images that have rolled to Xcode 26.5 ("this SDK is not supported by the compiler"). Jobs passed or failed depending on which image they landed on. The Xcode-bundled toolchain always matches the image's SDK, and matches local development. Cache keys now include the toolchain version so artifacts from one compiler are never restored into builds with another. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Drop couriered mail from blocked senders at envelope open The UI-layer block check (isPeerBlocked in the transport event coordinator) resolves a fingerprint from the live session or peer list, but a couriered message arrives precisely when its sender is absent — no session, no registry entry — so the check failed open and a blocked identity's mail was delivered anyway. Gate in openCourierEnvelope, where the sealed sender's full static key is in hand. End-to-end test ferries a full deposit→carry→handover round and verifies the envelope from a blocked sender never reaches the delegate (confirmed failing without the gate). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Fix favorites end-to-end: peer-list dedup, Nostr sync, /fav key corruption - UnifiedPeerService: dedup offline favorites against mesh peers by noise key. Phase 2 compared a 64-hex noise-key PeerID against 16-hex mesh IDs (never equal), leaving only a nickname+isConnected heuristic — a mutual favorite that was reachable-but-not-connected or renamed rendered twice, and a same-nick stranger could suppress a favorite entirely. - Nostr inbound: intercept [FAVORITED]/[UNFAVORITED] markers in the live PM handler so they update theyFavoritedUs instead of rendering as chat text; mutual favorites can now form over Nostr. Delete the dead favorite-aware PM variant and ChatNostrCoordinator.handleFavoriteNotification (unwired, parsed a stale FAVORITE:TRUE|… format no sender emits). - NostrTransport.isPeerReachable: match short form regardless of incoming ID width — toggling an offline favorite (addressed by 64-hex noise key) was silently dropped with no reachable transport. - BLEService.sendPrivateMessage: normalize recipient to the short ID like sendFilePrivate, so a 64-hex target hits the existing Noise session instead of initiating a handshake with a 32-byte wire recipient ID. - /fav, /unfav: stop writing Data(hexString: peerID.id) — the 8-byte routing ID for mesh peers — into the favorites store as a "noise key", and stop double-sending the favorite notification; delegate to toggleFavorite with a proper state check. - FavoritesPersistenceService.updatePeerFavoritedUs: keep the stored nickname when the caller passes the "Unknown" placeholder. - Bump marketing version to 1.5.4. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Route DMs to mutual favorites via Nostr when a mesh-keyed peer goes offline Field-tested on device: with a DM window opened while the peer was on mesh (conversation keyed by the short 16-hex ID), walking out of range and sending failed instantly with "peer not reachable" even though the header showed the peer as Nostr-reachable (mutual favorite, npub known). sendPrivateMessage derived the favorites key as Data(hexString: peerID.id) — for a short mesh ID that is the 8-byte routing ID, never the noise key — so the mutual-favorite/Nostr-key checks always came up empty and the send failed before reaching MessageRouter. Conversations keyed by the full 64-hex noise-key ID (opened from the offline favorite row) were unaffected, which is why later tests appeared to work. Resolve the noise key properly (peerID.noiseKey, then the unified peer row, then the favorites store by derived short ID) and add a regression test for the mesh-keyed-peer-goes-offline case. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Label Nostr DMs from favorites with their stored nickname Field-tested: a DM delivered over the Nostr fallback rendered as "anon#678e" instead of the sender's name. The inbound handler named the sender via displayNameForNostrPubkey, which only knows geohash-scoped names — even though the pipeline had already resolved the sender's noise key (the conversation is keyed by it). When the conversation key carries a noise key, prefer the favorite's stored nickname; geohash DMs (nostr_ keys) keep the anon geo name. This also stops an inbound Nostr [FAVORITED] from overwriting the stored nickname with the anon fallback, since the same name feeds updatePeerFavoritedUs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Fix courier path for offline favorites addressed by noise-key IDs Two Codex review findings, both the same ID-width confusion this PR targets, in the courier flow: - CourierDirectory.favoritesBacked resolved recipients only via getFavoriteStatus(forPeerID:), which requires a short 16-hex ID — offline favorites are addressed by the full 64-hex noise-key ID, so attemptCourierDeposit silently bailed for exactly the peers couriers exist to serve. The 64-hex ID now yields its own key directly. - openCourierEnvelope emitted the derived short mesh ID even when the sender has no live mesh identity, landing couriered mail in an unresolvable short-ID thread labeled "Unknown". Absent senders now emit the full noise-key ID so the message joins the stable favorite conversation; present senders keep the live short-ID thread. 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>
1684 lines
66 KiB
Swift
1684 lines
66 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
|
||
)
|
||
}
|
||
|
||
// Mesh (Noise identity) block helpers. Unlike the `/block <nickname>`
|
||
// command, these resolve and persist the block by the peer's stable
|
||
// fingerprint (derived from `peerID`), so the exact tapped peer is
|
||
// (un)blocked — unambiguous across nickname collisions and functional for
|
||
// offline peers that can no longer be resolved through the mesh service.
|
||
@MainActor
|
||
func blockMeshPeer(peerID: PeerID, displayName: String) {
|
||
setMeshPeerBlocked(peerID, blocked: true, displayName: displayName)
|
||
}
|
||
|
||
@MainActor
|
||
func unblockMeshPeer(peerID: PeerID, displayName: String) {
|
||
setMeshPeerBlocked(peerID, blocked: false, displayName: displayName)
|
||
}
|
||
|
||
@MainActor
|
||
private func setMeshPeerBlocked(_ peerID: PeerID, blocked: Bool, displayName: String) {
|
||
guard unifiedPeerService.setBlocked(peerID, blocked: blocked) != nil else {
|
||
addCommandOutput(
|
||
String(
|
||
format: String(
|
||
localized: blocked ? "system.mesh.block_failed" : "system.mesh.unblock_failed",
|
||
comment: "System message shown when a mesh peer cannot be blocked or unblocked"
|
||
),
|
||
locale: .current,
|
||
displayName
|
||
)
|
||
)
|
||
return
|
||
}
|
||
addCommandOutput(
|
||
String(
|
||
format: String(
|
||
localized: blocked ? "system.mesh.blocked" : "system.mesh.unblocked",
|
||
comment: "System message shown when a mesh peer is blocked or unblocked"
|
||
),
|
||
locale: .current,
|
||
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()
|
||
|
||
// Drop courier mail carried for third parties (memory and disk)
|
||
CourierStore.shared.wipe()
|
||
|
||
// 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 /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 {
|
||
addCommandOutput(msg)
|
||
}
|
||
case .error(let message):
|
||
addCommandOutput(message)
|
||
case .handled:
|
||
// Command was handled, no message needed
|
||
break
|
||
}
|
||
}
|
||
|
||
/// Command output belongs in the conversation where the user typed the
|
||
/// command; the public timeline is invisible while a DM is open. The DM
|
||
/// selection is read *after* processing so commands that switch chats
|
||
/// (`/msg`) print into the conversation they just opened.
|
||
@MainActor
|
||
private func addCommandOutput(_ content: String) {
|
||
if let peerID = selectedPrivateChatPeer {
|
||
addLocalPrivateSystemMessage(content, to: peerID)
|
||
} else {
|
||
addSystemMessage(content)
|
||
}
|
||
}
|
||
|
||
// 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
|