mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-08-22 07:16:03 +00:00
Add code to clear iOS app switcher snapshot cache during panic mode. iOS automatically captures screenshots for the app switcher, which could reveal sensitive message content visible at the time. Changes: - Add clearAppSwitcherSnapshots() helper function (iOS only) - Call it from panicClearAllData() during file cleanup phase - Clears all files in Library/Caches/Snapshots/ directory Security: Sensitive information visible in the app when it entered background will no longer be recoverable from snapshot cache after panic reset. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
3896 lines
167 KiB
Swift
3896 lines
167 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 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, 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
|
||
}
|
||
}
|
||
|
||
private var publicRateLimiter = MessageRateLimiter(
|
||
senderCapacity: TransportConfig.uiSenderRateBucketCapacity,
|
||
senderRefillPerSec: TransportConfig.uiSenderRateBucketRefillPerSec,
|
||
contentCapacity: TransportConfig.uiContentRateBucketCapacity,
|
||
contentRefillPerSec: TransportConfig.uiContentRateBucketRefillPerSec
|
||
)
|
||
|
||
@MainActor
|
||
private func normalizedSenderKey(for message: BitchatMessage) -> String {
|
||
if let spid = message.senderPeerID {
|
||
if spid.isGeoChat || spid.isGeoDM {
|
||
let full = (nostrKeyMapping[spid] ?? spid.bare).lowercased()
|
||
return "nostr:" + full
|
||
} else if spid.id.count == 16, let full = getNoiseKeyForShortID(spid)?.id.lowercased() {
|
||
return "noise:" + full
|
||
} else {
|
||
return "mesh:" + spid.id.lowercased()
|
||
}
|
||
}
|
||
return "name:" + message.sender.lowercased()
|
||
}
|
||
|
||
// MARK: - Published Properties
|
||
|
||
@Published var messages: [BitchatMessage] = []
|
||
@Published var currentColorScheme: ColorScheme = .light
|
||
private let maxMessages = TransportConfig.meshTimelineCap // Maximum messages before oldest are removed
|
||
@Published var isConnected = false
|
||
private var recentlySeenPeers: Set<PeerID> = []
|
||
private var lastNetworkNotificationTime = Date.distantPast
|
||
private var networkResetTimer: Timer? = nil
|
||
private var networkEmptyTimer: Timer? = nil
|
||
private let networkResetGraceSeconds: TimeInterval = TransportConfig.networkResetGraceSeconds // avoid refiring on short drops/reconnects
|
||
@Published var nickname: String = "" {
|
||
didSet {
|
||
// Trim whitespace whenever nickname is set
|
||
let trimmed = nickname.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
if trimmed != nickname {
|
||
nickname = trimmed
|
||
}
|
||
// 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
|
||
|
||
// Computed properties for compatibility
|
||
@MainActor
|
||
var connectedPeers: Set<PeerID> { unifiedPeerService.connectedPeerIDs }
|
||
@Published var allPeers: [BitchatPeer] = []
|
||
var privateChats: [PeerID: [BitchatMessage]] {
|
||
get { privateChatManager.privateChats }
|
||
set { privateChatManager.privateChats = newValue }
|
||
}
|
||
var selectedPrivateChatPeer: PeerID? {
|
||
get { privateChatManager.selectedPeer }
|
||
set {
|
||
if let peerID = newValue {
|
||
privateChatManager.startChat(with: peerID)
|
||
} else {
|
||
privateChatManager.endChat()
|
||
}
|
||
}
|
||
}
|
||
var unreadPrivateMessages: Set<PeerID> {
|
||
get { privateChatManager.unreadMessages }
|
||
set { privateChatManager.unreadMessages = newValue }
|
||
}
|
||
|
||
/// Check if there are any unread messages (including from temporary Nostr peer IDs)
|
||
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() {
|
||
// Pick most recent unread by last message timestamp
|
||
let unreadSorted = unreadPrivateMessages
|
||
.map { ($0, privateChats[$0]?.last?.timestamp ?? Date.distantPast) }
|
||
.sorted { $0.1 > $1.1 }
|
||
if let target = unreadSorted.first?.0 {
|
||
startPrivateChat(with: target)
|
||
return
|
||
}
|
||
// Otherwise pick most recent private chat overall
|
||
let recent = privateChats
|
||
.map { (id: $0.key, ts: $0.value.last?.timestamp ?? Date.distantPast) }
|
||
.sorted { $0.ts > $1.ts }
|
||
if let target = recent.first?.id {
|
||
startPrivateChat(with: target)
|
||
}
|
||
}
|
||
|
||
//
|
||
var peerIDToPublicKeyFingerprint: [PeerID: String] = [:]
|
||
private var selectedPrivateChatFingerprint: String? = nil
|
||
// Map stable short peer IDs (16-hex) to full Noise public key hex (64-hex) for session continuity
|
||
private var shortIDToNoiseKey: [PeerID: PeerID] = [:]
|
||
|
||
// 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 = shortIDToNoiseKey[shortPeerID] { return mapped }
|
||
// Fallback: derive from active Noise session if available
|
||
if shortPeerID.id.count == 16,
|
||
let key = meshService.getNoiseService().getPeerPublicKeyData(shortPeerID) {
|
||
let stable = PeerID(hexData: key)
|
||
shortIDToNoiseKey[shortPeerID] = stable
|
||
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 pair = shortIDToNoiseKey.first(where: { $0.value == fullNoiseKeyHex }) {
|
||
return pair.key
|
||
}
|
||
return fullNoiseKeyHex
|
||
}
|
||
private 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
|
||
|
||
// Temporary property to fix compilation
|
||
@Published var showPasswordPrompt = false
|
||
|
||
// MARK: - Services and Storage
|
||
|
||
let meshService: Transport
|
||
let idBridge: NostrIdentityBridge
|
||
let identityManager: SecureIdentityStateManagerProtocol
|
||
|
||
var nostrRelayManager: NostrRelayManager?
|
||
private let userDefaults = UserDefaults.standard
|
||
let keychain: KeychainManagerProtocol
|
||
private let nicknameKey = "bitchat.nickname"
|
||
// Location channel state (macOS supports manual geohash selection)
|
||
@Published var activeChannel: ChannelID = .mesh
|
||
var geoSubscriptionID: String? = nil
|
||
var geoDmSubscriptionID: String? = nil
|
||
var currentGeohash: String? = nil
|
||
var cachedGeohashIdentity: (geohash: String, identity: NostrIdentity)? = nil // Cache current geohash identity
|
||
var geoNicknames: [String: String] = [:] // pubkeyHex(lowercased) -> nickname
|
||
// Show Tor status once per app launch
|
||
var torStatusAnnounced = false
|
||
private var torProgressCancellable: AnyCancellable?
|
||
private var lastTorProgressAnnounced = -1
|
||
// 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
|
||
|
||
// Caches for expensive computations
|
||
private var encryptionStatusCache: [PeerID: EncryptionStatus] = [:]
|
||
|
||
// 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
|
||
@Published var peerEncryptionStatus: [PeerID: EncryptionStatus] = [:]
|
||
@Published var verifiedFingerprints: Set<String> = [] // Set of verified fingerprints
|
||
@Published var showingFingerprintFor: PeerID? = nil // Currently showing fingerprint sheet for peer
|
||
|
||
// Bluetooth state management
|
||
@Published var showBluetoothAlert = false
|
||
@Published var bluetoothAlertMessage = ""
|
||
@Published var bluetoothState: CBManagerState = .unknown
|
||
|
||
// Presentation state for privacy gating
|
||
@Published var isLocationChannelsSheetPresented: Bool = false
|
||
@Published var isAppInfoPresented: Bool = false
|
||
@Published var showScreenshotPrivacyWarning: Bool = false
|
||
|
||
var timelineStore = PublicTimelineStore(
|
||
meshCap: TransportConfig.meshTimelineCap,
|
||
geohashCap: TransportConfig.geoTimelineCap
|
||
)
|
||
// Channel activity tracking for background nudges
|
||
var lastPublicActivityAt: [String: Date] = [:] // channelKey -> last activity time
|
||
private var lastPublicActivityNotifyAt: [String: Date] = [:]
|
||
private let channelInactivityThreshold: TimeInterval = TransportConfig.uiChannelInactivityThresholdSeconds
|
||
// Geohash participant tracker
|
||
let participantTracker = GeohashParticipantTracker(activityCutoff: -TransportConfig.uiRecentCutoffFiveMinutesSeconds)
|
||
// Participants who indicated they teleported (by tag in their events)
|
||
@Published var teleportedGeo: Set<String> = [] // lowercased pubkey hex
|
||
// Sampling subscriptions for multiple geohashes (when channel sheet is open)
|
||
var geoSamplingSubs: [String: String] = [:] // subID -> geohash
|
||
var lastGeoNotificationAt: [String: Date] = [:] // geohash -> last notify time
|
||
|
||
|
||
// MARK: - Message Delivery Tracking
|
||
|
||
// Delivery tracking
|
||
var cancellables = Set<AnyCancellable>()
|
||
var transferIdToMessageIDs: [String: [String]] = [:]
|
||
var messageIDToTransferId: [String: String] = [:]
|
||
|
||
// MARK: - QR Verification (pending state)
|
||
private struct PendingVerification {
|
||
let noiseKeyHex: String
|
||
let signKeyHex: String
|
||
let nonceA: Data
|
||
let startedAt: Date
|
||
var sent: Bool
|
||
}
|
||
private var pendingQRVerifications: [PeerID: PendingVerification] = [:]
|
||
// Last handled challenge nonce per peer to avoid duplicate responses
|
||
private var lastVerifyNonceByPeer: [PeerID: Data] = [:]
|
||
// Track when we last received a verify challenge from a peer (fingerprint-keyed)
|
||
private var lastInboundVerifyChallengeAt: [String: Date] = [:] // key: fingerprint
|
||
// Throttle mutual verification toasts per fingerprint
|
||
private var lastMutualToastAt: [String: Date] = [:] // key: fingerprint
|
||
|
||
// MARK: - Public message batching (UI perf)
|
||
let publicMessagePipeline: PublicMessagePipeline
|
||
@Published private(set) var isBatchingPublic: Bool = false
|
||
|
||
// 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 to UserDefaults whenever it changes (no manual synchronize/verify re-read)
|
||
if let data = try? JSONEncoder().encode(Array(sentReadReceipts)) {
|
||
UserDefaults.standard.set(data, forKey: "sentReadReceipts")
|
||
} else {
|
||
SecureLogger.error("❌ Failed to encode read receipts for persistence", category: .session)
|
||
}
|
||
}
|
||
}
|
||
|
||
// Throttle verification response toasts per peer to avoid spam
|
||
var lastVerifyToastAt: [String: Date] = [:]
|
||
|
||
// Track which GeoDM messages we've already sent a delivery ACK for (by messageID)
|
||
var sentGeoDeliveryAcks: Set<String> = []
|
||
|
||
// Track app startup phase to prevent marking old messages as unread
|
||
private var isStartupPhase = true
|
||
// Announce Tor initial readiness once per launch to avoid duplicates
|
||
var torInitialReadyAnnounced: Bool = false
|
||
|
||
// Track Nostr pubkey mappings for unknown senders
|
||
var nostrKeyMapping: [PeerID: String] = [:] // senderPeerID -> nostrPubkey
|
||
|
||
// MARK: - Initialization
|
||
|
||
@MainActor
|
||
convenience init(
|
||
keychain: KeychainManagerProtocol,
|
||
idBridge: NostrIdentityBridge,
|
||
identityManager: SecureIdentityStateManagerProtocol
|
||
) {
|
||
self.init(
|
||
keychain: keychain,
|
||
idBridge: idBridge,
|
||
identityManager: identityManager,
|
||
transport: BLEService(keychain: keychain, idBridge: idBridge, identityManager: identityManager)
|
||
)
|
||
}
|
||
|
||
/// 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
|
||
) {
|
||
self.keychain = keychain
|
||
self.idBridge = idBridge
|
||
self.identityManager = identityManager
|
||
self.meshService = transport
|
||
self.publicMessagePipeline = PublicMessagePipeline()
|
||
|
||
// Load persisted read receipts
|
||
if let data = UserDefaults.standard.data(forKey: "sentReadReceipts"),
|
||
let receipts = try? JSONDecoder().decode([String].self, from: data) {
|
||
self.sentReadReceipts = Set(receipts)
|
||
// Successfully loaded read receipts
|
||
} else {
|
||
// No persisted read receipts found
|
||
}
|
||
|
||
// Initialize services
|
||
self.commandProcessor = CommandProcessor(identityManager: identityManager)
|
||
self.privateChatManager = PrivateChatManager(meshService: meshService)
|
||
self.unifiedPeerService = UnifiedPeerService(meshService: meshService, idBridge: idBridge, identityManager: identityManager)
|
||
let nostrTransport = NostrTransport(keychain: keychain, idBridge: idBridge)
|
||
nostrTransport.senderPeerID = meshService.myPeerID
|
||
self.messageRouter = MessageRouter(transports: [meshService, nostrTransport])
|
||
// Route receipts from PrivateChatManager through MessageRouter
|
||
self.privateChatManager.messageRouter = self.messageRouter
|
||
// Allow PrivateChatManager to look up peer info for message consolidation
|
||
self.privateChatManager.unifiedPeerService = self.unifiedPeerService
|
||
// Allow UnifiedPeerService to route favorite notifications via mesh/Nostr
|
||
self.unifiedPeerService.messageRouter = self.messageRouter
|
||
self.autocompleteService = AutocompleteService()
|
||
self.deduplicationService = MessageDeduplicationService()
|
||
|
||
// Wire up dependencies
|
||
self.commandProcessor.chatViewModel = self
|
||
self.participantTracker.configure(context: self)
|
||
|
||
// Subscribe to privateChatManager changes to trigger UI updates
|
||
privateChatManager.objectWillChange
|
||
.sink { [weak self] _ in
|
||
self?.objectWillChange.send()
|
||
}
|
||
.store(in: &cancellables)
|
||
|
||
// Subscribe to participantTracker changes to trigger UI updates
|
||
participantTracker.objectWillChange
|
||
.sink { [weak self] _ in
|
||
self?.objectWillChange.send()
|
||
}
|
||
.store(in: &cancellables)
|
||
self.commandProcessor.meshService = meshService
|
||
|
||
loadNickname()
|
||
loadVerifiedFingerprints()
|
||
meshService.delegate = self
|
||
|
||
// Log startup info
|
||
|
||
// Log fingerprint after a delay to ensure encryption service is ready
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiStartupInitialDelaySeconds) { [weak self] in
|
||
if let self = self {
|
||
_ = self.getMyFingerprint()
|
||
}
|
||
}
|
||
|
||
// Set nickname before starting services
|
||
meshService.setNickname(nickname)
|
||
|
||
// Start mesh service immediately
|
||
meshService.startServices()
|
||
|
||
publicMessagePipeline.delegate = self
|
||
publicMessagePipeline.updateActiveChannel(activeChannel)
|
||
|
||
// Check initial Bluetooth state after a brief delay to allow centralManager initialization
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
|
||
guard let self = self else { return }
|
||
if let bleService = self.meshService as? BLEService {
|
||
let state = bleService.getCurrentBluetoothState()
|
||
self.updateBluetoothState(state)
|
||
}
|
||
}
|
||
|
||
// Announce Tor status (geohash-only; do not show in mesh chat). Only when auto-start is allowed.
|
||
if TorManager.shared.torEnforced && !torStatusAnnounced && TorManager.shared.isAutoStartAllowed() {
|
||
torStatusAnnounced = true
|
||
addGeohashOnlySystemMessage(
|
||
String(localized: "system.tor.starting", comment: "System message when Tor is starting")
|
||
)
|
||
// Suppress incremental Tor progress messages
|
||
torProgressCancellable = nil
|
||
} else if !TorManager.shared.torEnforced && !torStatusAnnounced {
|
||
torStatusAnnounced = true
|
||
addGeohashOnlySystemMessage(
|
||
String(localized: "system.tor.dev_bypass", comment: "System message when Tor bypass is enabled in development")
|
||
)
|
||
}
|
||
|
||
// Initialize Nostr relay manager regardless of Tor readiness; connection is controlled elsewhere
|
||
nostrRelayManager = NostrRelayManager.shared
|
||
// Attempt to flush any queued outbox (mesh/Nostr routing will gate appropriately)
|
||
messageRouter.flushAllOutbox()
|
||
// End startup phase after a short delay
|
||
Task { @MainActor in
|
||
try? await Task.sleep(nanoseconds: UInt64(TransportConfig.uiStartupPhaseDurationSeconds * 1_000_000_000))
|
||
self.isStartupPhase = false
|
||
}
|
||
// Bind unified peer service's peer list to our published property
|
||
let peersCancellable = unifiedPeerService.$peers
|
||
.receive(on: DispatchQueue.main)
|
||
.sink { [weak self] peers in
|
||
guard let self = self else { return }
|
||
// Update peers directly; @Published drives UI updates
|
||
self.allPeers = peers
|
||
// Update peer index for O(1) lookups
|
||
// Deduplicate peers by ID to prevent crash from duplicate keys
|
||
var uniquePeers: [PeerID: BitchatPeer] = [:]
|
||
for peer in peers {
|
||
// Keep the first occurrence of each peer ID
|
||
if uniquePeers[peer.peerID] == nil {
|
||
uniquePeers[peer.peerID] = peer
|
||
} else {
|
||
SecureLogger.warning("⚠️ Duplicate peer ID detected: \(peer.peerID) (\(peer.displayName))", category: .session)
|
||
}
|
||
}
|
||
self.peerIndex = uniquePeers
|
||
// Update private chat peer ID if needed when peers change
|
||
if self.selectedPrivateChatFingerprint != nil {
|
||
self.updatePrivateChatPeerIfNeeded()
|
||
}
|
||
}
|
||
self.cancellables.insert(peersCancellable)
|
||
|
||
// Resubscribe geohash on relay reconnect
|
||
if let relayMgr = self.nostrRelayManager {
|
||
relayMgr.$isConnected
|
||
.receive(on: DispatchQueue.main)
|
||
.sink { [weak self] connected in
|
||
guard let self = self else { return }
|
||
if connected {
|
||
Task { @MainActor in
|
||
// Set up DM handler once on first connect
|
||
if !self.nostrHandlersSetup {
|
||
self.setupNostrMessageHandling()
|
||
self.nostrHandlersSetup = true
|
||
}
|
||
self.resubscribeCurrentGeohash()
|
||
// Re-init sampling for regional + bookmarked geohashes after reconnect
|
||
self.geoChannelCoordinator?.refreshSampling()
|
||
}
|
||
}
|
||
}
|
||
.store(in: &self.cancellables)
|
||
}
|
||
|
||
// Set up Noise encryption callbacks
|
||
setupNoiseCallbacks()
|
||
|
||
TransferProgressManager.shared.publisher
|
||
.receive(on: DispatchQueue.main)
|
||
.sink { [weak self] event in
|
||
self?.handleTransferEvent(event)
|
||
}
|
||
.store(in: &cancellables)
|
||
|
||
geoChannelCoordinator = GeoChannelCoordinator(
|
||
onChannelSwitch: { [weak self] channel in
|
||
self?.switchLocationChannel(to: channel)
|
||
},
|
||
beginSampling: { [weak self] geohashes in
|
||
self?.beginGeohashSampling(for: geohashes)
|
||
},
|
||
endSampling: { [weak self] in
|
||
self?.endGeohashSampling()
|
||
}
|
||
)
|
||
|
||
// Track teleport flag changes to keep our own teleported marker in sync with regional status
|
||
LocationChannelManager.shared.$teleported
|
||
.receive(on: DispatchQueue.main)
|
||
.sink { [weak self] isTeleported in
|
||
guard let self = self else { return }
|
||
Task { @MainActor in
|
||
guard case .location(let ch) = self.activeChannel,
|
||
let id = try? idBridge.deriveIdentity(forGeohash: ch.geohash) else { return }
|
||
let key = id.publicKeyHex.lowercased()
|
||
let hasRegional = !LocationChannelManager.shared.availableChannels.isEmpty
|
||
let inRegional = LocationChannelManager.shared.availableChannels.contains { $0.geohash == ch.geohash }
|
||
if isTeleported && hasRegional && !inRegional {
|
||
self.teleportedGeo = self.teleportedGeo.union([key])
|
||
} else {
|
||
self.teleportedGeo.remove(key)
|
||
}
|
||
}
|
||
}
|
||
.store(in: &cancellables)
|
||
|
||
// Request notification permission (guards test environment internally)
|
||
NotificationService.shared.requestAuthorization()
|
||
|
||
|
||
// Listen for favorite status changes
|
||
NotificationCenter.default.addObserver(
|
||
self,
|
||
selector: #selector(handleFavoriteStatusChanged),
|
||
name: .favoriteStatusChanged,
|
||
object: nil
|
||
)
|
||
|
||
// Listen for peer status updates to refresh UI
|
||
NotificationCenter.default.addObserver(
|
||
self,
|
||
selector: #selector(handlePeerStatusUpdate),
|
||
name: Notification.Name("peerStatusUpdated"),
|
||
object: nil
|
||
)
|
||
|
||
// Listen for delivery acknowledgments
|
||
|
||
// When app becomes active, send read receipts for visible messages
|
||
#if os(macOS)
|
||
NotificationCenter.default.addObserver(
|
||
self,
|
||
selector: #selector(appDidBecomeActive),
|
||
name: NSApplication.didBecomeActiveNotification,
|
||
object: nil
|
||
)
|
||
|
||
// Add app lifecycle observers to save data
|
||
NotificationCenter.default.addObserver(
|
||
self,
|
||
selector: #selector(appWillResignActive),
|
||
name: NSApplication.willResignActiveNotification,
|
||
object: nil
|
||
)
|
||
NotificationCenter.default.addObserver(
|
||
self,
|
||
selector: #selector(applicationWillTerminate),
|
||
name: NSApplication.willTerminateNotification,
|
||
object: nil
|
||
)
|
||
// Tor lifecycle notifications: inform user when Tor restarts and when ready (macOS)
|
||
NotificationCenter.default.addObserver(
|
||
self,
|
||
selector: #selector(handleTorWillRestart),
|
||
name: .TorWillRestart,
|
||
object: nil
|
||
)
|
||
NotificationCenter.default.addObserver(
|
||
self,
|
||
selector: #selector(handleTorDidBecomeReady),
|
||
name: .TorDidBecomeReady,
|
||
object: nil
|
||
)
|
||
NotificationCenter.default.addObserver(
|
||
self,
|
||
selector: #selector(handleTorWillStart),
|
||
name: .TorWillStart,
|
||
object: nil
|
||
)
|
||
NotificationCenter.default.addObserver(
|
||
self,
|
||
selector: #selector(handleTorPreferenceChanged(_:)),
|
||
name: .TorUserPreferenceChanged,
|
||
object: nil
|
||
)
|
||
#else
|
||
NotificationCenter.default.addObserver(
|
||
self,
|
||
selector: #selector(appDidBecomeActive),
|
||
name: UIApplication.didBecomeActiveNotification,
|
||
object: nil
|
||
)
|
||
// Resubscribe geohash on app foreground
|
||
// Resubscribe handled via appDidBecomeActive selector
|
||
|
||
// Add screenshot detection for iOS
|
||
NotificationCenter.default.addObserver(
|
||
self,
|
||
selector: #selector(userDidTakeScreenshot),
|
||
name: UIApplication.userDidTakeScreenshotNotification,
|
||
object: nil
|
||
)
|
||
|
||
// Add app lifecycle observers to save data
|
||
NotificationCenter.default.addObserver(
|
||
self,
|
||
selector: #selector(appWillResignActive),
|
||
name: UIApplication.willResignActiveNotification,
|
||
object: nil
|
||
)
|
||
NotificationCenter.default.addObserver(
|
||
self,
|
||
selector: #selector(applicationWillTerminate),
|
||
name: UIApplication.willTerminateNotification,
|
||
object: nil
|
||
)
|
||
// Tor lifecycle notifications: inform user when Tor restarts and when ready
|
||
NotificationCenter.default.addObserver(
|
||
self,
|
||
selector: #selector(handleTorWillRestart),
|
||
name: .TorWillRestart,
|
||
object: nil
|
||
)
|
||
NotificationCenter.default.addObserver(
|
||
self,
|
||
selector: #selector(handleTorDidBecomeReady),
|
||
name: .TorDidBecomeReady,
|
||
object: nil
|
||
)
|
||
NotificationCenter.default.addObserver(
|
||
self,
|
||
selector: #selector(handleTorWillStart),
|
||
name: .TorWillStart,
|
||
object: nil
|
||
)
|
||
NotificationCenter.default.addObserver(
|
||
self,
|
||
selector: #selector(handleTorPreferenceChanged(_:)),
|
||
name: .TorUserPreferenceChanged,
|
||
object: nil
|
||
)
|
||
#endif
|
||
}
|
||
|
||
// MARK: - Deinitialization
|
||
|
||
deinit {
|
||
// No need to force UserDefaults synchronization
|
||
}
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
// MARK: - Nickname Management
|
||
|
||
private func loadNickname() {
|
||
if let savedNickname = userDefaults.string(forKey: nicknameKey) {
|
||
// Trim whitespace when loading
|
||
nickname = savedNickname.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
} 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() {
|
||
// Trim whitespace from nickname
|
||
let trimmed = nickname.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
|
||
// Check if nickname is empty after trimming
|
||
if trimmed.isEmpty {
|
||
nickname = "anon\(Int.random(in: 1000...9999))"
|
||
} else {
|
||
nickname = trimmed
|
||
}
|
||
saveNickname()
|
||
}
|
||
|
||
// MARK: - Favorites Management
|
||
|
||
// 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 {
|
||
// First check direct unread messages
|
||
if unreadPrivateMessages.contains(peerID) {
|
||
return true
|
||
}
|
||
|
||
// Check if messages are stored under the stable Noise key hex
|
||
if let peer = unifiedPeerService.getPeer(by: peerID) {
|
||
let noiseKeyHex = PeerID(hexData: peer.noisePublicKey)
|
||
if unreadPrivateMessages.contains(noiseKeyHex) {
|
||
return true
|
||
}
|
||
// Also check for geohash (Nostr) DM conv key if this peer has a known Nostr pubkey
|
||
if let nostrHex = peer.nostrPublicKey {
|
||
let convKey = PeerID(nostr_: nostrHex)
|
||
if unreadPrivateMessages.contains(convKey) {
|
||
return true
|
||
}
|
||
}
|
||
}
|
||
|
||
// Get the peer's nickname to check for temporary Nostr peer IDs
|
||
let peerNickname = meshService.peerNickname(peerID: peerID)?.lowercased() ?? ""
|
||
|
||
// Check if any temporary Nostr peer IDs have unread messages from this nickname
|
||
for unreadPeerID in unreadPrivateMessages {
|
||
if unreadPeerID.isGeoDM {
|
||
// Check if messages from this temporary peer match the nickname
|
||
if let messages = privateChats[unreadPeerID],
|
||
let firstMessage = messages.first,
|
||
firstMessage.sender.lowercased() == peerNickname {
|
||
return true
|
||
}
|
||
}
|
||
}
|
||
|
||
return false
|
||
}
|
||
|
||
@MainActor
|
||
func toggleFavorite(peerID: PeerID) {
|
||
// Distinguish between ephemeral peer IDs (16 hex chars) and Noise public keys (64 hex chars)
|
||
// Ephemeral peer IDs are 8 bytes = 16 hex characters
|
||
// Noise public keys are 32 bytes = 64 hex characters
|
||
|
||
if let noisePublicKey = peerID.noiseKey {
|
||
// This is a stable Noise key hex (used in private chats)
|
||
// Find the ephemeral peer ID for this Noise key
|
||
let ephemeralPeerID = unifiedPeerService.peers.first { peer in
|
||
peer.noisePublicKey == noisePublicKey
|
||
}?.peerID
|
||
|
||
if let ephemeralID = ephemeralPeerID {
|
||
// Found the ephemeral peer, use normal toggle
|
||
unifiedPeerService.toggleFavorite(ephemeralID)
|
||
// Also trigger UI update
|
||
objectWillChange.send()
|
||
} else {
|
||
// No ephemeral peer found, directly toggle via FavoritesPersistenceService
|
||
let currentStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noisePublicKey)
|
||
let wasFavorite = currentStatus?.isFavorite ?? false
|
||
|
||
if wasFavorite {
|
||
// Remove favorite
|
||
FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noisePublicKey)
|
||
} else {
|
||
// Add favorite - get nickname from current status or from private chat messages
|
||
var nickname = currentStatus?.peerNickname
|
||
|
||
// If no nickname in status, try to get from private chat messages
|
||
if nickname == nil, let messages = privateChats[peerID], !messages.isEmpty {
|
||
// Get the nickname from the first message where this peer was the sender
|
||
nickname = messages.first { $0.senderPeerID == peerID }?.sender
|
||
}
|
||
|
||
let finalNickname = nickname ?? "Unknown"
|
||
let nostrKey = currentStatus?.peerNostrPublicKey ?? idBridge.getNostrPublicKey(for: noisePublicKey)
|
||
|
||
FavoritesPersistenceService.shared.addFavorite(
|
||
peerNoisePublicKey: noisePublicKey,
|
||
peerNostrPublicKey: nostrKey,
|
||
peerNickname: finalNickname
|
||
)
|
||
}
|
||
|
||
// Trigger UI update
|
||
objectWillChange.send()
|
||
|
||
// Send favorite notification via Nostr if we're mutual favorites
|
||
if !wasFavorite && currentStatus?.theyFavoritedUs == true {
|
||
// We just favorited them and they already favorite us - send via Nostr
|
||
sendFavoriteNotificationViaNostr(noisePublicKey: noisePublicKey, isFavorite: true)
|
||
} else if wasFavorite {
|
||
// We're unfavoriting - send via Nostr if they still favorite us
|
||
sendFavoriteNotificationViaNostr(noisePublicKey: noisePublicKey, isFavorite: false)
|
||
}
|
||
}
|
||
} else {
|
||
// This is an ephemeral peer ID (16 hex chars), use normal toggle
|
||
unifiedPeerService.toggleFavorite(peerID)
|
||
// Trigger UI update
|
||
objectWillChange.send()
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
func isFavorite(peerID: PeerID) -> Bool {
|
||
// Distinguish between ephemeral peer IDs (16 hex chars) and Noise public keys (64 hex chars)
|
||
if let noisePublicKey = peerID.noiseKey {
|
||
// This is a Noise public key
|
||
if let status = FavoritesPersistenceService.shared.getFavoriteStatus(for: noisePublicKey) {
|
||
return status.isFavorite
|
||
}
|
||
} else {
|
||
// This is an ephemeral peer ID - check with UnifiedPeerService
|
||
if let peer = unifiedPeerService.getPeer(by: peerID) {
|
||
return peer.isFavorite
|
||
}
|
||
}
|
||
|
||
return false
|
||
}
|
||
|
||
// MARK: - Public Key and Identity Management
|
||
|
||
@MainActor
|
||
func isPeerBlocked(_ peerID: PeerID) -> Bool {
|
||
return unifiedPeerService.isBlocked(peerID)
|
||
}
|
||
|
||
// Helper method to find current peer ID for a fingerprint
|
||
@MainActor
|
||
private func getCurrentPeerIDForFingerprint(_ fingerprint: String) -> PeerID? {
|
||
// Search through all connected peers to find the one with matching fingerprint
|
||
for peerID in connectedPeers {
|
||
if let mappedFingerprint = peerIDToPublicKeyFingerprint[peerID],
|
||
mappedFingerprint == fingerprint {
|
||
return peerID
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// Helper method to update selectedPrivateChatPeer if fingerprint matches
|
||
@MainActor
|
||
private func updatePrivateChatPeerIfNeeded() {
|
||
guard let chatFingerprint = selectedPrivateChatFingerprint else { return }
|
||
|
||
// Find current peer ID for the fingerprint
|
||
if let currentPeerID = getCurrentPeerIDForFingerprint(chatFingerprint) {
|
||
// Update the selected peer if it's different
|
||
if let oldPeerID = selectedPrivateChatPeer, oldPeerID != currentPeerID {
|
||
|
||
// Migrate messages from old peer ID to new peer ID
|
||
if let oldMessages = privateChats[oldPeerID] {
|
||
var chats = privateChats
|
||
if chats[currentPeerID] == nil {
|
||
chats[currentPeerID] = []
|
||
}
|
||
chats[currentPeerID]?.append(contentsOf: oldMessages)
|
||
// Sort by timestamp
|
||
chats[currentPeerID]?.sort { $0.timestamp < $1.timestamp }
|
||
|
||
// Remove duplicates
|
||
var seen = Set<String>()
|
||
chats[currentPeerID] = chats[currentPeerID]?.filter { msg in
|
||
if seen.contains(msg.id) {
|
||
return false
|
||
}
|
||
seen.insert(msg.id)
|
||
return true
|
||
}
|
||
|
||
// Remove old peer ID
|
||
chats.removeValue(forKey: oldPeerID)
|
||
|
||
// Update all at once
|
||
privateChats = chats // Trigger setter
|
||
}
|
||
|
||
// Migrate unread status
|
||
if unreadPrivateMessages.contains(oldPeerID) {
|
||
unreadPrivateMessages.remove(oldPeerID)
|
||
unreadPrivateMessages.insert(currentPeerID)
|
||
}
|
||
|
||
selectedPrivateChatPeer = currentPeerID
|
||
|
||
// Schedule UI update for encryption status change
|
||
// UI will update automatically
|
||
|
||
// Also refresh the peer list to update encryption status
|
||
Task { @MainActor in
|
||
// UnifiedPeerService updates automatically via subscriptions
|
||
}
|
||
} else if selectedPrivateChatPeer == nil {
|
||
// Just set the peer ID if we don't have one
|
||
selectedPrivateChatPeer = currentPeerID
|
||
// UI will update automatically
|
||
}
|
||
|
||
// Clear unread messages for the current peer ID
|
||
unreadPrivateMessages.remove(currentPeerID)
|
||
}
|
||
}
|
||
|
||
// 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) {
|
||
// Ignore messages that are empty or whitespace-only to prevent blank lines
|
||
let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
guard !trimmed.isEmpty else { return }
|
||
|
||
// Check for commands
|
||
if content.hasPrefix("/") {
|
||
Task { @MainActor in
|
||
handleCommand(content)
|
||
}
|
||
return
|
||
}
|
||
|
||
if selectedPrivateChatPeer != nil {
|
||
// Update peer ID in case it changed due to reconnection
|
||
updatePrivateChatPeerIfNeeded()
|
||
|
||
if let selectedPeer = selectedPrivateChatPeer {
|
||
sendPrivateMessage(content, to: selectedPeer)
|
||
}
|
||
return
|
||
}
|
||
|
||
// Parse mentions from the content (use original content for user intent)
|
||
let mentions = parseMentions(from: content)
|
||
|
||
var geoContext: GeoOutgoingContext? = nil
|
||
|
||
// Add message to local display
|
||
var displaySender = nickname
|
||
var localSenderPeerID = meshService.myPeerID
|
||
var messageID: String? = nil
|
||
var messageTimestamp = Date()
|
||
|
||
switch activeChannel {
|
||
case .mesh:
|
||
break
|
||
case .location(let ch):
|
||
do {
|
||
let identity = try idBridge.deriveIdentity(forGeohash: ch.geohash)
|
||
let suffix = String(identity.publicKeyHex.suffix(4))
|
||
displaySender = nickname + "#" + suffix
|
||
localSenderPeerID = PeerID(nostr: identity.publicKeyHex)
|
||
let teleported = LocationChannelManager.shared.teleported
|
||
let event = try NostrProtocol.createEphemeralGeohashEvent(
|
||
content: trimmed,
|
||
geohash: ch.geohash,
|
||
senderIdentity: identity,
|
||
nickname: nickname,
|
||
teleported: teleported
|
||
)
|
||
messageID = event.id
|
||
messageTimestamp = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
||
geoContext = (channel: ch, event: event, identity: identity, teleported: teleported)
|
||
} catch {
|
||
SecureLogger.error("❌ Failed to prepare geohash message: \(error)", category: .session)
|
||
addSystemMessage(
|
||
String(localized: "system.location.send_failed", comment: "System message when a location channel send fails")
|
||
)
|
||
return
|
||
}
|
||
}
|
||
|
||
let message = BitchatMessage(
|
||
id: messageID,
|
||
sender: displaySender,
|
||
content: trimmed,
|
||
timestamp: messageTimestamp,
|
||
isRelay: false,
|
||
senderPeerID: localSenderPeerID,
|
||
mentions: mentions.isEmpty ? nil : mentions
|
||
)
|
||
|
||
timelineStore.append(message, to: activeChannel)
|
||
refreshVisibleMessages(from: activeChannel)
|
||
|
||
// Update content LRU for near-dup detection
|
||
let ckey = deduplicationService.normalizedContentKey(message.content)
|
||
deduplicationService.recordContentKey(ckey, timestamp: message.timestamp)
|
||
|
||
trimMessagesIfNeeded()
|
||
|
||
// UI updates automatically via @Published var messages
|
||
|
||
updateChannelActivityTimeThenSend(content: content,
|
||
trimmed: trimmed,
|
||
mentions: mentions,
|
||
geoContext: geoContext,
|
||
messageID: message.id,
|
||
timestamp: message.timestamp)
|
||
}
|
||
|
||
private func updateChannelActivityTimeThenSend(content: String,
|
||
trimmed: String,
|
||
mentions: [String],
|
||
geoContext: GeoOutgoingContext?,
|
||
messageID: String,
|
||
timestamp: Date) {
|
||
switch activeChannel {
|
||
case .mesh:
|
||
lastPublicActivityAt["mesh"] = Date()
|
||
// Send via mesh with mentions
|
||
meshService.sendMessage(content, mentions: mentions, messageID: messageID, timestamp: timestamp)
|
||
case .location(let ch):
|
||
lastPublicActivityAt["geo:\(ch.geohash)"] = Date()
|
||
guard let context = geoContext, context.channel.geohash == ch.geohash else {
|
||
SecureLogger.error("Geo: missing send context for \(ch.geohash)", category: .session)
|
||
addSystemMessage(
|
||
String(localized: "system.location.send_failed", comment: "System message when a location channel send fails")
|
||
)
|
||
return
|
||
}
|
||
// Send to geohash channel via Nostr ephemeral
|
||
Task { @MainActor in
|
||
self.sendGeohash(context: context)
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
// 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] {
|
||
participantTracker.getVisiblePeople()
|
||
}
|
||
|
||
/// CommandContextProvider conformance - returns visible geo participants
|
||
func getVisibleGeoParticipants() -> [CommandGeoParticipant] {
|
||
visibleGeohashPeople().map { CommandGeoParticipant(id: $0.id, displayName: $0.displayName) }
|
||
}
|
||
/// Returns the current participant count for a specific geohash, using the 5-minute activity window.
|
||
@MainActor
|
||
func geohashParticipantCount(for geohash: String) -> Int {
|
||
participantTracker.participantCount(for: geohash)
|
||
}
|
||
|
||
// MARK: - GeohashParticipantContext Protocol
|
||
|
||
func displayNameForPubkey(_ pubkeyHex: String) -> String {
|
||
displayNameForNostrPubkey(pubkeyHex)
|
||
}
|
||
|
||
func isBlocked(_ pubkeyHexLowercased: String) -> Bool {
|
||
identityManager.isNostrBlocked(pubkeyHexLowercased: pubkeyHexLowercased)
|
||
}
|
||
|
||
// Geohash block helpers
|
||
@MainActor
|
||
func isGeohashUserBlocked(pubkeyHexLowercased: String) -> Bool {
|
||
return identityManager.isNostrBlocked(pubkeyHexLowercased: pubkeyHexLowercased)
|
||
}
|
||
@MainActor
|
||
func blockGeohashUser(pubkeyHexLowercased: String, displayName: String) {
|
||
let hex = pubkeyHexLowercased.lowercased()
|
||
identityManager.setNostrBlocked(hex, isBlocked: true)
|
||
|
||
// Remove from participants for all geohashes
|
||
participantTracker.removeParticipant(pubkeyHex: hex)
|
||
|
||
// Remove their public messages from current geohash timeline and visible list
|
||
if let gh = currentGeohash {
|
||
let predicate: (BitchatMessage) -> Bool = { [self] msg in
|
||
guard let spid = msg.senderPeerID, spid.isGeoDM || spid.isGeoChat else { return false }
|
||
if let full = self.nostrKeyMapping[spid]?.lowercased() { return full == hex }
|
||
return false
|
||
}
|
||
timelineStore.removeMessages(in: gh, where: predicate)
|
||
if case .location = activeChannel {
|
||
messages.removeAll(where: predicate)
|
||
}
|
||
}
|
||
|
||
// Remove geohash DM conversation if exists
|
||
let convKey = PeerID(nostr_: hex)
|
||
if privateChats[convKey] != nil {
|
||
privateChats.removeValue(forKey: convKey)
|
||
unreadPrivateMessages.remove(convKey)
|
||
}
|
||
|
||
// Remove mapping keys pointing to this pubkey to avoid accidental resolution
|
||
for (key, value) in self.nostrKeyMapping where value.lowercased() == hex {
|
||
self.nostrKeyMapping.removeValue(forKey: key)
|
||
}
|
||
|
||
addSystemMessage(
|
||
String(
|
||
format: String(localized: "system.geohash.blocked", comment: "System message shown when a user is blocked in geohash chats"),
|
||
locale: .current,
|
||
displayName
|
||
)
|
||
)
|
||
}
|
||
@MainActor
|
||
func unblockGeohashUser(pubkeyHexLowercased: String, displayName: String) {
|
||
identityManager.setNostrBlocked(pubkeyHexLowercased, isBlocked: false)
|
||
addSystemMessage(
|
||
String(
|
||
format: String(localized: "system.geohash.unblocked", comment: "System message shown when a user is unblocked in geohash chats"),
|
||
locale: .current,
|
||
displayName
|
||
)
|
||
)
|
||
}
|
||
|
||
|
||
|
||
func displayNameForNostrPubkey(_ pubkeyHex: String) -> String {
|
||
let suffix = String(pubkeyHex.suffix(4))
|
||
// If this is our per-geohash identity, use our nickname
|
||
if let gh = currentGeohash, let myGeoIdentity = try? idBridge.deriveIdentity(forGeohash: gh) {
|
||
if myGeoIdentity.publicKeyHex.lowercased() == pubkeyHex.lowercased() {
|
||
return nickname + "#" + suffix
|
||
}
|
||
}
|
||
// If we have a known nickname tag for this pubkey, use it
|
||
if let nick = geoNicknames[pubkeyHex.lowercased()], !nick.isEmpty {
|
||
return nick + "#" + suffix
|
||
}
|
||
// Otherwise, anonymous with collision-resistant suffix
|
||
return "anon#\(suffix)"
|
||
}
|
||
|
||
|
||
|
||
// MARK: - Media Transfers
|
||
|
||
private enum MediaSendError: Error {
|
||
case encodingFailed
|
||
case tooLarge
|
||
case copyFailed
|
||
}
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
func currentPublicSender() -> (name: String, peerID: PeerID) {
|
||
var displaySender = nickname
|
||
var senderPeerID = meshService.myPeerID
|
||
if case .location(let ch) = activeChannel,
|
||
let identity = try? idBridge.deriveIdentity(forGeohash: ch.geohash) {
|
||
let suffix = String(identity.publicKeyHex.suffix(4))
|
||
displaySender = nickname + "#" + suffix
|
||
senderPeerID = PeerID(nostr: identity.publicKeyHex)
|
||
}
|
||
return (displaySender, senderPeerID)
|
||
}
|
||
|
||
@MainActor
|
||
func nicknameForPeer(_ peerID: PeerID) -> String {
|
||
if let name = meshService.peerNickname(peerID: peerID) {
|
||
return name
|
||
}
|
||
if let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID),
|
||
!favorite.peerNickname.isEmpty {
|
||
return favorite.peerNickname
|
||
}
|
||
if let noiseKey = Data(hexString: peerID.id),
|
||
let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
|
||
!favorite.peerNickname.isEmpty {
|
||
return favorite.peerNickname
|
||
}
|
||
return "user"
|
||
}
|
||
|
||
|
||
|
||
@MainActor
|
||
func removeMessage(withID messageID: String, cleanupFile: Bool = false) {
|
||
var removedMessage: BitchatMessage?
|
||
|
||
if let idx = messages.firstIndex(where: { $0.id == messageID }) {
|
||
removedMessage = messages.remove(at: idx)
|
||
}
|
||
|
||
if let storeRemoved = timelineStore.removeMessage(withID: messageID) {
|
||
removedMessage = removedMessage ?? storeRemoved
|
||
}
|
||
|
||
var chats = privateChats
|
||
for (peerID, items) in chats {
|
||
let filtered = items.filter { $0.id != messageID }
|
||
if filtered.count != items.count {
|
||
if filtered.isEmpty {
|
||
chats.removeValue(forKey: peerID)
|
||
} else {
|
||
chats[peerID] = filtered
|
||
}
|
||
if removedMessage == nil {
|
||
removedMessage = items.first(where: { $0.id == messageID })
|
||
}
|
||
}
|
||
}
|
||
privateChats = chats
|
||
|
||
if cleanupFile, let message = removedMessage {
|
||
cleanupLocalFile(forMessage: message)
|
||
}
|
||
|
||
objectWillChange.send()
|
||
}
|
||
|
||
|
||
/// 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
|
||
)
|
||
if privateChats[peerID] == nil { privateChats[peerID] = [] }
|
||
privateChats[peerID]?.append(systemMessage)
|
||
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
|
||
|
||
switch state {
|
||
case .poweredOff:
|
||
bluetoothAlertMessage = String(localized: "content.alert.bluetooth_required.off", comment: "Message shown when Bluetooth is turned off")
|
||
showBluetoothAlert = true
|
||
case .unauthorized:
|
||
bluetoothAlertMessage = String(localized: "content.alert.bluetooth_required.permission", comment: "Message shown when Bluetooth permission is missing")
|
||
showBluetoothAlert = true
|
||
case .unsupported:
|
||
bluetoothAlertMessage = String(localized: "content.alert.bluetooth_required.unsupported", comment: "Message shown when the device lacks Bluetooth support")
|
||
showBluetoothAlert = true
|
||
case .poweredOn:
|
||
// Hide alert when Bluetooth is powered on
|
||
showBluetoothAlert = false
|
||
bluetoothAlertMessage = ""
|
||
case .unknown, .resetting:
|
||
// Don't show alerts for transient states
|
||
showBluetoothAlert = false
|
||
@unknown default:
|
||
showBluetoothAlert = false
|
||
}
|
||
}
|
||
|
||
// 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) {
|
||
// Safety check: Don't allow starting chat with ourselves
|
||
if peerID == meshService.myPeerID {
|
||
return
|
||
}
|
||
|
||
let peerNickname = meshService.peerNickname(peerID: peerID) ?? "unknown"
|
||
|
||
// Check if the peer is blocked
|
||
if unifiedPeerService.isBlocked(peerID) {
|
||
addSystemMessage(
|
||
String(
|
||
format: String(localized: "system.chat.blocked", comment: "System message when starting chat fails because peer is blocked"),
|
||
locale: .current,
|
||
peerNickname
|
||
)
|
||
)
|
||
return
|
||
}
|
||
|
||
// Check mutual favorites for offline messaging
|
||
if let peer = unifiedPeerService.getPeer(by: peerID),
|
||
peer.isFavorite && !peer.theyFavoritedUs && !peer.isConnected {
|
||
addSystemMessage(
|
||
String(
|
||
format: String(localized: "system.chat.requires_favorite", comment: "System message when mutual favorite requirement blocks chat"),
|
||
locale: .current,
|
||
peerNickname
|
||
)
|
||
)
|
||
return
|
||
}
|
||
|
||
// Consolidate messages from different peer ID representations (stable Noise key, temp Nostr IDs)
|
||
// Pass persisted sentReadReceipts to correctly identify already-read messages after app restart
|
||
_ = privateChatManager.consolidateMessages(for: peerID, peerNickname: peerNickname, persistedReadReceipts: sentReadReceipts)
|
||
|
||
// Trigger handshake if needed (mesh peers only). Skip for Nostr geohash conv keys.
|
||
if !peerID.isGeoDM && !peerID.isGeoChat {
|
||
let sessionState = meshService.getNoiseSessionState(for: peerID)
|
||
switch sessionState {
|
||
case .none, .failed:
|
||
meshService.triggerHandshake(with: peerID)
|
||
case .handshakeQueued, .handshaking, .established:
|
||
break
|
||
}
|
||
} else {
|
||
SecureLogger.debug("GeoDM: skipping mesh handshake for virtual peerID=\(peerID)", category: .session)
|
||
}
|
||
|
||
// Sync read receipt tracking to prevent duplicates
|
||
privateChatManager.syncReadReceiptsForSentMessages(peerID: peerID, nickname: nickname, externalReceipts: &sentReadReceipts)
|
||
|
||
privateChatManager.startChat(with: peerID)
|
||
|
||
// Also mark messages as read for Nostr ACKs
|
||
// This ensures read receipts are sent even for consolidated messages
|
||
markPrivateMessagesAsRead(from: peerID)
|
||
}
|
||
|
||
func endPrivateChat() {
|
||
selectedPrivateChatPeer = nil
|
||
selectedPrivateChatFingerprint = nil
|
||
}
|
||
|
||
// MARK: - Nostr Message Handling
|
||
|
||
@MainActor
|
||
@objc private func handleNostrMessage(_ notification: Notification) {
|
||
guard let message = notification.userInfo?["message"] as? BitchatMessage else { return }
|
||
|
||
// Store the Nostr pubkey if provided (for messages from unknown senders)
|
||
if let nostrPubkey = notification.userInfo?["nostrPubkey"] as? String,
|
||
let senderPeerID = message.senderPeerID {
|
||
// Store mapping for read receipts
|
||
nostrKeyMapping[senderPeerID] = nostrPubkey
|
||
}
|
||
|
||
// Process the Nostr message through the same flow as Bluetooth messages
|
||
didReceiveMessage(message)
|
||
}
|
||
|
||
@objc private func handleDeliveryAcknowledgment(_ notification: Notification) {
|
||
guard let messageId = notification.userInfo?["messageId"] as? String else { return }
|
||
|
||
|
||
|
||
// Update the delivery status for the message
|
||
if let index = messages.firstIndex(where: { $0.id == messageId }) {
|
||
// Update delivery status to delivered
|
||
messages[index].deliveryStatus = DeliveryStatus.delivered(to: "nostr", at: Date())
|
||
|
||
// Schedule UI update for delivery status
|
||
// UI will update automatically
|
||
}
|
||
|
||
// Also update in private chats if it's a private message
|
||
for (peerID, chatMessages) in privateChats {
|
||
if let index = chatMessages.firstIndex(where: { $0.id == messageId }) {
|
||
privateChats[peerID]?[index].deliveryStatus = DeliveryStatus.delivered(to: "nostr", at: Date())
|
||
// UI will update automatically
|
||
break
|
||
}
|
||
}
|
||
}
|
||
|
||
@objc private func handleNostrReadReceipt(_ notification: Notification) {
|
||
guard let receipt = notification.userInfo?["receipt"] as? ReadReceipt else { return }
|
||
|
||
SecureLogger.info("📖 Handling read receipt for message \(receipt.originalMessageID) from Nostr", category: .session)
|
||
|
||
// Process the read receipt through the same flow as Bluetooth read receipts
|
||
didReceiveReadReceipt(receipt)
|
||
}
|
||
|
||
@MainActor
|
||
@objc private func handlePeerStatusUpdate(_ notification: Notification) {
|
||
// Update private chat peer if needed when peer status changes
|
||
updatePrivateChatPeerIfNeeded()
|
||
}
|
||
|
||
@objc private func handleFavoriteStatusChanged(_ notification: Notification) {
|
||
guard let peerPublicKey = notification.userInfo?["peerPublicKey"] as? Data else { return }
|
||
|
||
Task { @MainActor in
|
||
// Handle noise key updates
|
||
if let isKeyUpdate = notification.userInfo?["isKeyUpdate"] as? Bool,
|
||
isKeyUpdate,
|
||
let oldKey = notification.userInfo?["oldPeerPublicKey"] as? Data {
|
||
let oldPeerID = PeerID(hexData: oldKey)
|
||
let newPeerID = PeerID(hexData: peerPublicKey)
|
||
|
||
// If we have a private chat open with the old peer ID, update it to the new one
|
||
if selectedPrivateChatPeer == oldPeerID {
|
||
SecureLogger.info("📱 Updating private chat peer ID due to key change: \(oldPeerID) -> \(newPeerID)", category: .session)
|
||
|
||
// Transfer private chat messages to new peer ID
|
||
if let messages = privateChats[oldPeerID] {
|
||
var chats = privateChats
|
||
chats[newPeerID] = messages
|
||
chats.removeValue(forKey: oldPeerID)
|
||
privateChats = chats // Trigger setter
|
||
}
|
||
|
||
// Transfer unread status
|
||
if unreadPrivateMessages.contains(oldPeerID) {
|
||
unreadPrivateMessages.remove(oldPeerID)
|
||
unreadPrivateMessages.insert(newPeerID)
|
||
}
|
||
|
||
// Update selected peer
|
||
selectedPrivateChatPeer = newPeerID
|
||
|
||
// Update fingerprint tracking if needed
|
||
if let fingerprint = peerIDToPublicKeyFingerprint[oldPeerID] {
|
||
peerIDToPublicKeyFingerprint.removeValue(forKey: oldPeerID)
|
||
peerIDToPublicKeyFingerprint[newPeerID] = fingerprint
|
||
selectedPrivateChatFingerprint = fingerprint
|
||
}
|
||
|
||
// Schedule UI refresh
|
||
// UI will update automatically
|
||
} else {
|
||
// Even if the chat isn't open, migrate any existing private chat data
|
||
if let messages = privateChats[oldPeerID] {
|
||
SecureLogger.debug("📱 Migrating private chat messages from \(oldPeerID) to \(newPeerID)", category: .session)
|
||
var chats = privateChats
|
||
chats[newPeerID] = messages
|
||
chats.removeValue(forKey: oldPeerID)
|
||
privateChats = chats // Trigger setter
|
||
}
|
||
|
||
// Transfer unread status
|
||
if unreadPrivateMessages.contains(oldPeerID) {
|
||
unreadPrivateMessages.remove(oldPeerID)
|
||
unreadPrivateMessages.insert(newPeerID)
|
||
}
|
||
|
||
// Update fingerprint mapping
|
||
if let fingerprint = peerIDToPublicKeyFingerprint[oldPeerID] {
|
||
peerIDToPublicKeyFingerprint.removeValue(forKey: oldPeerID)
|
||
peerIDToPublicKeyFingerprint[newPeerID] = fingerprint
|
||
}
|
||
}
|
||
}
|
||
|
||
// First check if this is a peer ID update for our current chat
|
||
updatePrivateChatPeerIfNeeded()
|
||
|
||
// Then handle favorite/unfavorite messages if applicable
|
||
if let isFavorite = notification.userInfo?["isFavorite"] as? Bool {
|
||
let peerID = PeerID(hexData: peerPublicKey)
|
||
let action = isFavorite ? "favorited" : "unfavorited"
|
||
|
||
// Find peer nickname
|
||
let peerNickname: String
|
||
if let nickname = meshService.peerNickname(peerID: peerID) {
|
||
peerNickname = nickname
|
||
} else if let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(for: peerPublicKey) {
|
||
peerNickname = favorite.peerNickname
|
||
} else {
|
||
peerNickname = "Unknown"
|
||
}
|
||
|
||
// Create system message
|
||
let systemMessage = BitchatMessage(
|
||
id: UUID().uuidString,
|
||
sender: "System",
|
||
content: "\(peerNickname) \(action) you",
|
||
timestamp: Date(),
|
||
isRelay: false,
|
||
originalSender: nil,
|
||
isPrivate: false,
|
||
recipientNickname: nil,
|
||
senderPeerID: nil,
|
||
mentions: nil
|
||
)
|
||
|
||
// Add to message stream
|
||
addMessage(systemMessage)
|
||
|
||
// Update peer manager to refresh UI
|
||
// UnifiedPeerService updates automatically via subscriptions
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - App Lifecycle
|
||
|
||
@MainActor
|
||
@objc private func appDidBecomeActive() {
|
||
// Check Bluetooth state and show alert if needed
|
||
if let bleService = meshService as? BLEService {
|
||
let currentState = bleService.getCurrentBluetoothState()
|
||
updateBluetoothState(currentState)
|
||
}
|
||
|
||
// When app becomes active, send read receipts for visible private chat
|
||
if let peerID = selectedPrivateChatPeer {
|
||
// Try immediately
|
||
self.markPrivateMessagesAsRead(from: peerID)
|
||
// And again with a delay
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiAnimationMediumSeconds) {
|
||
self.markPrivateMessagesAsRead(from: peerID)
|
||
}
|
||
}
|
||
// Subscriptions will be resent after connections come back up
|
||
}
|
||
|
||
@MainActor
|
||
@objc private func userDidTakeScreenshot() {
|
||
// Respect privacy: do not broadcast screenshots taken from non-chat sheets
|
||
if isLocationChannelsSheetPresented {
|
||
// Show a warning about sharing location screenshots publicly
|
||
showScreenshotPrivacyWarning = true
|
||
return
|
||
}
|
||
if isAppInfoPresented {
|
||
// Silently ignore screenshots of app info
|
||
return
|
||
}
|
||
|
||
// Send screenshot notification based on current context
|
||
let screenshotMessage = "* \(nickname) took a screenshot *"
|
||
|
||
if let peerID = selectedPrivateChatPeer {
|
||
// In private chat - send to the other person
|
||
if let peerNickname = meshService.peerNickname(peerID: peerID) {
|
||
// Only send screenshot notification if we have an established session
|
||
// This prevents triggering handshake requests for screenshot notifications
|
||
let sessionState = meshService.getNoiseSessionState(for: peerID)
|
||
switch sessionState {
|
||
case .established:
|
||
// Send the message directly without going through sendPrivateMessage to avoid local echo
|
||
messageRouter.sendPrivate(screenshotMessage, to: peerID, recipientNickname: peerNickname, messageID: UUID().uuidString)
|
||
case .none, .failed, .handshakeQueued, .handshaking:
|
||
// Don't send screenshot notification if no session exists
|
||
SecureLogger.debug("Skipping screenshot notification to \(peerID) - no established session", category: .security)
|
||
}
|
||
}
|
||
|
||
// Show local notification immediately as system message (only in chat)
|
||
let localNotification = BitchatMessage(
|
||
sender: "system",
|
||
content: "you took a screenshot",
|
||
timestamp: Date(),
|
||
isRelay: false,
|
||
originalSender: nil,
|
||
isPrivate: true,
|
||
recipientNickname: meshService.peerNickname(peerID: peerID),
|
||
senderPeerID: meshService.myPeerID
|
||
)
|
||
var chats = privateChats
|
||
if chats[peerID] == nil {
|
||
chats[peerID] = []
|
||
}
|
||
chats[peerID]?.append(localNotification)
|
||
privateChats = chats // Trigger setter
|
||
|
||
} else {
|
||
// In public chat - send to active public channel
|
||
switch activeChannel {
|
||
case .mesh:
|
||
meshService.sendMessage(screenshotMessage,
|
||
mentions: [],
|
||
messageID: UUID().uuidString,
|
||
timestamp: Date())
|
||
case .location(let ch):
|
||
Task { @MainActor in
|
||
do {
|
||
let identity = try idBridge.deriveIdentity(forGeohash: ch.geohash)
|
||
let event = try NostrProtocol.createEphemeralGeohashEvent(
|
||
content: screenshotMessage,
|
||
geohash: ch.geohash,
|
||
senderIdentity: identity,
|
||
nickname: self.nickname,
|
||
teleported: LocationChannelManager.shared.teleported
|
||
)
|
||
let targetRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: ch.geohash, count: 5)
|
||
if targetRelays.isEmpty {
|
||
SecureLogger.warning("Geo: no geohash relays available for \(ch.geohash); not sending", category: .session)
|
||
} else {
|
||
NostrRelayManager.shared.sendEvent(event, to: targetRelays)
|
||
}
|
||
// Track ourselves as active participant
|
||
self.participantTracker.recordParticipant(pubkeyHex: identity.publicKeyHex)
|
||
} catch {
|
||
SecureLogger.error("❌ Failed to send geohash screenshot message: \(error)", category: .session)
|
||
self.addSystemMessage(
|
||
String(localized: "system.location.send_failed", comment: "System message when a location channel send fails")
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
// Show local notification immediately as system message (only in chat)
|
||
let localNotification = BitchatMessage(
|
||
sender: "system",
|
||
content: "you took a screenshot",
|
||
timestamp: Date(),
|
||
isRelay: false
|
||
)
|
||
// Add system message
|
||
addMessage(localNotification)
|
||
}
|
||
}
|
||
|
||
@objc private func appWillResignActive() {
|
||
// No-op; avoid forcing synchronize on resign
|
||
}
|
||
|
||
/// Save identity state without stopping services (for backgrounding)
|
||
func saveIdentityState() {
|
||
// Force save any pending identity changes (verifications, favorites, etc)
|
||
identityManager.forceSave()
|
||
|
||
// Verify identity key is still there
|
||
_ = keychain.verifyIdentityKeyExists()
|
||
}
|
||
|
||
@objc func applicationWillTerminate() {
|
||
// Send leave message to all peers
|
||
meshService.stopServices()
|
||
|
||
// Save identity state
|
||
saveIdentityState()
|
||
}
|
||
|
||
@MainActor
|
||
private func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID, originalTransport: String? = nil) {
|
||
// First, try to resolve the current peer ID in case they reconnected with a new ID
|
||
var actualPeerID = peerID
|
||
|
||
// Check if this peer ID exists in current nicknames
|
||
if meshService.peerNickname(peerID: peerID) == nil {
|
||
// Peer not found with this ID, try to find by fingerprint or nickname
|
||
if let oldNoiseKey = Data(hexString: peerID.id),
|
||
let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: oldNoiseKey) {
|
||
let peerNickname = favoriteStatus.peerNickname
|
||
|
||
// Search for the current peer ID with the same nickname
|
||
for (currentPeerID, currentNickname) in meshService.getPeerNicknames() {
|
||
if currentNickname == peerNickname {
|
||
SecureLogger.info("📖 Resolved updated peer ID for read receipt: \(peerID) -> \(currentPeerID)", category: .session)
|
||
actualPeerID = currentPeerID
|
||
break
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// If this originated over Nostr, skip (handled by Nostr code paths)
|
||
if originalTransport == "nostr" {
|
||
return
|
||
}
|
||
// Use router to decide (mesh if reachable, else Nostr if available)
|
||
messageRouter.sendReadReceipt(receipt, to: actualPeerID)
|
||
}
|
||
|
||
@MainActor
|
||
func markPrivateMessagesAsRead(from peerID: PeerID) {
|
||
privateChatManager.markAsRead(from: peerID)
|
||
|
||
// Handle GeoDM (nostr_*) read receipts directly via per-geohash identity
|
||
if peerID.isGeoDM,
|
||
let recipientHex = nostrKeyMapping[peerID],
|
||
case .location(let ch) = LocationChannelManager.shared.selectedChannel,
|
||
let id = try? idBridge.deriveIdentity(forGeohash: ch.geohash) {
|
||
let messages = privateChats[peerID] ?? []
|
||
for message in messages where message.senderPeerID == peerID && !message.isRelay {
|
||
if !sentReadReceipts.contains(message.id) {
|
||
SecureLogger.debug("GeoDM: sending READ for mid=\(message.id.prefix(8))… to=\(recipientHex.prefix(8))…", category: .session)
|
||
let nostrTransport = NostrTransport(keychain: keychain, idBridge: idBridge)
|
||
nostrTransport.senderPeerID = meshService.myPeerID
|
||
nostrTransport.sendReadReceiptGeohash(message.id, toRecipientHex: recipientHex, from: id)
|
||
sentReadReceipts.insert(message.id)
|
||
}
|
||
}
|
||
return
|
||
}
|
||
|
||
// Get the peer's Noise key to check for Nostr messages
|
||
var noiseKeyHex: PeerID? = nil
|
||
var peerNostrPubkey: String? = nil
|
||
|
||
// First check if peerID is already a hex Noise key
|
||
if let noiseKey = Data(hexString: peerID.id),
|
||
let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey) {
|
||
noiseKeyHex = peerID
|
||
peerNostrPubkey = favoriteStatus.peerNostrPublicKey
|
||
}
|
||
// Otherwise get the Noise key from the peer info
|
||
else if let peer = unifiedPeerService.getPeer(by: peerID) {
|
||
noiseKeyHex = PeerID(hexData: peer.noisePublicKey)
|
||
let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: peer.noisePublicKey)
|
||
peerNostrPubkey = favoriteStatus?.peerNostrPublicKey
|
||
|
||
// Also remove unread status from the stable Noise key if it exists
|
||
if let keyHex = noiseKeyHex, unreadPrivateMessages.contains(keyHex) {
|
||
unreadPrivateMessages.remove(keyHex)
|
||
}
|
||
}
|
||
|
||
// Send Nostr read ACKs if peer has Nostr capability
|
||
if peerNostrPubkey != nil {
|
||
// Check messages under both ephemeral peer ID and stable Noise key
|
||
let messagesToAck = getPrivateChatMessages(for: peerID)
|
||
|
||
for message in messagesToAck {
|
||
// Only send read ACKs for messages from the peer (not our own)
|
||
// Check both the ephemeral peer ID and stable Noise key as sender
|
||
if (message.senderPeerID == peerID || message.senderPeerID == noiseKeyHex) && !message.isRelay {
|
||
// Skip if we already sent an ACK for this message
|
||
if !sentReadReceipts.contains(message.id) {
|
||
// Use stable Noise key hex if available; else fall back to peerID
|
||
let recipPeer = peerID.isHex ? peerID : (unifiedPeerService.getPeer(by: peerID)?.peerID ?? peerID)
|
||
let receipt = ReadReceipt(originalMessageID: message.id, readerID: meshService.myPeerID, readerNickname: nickname)
|
||
messageRouter.sendReadReceipt(receipt, to: recipPeer)
|
||
sentReadReceipts.insert(message.id)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
func getPrivateChatMessages(for peerID: PeerID) -> [BitchatMessage] {
|
||
var combined: [BitchatMessage] = []
|
||
|
||
// Gather messages under the ephemeral peer ID
|
||
if let ephemeralMessages = privateChats[peerID] {
|
||
combined.append(contentsOf: ephemeralMessages)
|
||
}
|
||
|
||
// Also include messages stored under the stable Noise key (Nostr path)
|
||
if let peer = unifiedPeerService.getPeer(by: peerID) {
|
||
let noiseKeyHex = PeerID(hexData: peer.noisePublicKey)
|
||
if noiseKeyHex != peerID, let nostrMessages = privateChats[noiseKeyHex] {
|
||
combined.append(contentsOf: nostrMessages)
|
||
}
|
||
}
|
||
|
||
// De-duplicate by message ID: keep the item with the most advanced delivery status.
|
||
// This prevents duplicate IDs causing LazyVStack warnings and blank rows, and ensures
|
||
// we show the row whose status has already progressed to delivered/read.
|
||
func statusRank(_ s: DeliveryStatus?) -> Int {
|
||
guard let s = s else { return 0 }
|
||
switch s {
|
||
case .failed: return 1
|
||
case .sending: return 2
|
||
case .sent: return 3
|
||
case .partiallyDelivered: return 4
|
||
case .delivered: return 5
|
||
case .read: return 6
|
||
}
|
||
}
|
||
|
||
var bestByID: [String: BitchatMessage] = [:]
|
||
for msg in combined {
|
||
if let existing = bestByID[msg.id] {
|
||
let lhs = statusRank(existing.deliveryStatus)
|
||
let rhs = statusRank(msg.deliveryStatus)
|
||
if rhs > lhs || (rhs == lhs && msg.timestamp > existing.timestamp) {
|
||
bestByID[msg.id] = msg
|
||
}
|
||
} else {
|
||
bestByID[msg.id] = msg
|
||
}
|
||
}
|
||
|
||
// Return chronologically sorted, de-duplicated list
|
||
return bestByID.values.sorted { $0.timestamp < $1.timestamp }
|
||
}
|
||
|
||
@MainActor
|
||
func getPeerIDForNickname(_ nickname: String) -> PeerID? {
|
||
// When in a geohash channel, allow resolving by geohash participant nickname
|
||
switch LocationChannelManager.shared.selectedChannel {
|
||
case .location:
|
||
// If a disambiguation suffix is present (e.g., "name#abcd"), try exact displayName match first
|
||
if nickname.contains("#") {
|
||
if let person = visibleGeohashPeople().first(where: { $0.displayName == nickname }) {
|
||
let convKey = PeerID(nostr_: person.id)
|
||
nostrKeyMapping[convKey] = person.id
|
||
return convKey
|
||
}
|
||
}
|
||
let base: String = {
|
||
if let hashIndex = nickname.firstIndex(of: "#") { return String(nickname[..<hashIndex]) }
|
||
return nickname
|
||
}().lowercased()
|
||
// Try exact match against cached geoNicknames (pubkey -> nickname)
|
||
if let pub = geoNicknames.first(where: { (_, nick) in nick.lowercased() == base })?.key {
|
||
let convKey = PeerID(nostr_: pub)
|
||
nostrKeyMapping[convKey] = pub
|
||
return convKey
|
||
}
|
||
case .mesh:
|
||
break
|
||
}
|
||
// Fallback to mesh nickname resolution
|
||
return unifiedPeerService.getPeerID(for: nickname)
|
||
}
|
||
|
||
|
||
// MARK: - Emergency Functions
|
||
|
||
// PANIC: Emergency data clearing for activist safety
|
||
@MainActor
|
||
func panicClearAllData() {
|
||
// Messages are processed immediately - nothing to flush
|
||
|
||
// Clear all messages
|
||
messages.removeAll()
|
||
privateChatManager.privateChats.removeAll()
|
||
privateChatManager.unreadMessages.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")
|
||
|
||
// Clear verified fingerprints
|
||
verifiedFingerprints.removeAll()
|
||
// Verified fingerprints are cleared when identity data is cleared below
|
||
|
||
// 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()
|
||
peerIDToPublicKeyFingerprint.removeAll()
|
||
|
||
// 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
|
||
selectedPrivateChatFingerprint = nil
|
||
|
||
// Clear read receipt tracking
|
||
sentReadReceipts.removeAll()
|
||
deduplicationService.clearAll()
|
||
|
||
// Clear all caches
|
||
invalidateEncryptionCache()
|
||
|
||
// 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 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) {
|
||
// Build candidate list based on active channel
|
||
let peerCandidates: [String] = {
|
||
switch activeChannel {
|
||
case .mesh:
|
||
let values = meshService.getPeerNicknames().values
|
||
return Array(values.filter { $0 != meshService.myNickname })
|
||
case .location(let ch):
|
||
// From geochash participants we have seen via Nostr events
|
||
var tokens = Set<String>()
|
||
for (pubkey, nick) in geoNicknames {
|
||
let suffix = String(pubkey.suffix(4))
|
||
tokens.insert("\(nick)#\(suffix)")
|
||
}
|
||
// Optionally exclude self nick#abcd from suggestions
|
||
if let id = try? idBridge.deriveIdentity(forGeohash: ch.geohash) {
|
||
let myToken = nickname + "#" + String(id.publicKeyHex.suffix(4))
|
||
tokens.remove(myToken)
|
||
}
|
||
return Array(tokens)
|
||
}
|
||
}()
|
||
|
||
let (suggestions, range) = autocompleteService.getSuggestions(
|
||
for: text,
|
||
peers: peerCandidates,
|
||
cursorPosition: cursorPosition
|
||
)
|
||
|
||
if !suggestions.isEmpty {
|
||
autocompleteSuggestions = suggestions
|
||
autocompleteRange = range
|
||
showAutocomplete = true
|
||
selectedAutocompleteIndex = 0
|
||
} else {
|
||
autocompleteSuggestions = []
|
||
autocompleteRange = nil
|
||
showAutocomplete = false
|
||
selectedAutocompleteIndex = 0
|
||
}
|
||
}
|
||
|
||
func completeNickname(_ nickname: String, in text: inout String) -> Int {
|
||
guard let range = autocompleteRange else { return text.count }
|
||
|
||
text = autocompleteService.applySuggestion(nickname, to: text, range: range)
|
||
|
||
// Hide autocomplete
|
||
showAutocomplete = false
|
||
autocompleteSuggestions = []
|
||
autocompleteRange = nil
|
||
selectedAutocompleteIndex = 0
|
||
|
||
// Return new cursor position
|
||
return range.location + nickname.count + (nickname.hasPrefix("@") ? 1 : 2)
|
||
}
|
||
|
||
// MARK: - Message Formatting
|
||
|
||
@MainActor
|
||
func formatMessageAsText(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString {
|
||
// Determine if this message was sent by self (mesh, geo, or DM)
|
||
let isSelf: Bool = {
|
||
if let spid = message.senderPeerID {
|
||
// In geohash channels, compare against our per-geohash nostr short ID
|
||
if case .location(let ch) = activeChannel, spid.isGeoChat {
|
||
let myGeo: NostrIdentity? = {
|
||
if let cached = cachedGeohashIdentity, cached.geohash == ch.geohash {
|
||
return cached.identity
|
||
}
|
||
// Fallback: derive and cache (should rarely happen)
|
||
if let identity = try? idBridge.deriveIdentity(forGeohash: ch.geohash) {
|
||
cachedGeohashIdentity = (ch.geohash, identity)
|
||
return identity
|
||
}
|
||
return nil
|
||
}()
|
||
if let myGeo {
|
||
return spid == PeerID(nostr: myGeo.publicKeyHex)
|
||
}
|
||
}
|
||
return spid == meshService.myPeerID
|
||
}
|
||
// Fallback by nickname
|
||
if message.sender == nickname { return true }
|
||
if message.sender.hasPrefix(nickname + "#") { return true }
|
||
return false
|
||
}()
|
||
// Check cache first (key includes dark mode + self flag)
|
||
let isDark = colorScheme == .dark
|
||
if let cachedText = message.getCachedFormattedText(isDark: isDark, isSelf: isSelf) {
|
||
return cachedText
|
||
}
|
||
|
||
// Not cached, format the message
|
||
var result = AttributedString()
|
||
|
||
let baseColor: Color = isSelf ? .orange : peerColor(for: message, isDark: isDark)
|
||
|
||
if message.sender != "system" {
|
||
// Sender (at the beginning) with light-gray suffix styling if present
|
||
let (baseName, suffix) = message.sender.splitSuffix()
|
||
var senderStyle = AttributeContainer()
|
||
// Use consistent color for all senders
|
||
senderStyle.foregroundColor = baseColor
|
||
// Bold the user's own nickname
|
||
let fontWeight: Font.Weight = isSelf ? .bold : .medium
|
||
senderStyle.font = .bitchatSystem(size: 14, weight: fontWeight, design: .monospaced)
|
||
// Make sender clickable: encode senderPeerID into a custom URL
|
||
if let spid = message.senderPeerID, let url = URL(string: "bitchat://user/\(spid.toPercentEncoded())") {
|
||
senderStyle.link = url
|
||
}
|
||
|
||
// Prefix "<@"
|
||
result.append(AttributedString("<@").mergingAttributes(senderStyle))
|
||
// Base name
|
||
result.append(AttributedString(baseName).mergingAttributes(senderStyle))
|
||
// Optional suffix in lighter variant of the base color (green or orange for self)
|
||
if !suffix.isEmpty {
|
||
var suffixStyle = senderStyle
|
||
suffixStyle.foregroundColor = baseColor.opacity(0.6)
|
||
result.append(AttributedString(suffix).mergingAttributes(suffixStyle))
|
||
}
|
||
// Suffix "> "
|
||
result.append(AttributedString("> ").mergingAttributes(senderStyle))
|
||
|
||
// Process content with hashtags and mentions
|
||
let content = message.content
|
||
|
||
// For extremely long content, render as plain text to avoid heavy regex/layout work,
|
||
// unless the content includes Cashu tokens we want to chip-render below
|
||
// Compute NSString-backed length for regex/nsrange correctness with multi-byte characters
|
||
let nsContent = content as NSString
|
||
let nsLen = nsContent.length
|
||
let containsCashuEarly: Bool = {
|
||
let rx = Patterns.quickCashuPresence
|
||
return rx.numberOfMatches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) > 0
|
||
}()
|
||
if (content.count > 4000 || content.hasVeryLongToken(threshold: 1024)) && !containsCashuEarly {
|
||
var plainStyle = AttributeContainer()
|
||
plainStyle.foregroundColor = baseColor
|
||
plainStyle.font = isSelf
|
||
? .bitchatSystem(size: 14, weight: .bold, design: .monospaced)
|
||
: .bitchatSystem(size: 14, design: .monospaced)
|
||
result.append(AttributedString(content).mergingAttributes(plainStyle))
|
||
} else {
|
||
// Reuse compiled regexes and detector from MessageFormattingEngine
|
||
let hashtagRegex = Patterns.hashtag
|
||
let mentionRegex = Patterns.mention
|
||
let cashuRegex = Patterns.cashu
|
||
let bolt11Regex = Patterns.bolt11
|
||
let lnurlRegex = Patterns.lnurl
|
||
let lightningSchemeRegex = Patterns.lightningScheme
|
||
let detector = Patterns.linkDetector
|
||
let hasMentionsHint = content.contains("@")
|
||
let hasHashtagsHint = content.contains("#")
|
||
let hasURLHint = content.contains("://") || content.contains("www.") || content.contains("http")
|
||
let hasLightningHint = content.lowercased().contains("ln") || content.lowercased().contains("lightning:")
|
||
let hasCashuHint = content.lowercased().contains("cashu")
|
||
|
||
let hashtagMatches = hasHashtagsHint ? hashtagRegex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) : []
|
||
let mentionMatches = hasMentionsHint ? mentionRegex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) : []
|
||
let urlMatches = hasURLHint ? (detector?.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) ?? []) : []
|
||
let cashuMatches = hasCashuHint ? cashuRegex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) : []
|
||
let lightningMatches = hasLightningHint ? lightningSchemeRegex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) : []
|
||
let bolt11Matches = hasLightningHint ? bolt11Regex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) : []
|
||
let lnurlMatches = hasLightningHint ? lnurlRegex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) : []
|
||
|
||
// Combine and sort matches, excluding hashtags/URLs overlapping mentions
|
||
let mentionRanges = mentionMatches.map { $0.range(at: 0) }
|
||
func overlapsMention(_ r: NSRange) -> Bool {
|
||
for mr in mentionRanges { if NSIntersectionRange(r, mr).length > 0 { return true } }
|
||
return false
|
||
}
|
||
// Helper: check if a hashtag is immediately attached to a preceding @mention (e.g., @name#abcd)
|
||
func attachedToMention(_ r: NSRange) -> Bool {
|
||
if let nsRange = Range(r, in: content), nsRange.lowerBound > content.startIndex {
|
||
var i = content.index(before: nsRange.lowerBound)
|
||
while true {
|
||
let ch = content[i]
|
||
if ch.isWhitespace || ch.isNewline { break }
|
||
if ch == "@" { return true }
|
||
if i == content.startIndex { break }
|
||
i = content.index(before: i)
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
// Helper: ensure '#' starts a new token (start-of-line or whitespace before '#')
|
||
func isStandaloneHashtag(_ r: NSRange) -> Bool {
|
||
guard let nsRange = Range(r, in: content) else { return false }
|
||
if nsRange.lowerBound == content.startIndex { return true }
|
||
let prev = content.index(before: nsRange.lowerBound)
|
||
return content[prev].isWhitespace || content[prev].isNewline
|
||
}
|
||
var allMatches: [(range: NSRange, type: String)] = []
|
||
for match in hashtagMatches where !overlapsMention(match.range(at: 0)) && !attachedToMention(match.range(at: 0)) && isStandaloneHashtag(match.range(at: 0)) {
|
||
allMatches.append((match.range(at: 0), "hashtag"))
|
||
}
|
||
for match in mentionMatches {
|
||
allMatches.append((match.range(at: 0), "mention"))
|
||
}
|
||
for match in urlMatches where !overlapsMention(match.range) {
|
||
allMatches.append((match.range, "url"))
|
||
}
|
||
for match in cashuMatches where !overlapsMention(match.range(at: 0)) {
|
||
allMatches.append((match.range(at: 0), "cashu"))
|
||
}
|
||
// Lightning scheme first to avoid overlapping submatches
|
||
for match in lightningMatches where !overlapsMention(match.range(at: 0)) {
|
||
allMatches.append((match.range(at: 0), "lightning"))
|
||
}
|
||
// Exclude overlaps with lightning/url for bolt11/lnurl
|
||
let occupied: [NSRange] = urlMatches.map { $0.range } + lightningMatches.map { $0.range(at: 0) }
|
||
func overlapsOccupied(_ r: NSRange) -> Bool {
|
||
for or in occupied { if NSIntersectionRange(r, or).length > 0 { return true } }
|
||
return false
|
||
}
|
||
for match in bolt11Matches where !overlapsMention(match.range(at: 0)) && !overlapsOccupied(match.range(at: 0)) {
|
||
allMatches.append((match.range(at: 0), "bolt11"))
|
||
}
|
||
for match in lnurlMatches where !overlapsMention(match.range(at: 0)) && !overlapsOccupied(match.range(at: 0)) {
|
||
allMatches.append((match.range(at: 0), "lnurl"))
|
||
}
|
||
allMatches.sort { $0.range.location < $1.range.location }
|
||
|
||
// Build content with styling
|
||
var lastEnd = content.startIndex
|
||
let isMentioned = message.mentions?.contains(nickname) ?? false
|
||
|
||
for (range, type) in allMatches {
|
||
// Add text before match
|
||
if let nsRange = Range(range, in: content) {
|
||
if lastEnd < nsRange.lowerBound {
|
||
let beforeText = String(content[lastEnd..<nsRange.lowerBound])
|
||
if !beforeText.isEmpty {
|
||
var beforeStyle = AttributeContainer()
|
||
beforeStyle.foregroundColor = baseColor
|
||
beforeStyle.font = isSelf
|
||
? .bitchatSystem(size: 14, weight: .bold, design: .monospaced)
|
||
: .bitchatSystem(size: 14, design: .monospaced)
|
||
if isMentioned {
|
||
beforeStyle.font = beforeStyle.font?.bold()
|
||
}
|
||
result.append(AttributedString(beforeText).mergingAttributes(beforeStyle))
|
||
}
|
||
}
|
||
|
||
// Add styled match
|
||
let matchText = String(content[nsRange])
|
||
if type == "mention" {
|
||
// Split optional '#abcd' suffix and color suffix light grey
|
||
let (mBase, mSuffix) = matchText.splitSuffix()
|
||
// Determine if this mention targets me (resolves with optional suffix per active channel)
|
||
let mySuffix: String? = {
|
||
if case .location(let ch) = activeChannel, let id = try? idBridge.deriveIdentity(forGeohash: ch.geohash) {
|
||
return String(id.publicKeyHex.suffix(4))
|
||
}
|
||
return String(meshService.myPeerID.id.prefix(4))
|
||
}()
|
||
let isMentionToMe: Bool = {
|
||
if mBase == nickname {
|
||
if let suf = mySuffix, !mSuffix.isEmpty {
|
||
return mSuffix == "#\(suf)"
|
||
}
|
||
return mSuffix.isEmpty
|
||
}
|
||
return false
|
||
}()
|
||
var mentionStyle = AttributeContainer()
|
||
mentionStyle.font = .bitchatSystem(size: 14, weight: isSelf ? .bold : .semibold, design: .monospaced)
|
||
let mentionColor: Color = isMentionToMe ? .orange : baseColor
|
||
mentionStyle.foregroundColor = mentionColor
|
||
// Emit '@' (non-localizable symbol - use interpolation to avoid extraction)
|
||
let at = "@"
|
||
result.append(AttributedString("\(at)").mergingAttributes(mentionStyle))
|
||
// Base name
|
||
result.append(AttributedString(mBase).mergingAttributes(mentionStyle))
|
||
// Suffix in light grey
|
||
if !mSuffix.isEmpty {
|
||
var light = mentionStyle
|
||
light.foregroundColor = mentionColor.opacity(0.6)
|
||
result.append(AttributedString(mSuffix).mergingAttributes(light))
|
||
}
|
||
} else {
|
||
// Style non-mention matches
|
||
if type == "hashtag" {
|
||
// If the hashtag is a valid geohash, make it tappable (bitchat://geohash/<gh>)
|
||
let token = String(matchText.dropFirst()).lowercased()
|
||
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
||
let isGeohash = (2...12).contains(token.count) && token.allSatisfy { allowed.contains($0) }
|
||
// Do not link if this hashtag is directly attached to an @mention (e.g., @name#geohash)
|
||
let attachedToMention: Bool = {
|
||
// nsRange is the Range<String.Index> for this match within content
|
||
// Walk left until whitespace/newline; if we encounter '@' first, treat as part of mention
|
||
if nsRange.lowerBound > content.startIndex {
|
||
var i = content.index(before: nsRange.lowerBound)
|
||
while true {
|
||
let ch = content[i]
|
||
if ch.isWhitespace || ch.isNewline { break }
|
||
if ch == "@" { return true }
|
||
if i == content.startIndex { break }
|
||
i = content.index(before: i)
|
||
}
|
||
}
|
||
return false
|
||
}()
|
||
// Also require the '#' to start a new token (whitespace or start-of-line before '#')
|
||
let standalone: Bool = {
|
||
if nsRange.lowerBound == content.startIndex { return true }
|
||
let prev = content.index(before: nsRange.lowerBound)
|
||
return content[prev].isWhitespace || content[prev].isNewline
|
||
}()
|
||
var tagStyle = AttributeContainer()
|
||
tagStyle.font = isSelf
|
||
? .bitchatSystem(size: 14, weight: .bold, design: .monospaced)
|
||
: .bitchatSystem(size: 14, design: .monospaced)
|
||
tagStyle.foregroundColor = baseColor
|
||
if isGeohash && !attachedToMention && standalone, let url = URL(string: "bitchat://geohash/\(token)") {
|
||
tagStyle.link = url
|
||
tagStyle.underlineStyle = .single
|
||
}
|
||
result.append(AttributedString(matchText).mergingAttributes(tagStyle))
|
||
} else if type == "cashu" {
|
||
// Skip inline token; a styled chip is rendered below the message
|
||
// We insert a single space to avoid words sticking together
|
||
var spacer = AttributeContainer()
|
||
spacer.foregroundColor = baseColor
|
||
spacer.font = isSelf
|
||
? .bitchatSystem(size: 14, weight: .bold, design: .monospaced)
|
||
: .bitchatSystem(size: 14, design: .monospaced)
|
||
result.append(AttributedString(" ").mergingAttributes(spacer))
|
||
} else if type == "lightning" || type == "bolt11" || type == "lnurl" {
|
||
// Skip inline invoice/link; a styled chip is rendered below the message
|
||
var spacer = AttributeContainer()
|
||
spacer.foregroundColor = baseColor
|
||
spacer.font = isSelf
|
||
? .bitchatSystem(size: 14, weight: .bold, design: .monospaced)
|
||
: .bitchatSystem(size: 14, design: .monospaced)
|
||
result.append(AttributedString(" ").mergingAttributes(spacer))
|
||
} else {
|
||
// Keep URL styling and make it tappable via .link attribute
|
||
var matchStyle = AttributeContainer()
|
||
matchStyle.font = .bitchatSystem(size: 14, weight: isSelf ? .bold : .semibold, design: .monospaced)
|
||
if type == "url" {
|
||
matchStyle.foregroundColor = isSelf ? .orange : .blue
|
||
matchStyle.underlineStyle = .single
|
||
if let url = URL(string: matchText) {
|
||
matchStyle.link = url
|
||
}
|
||
}
|
||
result.append(AttributedString(matchText).mergingAttributes(matchStyle))
|
||
}
|
||
}
|
||
// Advance lastEnd safely in case of overlaps
|
||
if lastEnd < nsRange.upperBound {
|
||
lastEnd = nsRange.upperBound
|
||
}
|
||
}
|
||
}
|
||
|
||
// Add remaining text
|
||
if lastEnd < content.endIndex {
|
||
let remainingText = String(content[lastEnd...])
|
||
var remainingStyle = AttributeContainer()
|
||
remainingStyle.foregroundColor = baseColor
|
||
remainingStyle.font = isSelf
|
||
? .bitchatSystem(size: 14, weight: .bold, design: .monospaced)
|
||
: .bitchatSystem(size: 14, design: .monospaced)
|
||
if isMentioned {
|
||
remainingStyle.font = remainingStyle.font?.bold()
|
||
}
|
||
result.append(AttributedString(remainingText).mergingAttributes(remainingStyle))
|
||
}
|
||
}
|
||
|
||
// Add timestamp at the end (smaller, light grey)
|
||
let timestamp = AttributedString(" [\(message.formattedTimestamp)]")
|
||
var timestampStyle = AttributeContainer()
|
||
timestampStyle.foregroundColor = Color.gray.opacity(0.7)
|
||
timestampStyle.font = .bitchatSystem(size: 10, design: .monospaced)
|
||
result.append(timestamp.mergingAttributes(timestampStyle))
|
||
} else {
|
||
// System message
|
||
var contentStyle = AttributeContainer()
|
||
contentStyle.foregroundColor = Color.gray
|
||
let content = AttributedString("* \(message.content) *")
|
||
contentStyle.font = .bitchatSystem(size: 12, design: .monospaced).italic()
|
||
result.append(content.mergingAttributes(contentStyle))
|
||
|
||
// Add timestamp at the end for system messages too
|
||
let timestamp = AttributedString(" [\(message.formattedTimestamp)]")
|
||
var timestampStyle = AttributeContainer()
|
||
timestampStyle.foregroundColor = Color.gray.opacity(0.5)
|
||
timestampStyle.font = .bitchatSystem(size: 10, design: .monospaced)
|
||
result.append(timestamp.mergingAttributes(timestampStyle))
|
||
}
|
||
|
||
// Cache the formatted text
|
||
message.setCachedFormattedText(result, isDark: isDark, isSelf: isSelf)
|
||
|
||
return result
|
||
}
|
||
|
||
@MainActor
|
||
func formatMessageHeader(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString {
|
||
let isSelf: Bool = {
|
||
if let spid = message.senderPeerID {
|
||
if case .location(let ch) = activeChannel, spid.id.hasPrefix("nostr:") {
|
||
if let myGeo = try? idBridge.deriveIdentity(forGeohash: ch.geohash) {
|
||
return spid == PeerID(nostr: myGeo.publicKeyHex)
|
||
}
|
||
}
|
||
return spid == meshService.myPeerID
|
||
}
|
||
if message.sender == nickname { return true }
|
||
if message.sender.hasPrefix(nickname + "#") { return true }
|
||
return false
|
||
}()
|
||
|
||
let isDark = colorScheme == .dark
|
||
let baseColor: Color = isSelf ? .orange : peerColor(for: message, isDark: isDark)
|
||
|
||
if message.sender == "system" {
|
||
var style = AttributeContainer()
|
||
style.foregroundColor = baseColor
|
||
style.font = .bitchatSystem(size: 14, weight: .medium, design: .monospaced)
|
||
return AttributedString(message.sender).mergingAttributes(style)
|
||
}
|
||
|
||
var result = AttributedString()
|
||
let (baseName, suffix) = message.sender.splitSuffix()
|
||
var senderStyle = AttributeContainer()
|
||
senderStyle.foregroundColor = baseColor
|
||
senderStyle.font = .bitchatSystem(size: 14, weight: isSelf ? .bold : .medium, design: .monospaced)
|
||
if let spid = message.senderPeerID,
|
||
let url = URL(string: "bitchat://user/\(spid.id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? spid.id)") {
|
||
senderStyle.link = url
|
||
}
|
||
|
||
result.append(AttributedString("<@").mergingAttributes(senderStyle))
|
||
result.append(AttributedString(baseName).mergingAttributes(senderStyle))
|
||
if !suffix.isEmpty {
|
||
var suffixStyle = senderStyle
|
||
suffixStyle.foregroundColor = baseColor.opacity(0.6)
|
||
result.append(AttributedString(suffix).mergingAttributes(suffixStyle))
|
||
}
|
||
result.append(AttributedString("> ").mergingAttributes(senderStyle))
|
||
return result
|
||
}
|
||
|
||
// MARK: - Noise Protocol Support
|
||
|
||
@MainActor
|
||
func updateEncryptionStatusForPeers() {
|
||
for peerID in connectedPeers {
|
||
updateEncryptionStatusForPeer(peerID)
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
private func updateEncryptionStatusForPeer(_ peerID: PeerID) {
|
||
let noiseService = meshService.getNoiseService()
|
||
|
||
if noiseService.hasEstablishedSession(with: peerID) {
|
||
peerEncryptionStatus[peerID] = encryptionStatus(for: peerID)
|
||
} else if noiseService.hasSession(with: peerID) {
|
||
// Session exists but not established - handshaking
|
||
peerEncryptionStatus[peerID] = .noiseHandshaking
|
||
} else {
|
||
// No session at all
|
||
peerEncryptionStatus[peerID] = Optional.none
|
||
}
|
||
|
||
// Invalidate cache when encryption status changes
|
||
invalidateEncryptionCache(for: peerID)
|
||
|
||
// UI will update automatically via @Published properties
|
||
}
|
||
|
||
@MainActor
|
||
func getEncryptionStatus(for peerID: PeerID) -> EncryptionStatus {
|
||
// Check cache first
|
||
if let cachedStatus = encryptionStatusCache[peerID] {
|
||
return cachedStatus
|
||
}
|
||
|
||
// This must be a pure function - no state mutations allowed
|
||
// to avoid SwiftUI update loops
|
||
|
||
// Check if we've ever established a session by looking for a fingerprint
|
||
let hasEverEstablishedSession = getFingerprint(for: peerID) != nil
|
||
|
||
let sessionState = meshService.getNoiseSessionState(for: peerID)
|
||
|
||
let status: EncryptionStatus
|
||
|
||
// Determine status based on session state
|
||
switch sessionState {
|
||
case .established:
|
||
status = encryptionStatus(for: peerID)
|
||
case .handshaking, .handshakeQueued:
|
||
// If we've ever established a session, show secured instead of handshaking
|
||
if hasEverEstablishedSession {
|
||
// Check if it was verified before
|
||
status = encryptionStatus(for: peerID)
|
||
} else {
|
||
// First time establishing - show handshaking
|
||
status = .noiseHandshaking
|
||
}
|
||
case .none:
|
||
// If we've ever established a session, show secured instead of no handshake
|
||
if hasEverEstablishedSession {
|
||
// Check if it was verified before
|
||
status = encryptionStatus(for: peerID)
|
||
} else {
|
||
// Never established - show no handshake
|
||
status = .noHandshake
|
||
}
|
||
case .failed:
|
||
// If we've ever established a session, show secured instead of failed
|
||
if hasEverEstablishedSession {
|
||
// Check if it was verified before
|
||
status = encryptionStatus(for: peerID)
|
||
} else {
|
||
// Never established - show failed
|
||
status = .none
|
||
}
|
||
}
|
||
|
||
// Cache the result
|
||
encryptionStatusCache[peerID] = status
|
||
|
||
// Encryption status determined: \(status)
|
||
|
||
return status
|
||
}
|
||
|
||
// Clear caches when data changes
|
||
private func invalidateEncryptionCache(for peerID: PeerID? = nil) {
|
||
if let peerID {
|
||
encryptionStatusCache.removeValue(forKey: peerID)
|
||
} else {
|
||
encryptionStatusCache.removeAll()
|
||
}
|
||
}
|
||
|
||
|
||
// MARK: - Message Handling
|
||
|
||
func trimMessagesIfNeeded() {
|
||
if messages.count > maxMessages {
|
||
messages = Array(messages.suffix(maxMessages))
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
func refreshVisibleMessages(from channel: ChannelID? = nil) {
|
||
let target = channel ?? activeChannel
|
||
messages = timelineStore.messages(for: target)
|
||
}
|
||
|
||
@MainActor
|
||
private func peerColor(for message: BitchatMessage, isDark: Bool) -> Color {
|
||
if let spid = message.senderPeerID {
|
||
if spid.isGeoChat || spid.isGeoDM {
|
||
let full = nostrKeyMapping[spid]?.lowercased() ?? spid.bare.lowercased()
|
||
return getNostrPaletteColor(for: full, isDark: isDark)
|
||
} else if spid.id.count == 16 {
|
||
// Mesh short ID
|
||
return getPeerPaletteColor(for: spid, isDark: isDark)
|
||
} else {
|
||
return getPeerPaletteColor(for: PeerID(str: spid.id.lowercased()), isDark: isDark)
|
||
}
|
||
}
|
||
// Fallback when we only have a display name
|
||
return Color(peerSeed: message.sender.lowercased(), isDark: isDark)
|
||
}
|
||
|
||
// MARK: - MessageFormattingContext Protocol
|
||
|
||
@MainActor
|
||
func isSelfMessage(_ message: BitchatMessage) -> Bool {
|
||
if let spid = message.senderPeerID {
|
||
// In geohash channels, compare against our per-geohash nostr short ID
|
||
if case .location(let ch) = activeChannel, spid.isGeoChat {
|
||
let myGeo: NostrIdentity? = {
|
||
if let cached = cachedGeohashIdentity, cached.geohash == ch.geohash {
|
||
return cached.identity
|
||
}
|
||
// Derive and cache
|
||
if let identity = try? idBridge.deriveIdentity(forGeohash: ch.geohash) {
|
||
cachedGeohashIdentity = (ch.geohash, identity)
|
||
return identity
|
||
}
|
||
return nil
|
||
}()
|
||
if let myGeo {
|
||
return spid == PeerID(nostr: myGeo.publicKeyHex)
|
||
}
|
||
}
|
||
return spid == meshService.myPeerID
|
||
}
|
||
// Fallback by nickname
|
||
if message.sender == nickname { return true }
|
||
if message.sender.hasPrefix(nickname + "#") { return true }
|
||
return false
|
||
}
|
||
|
||
@MainActor
|
||
func senderColor(for message: BitchatMessage, isDark: Bool) -> Color {
|
||
return peerColor(for: message, isDark: isDark)
|
||
}
|
||
|
||
@MainActor
|
||
func peerURL(for peerID: PeerID) -> URL? {
|
||
return URL(string: "bitchat://user/\(peerID.toPercentEncoded())")
|
||
}
|
||
|
||
// Public helpers for views to color peers consistently in lists
|
||
@MainActor
|
||
func colorForNostrPubkey(_ pubkeyHexLowercased: String, isDark: Bool) -> Color {
|
||
return getNostrPaletteColor(for: pubkeyHexLowercased.lowercased(), isDark: isDark)
|
||
}
|
||
|
||
@MainActor
|
||
func colorForMeshPeer(id peerID: PeerID, isDark: Bool) -> Color {
|
||
return getPeerPaletteColor(for: peerID, isDark: isDark)
|
||
}
|
||
|
||
// MARK: - Peer Palette Coordination
|
||
private let meshPalette = MinimalDistancePalette(config: .mesh)
|
||
private let nostrPalette = MinimalDistancePalette(config: .nostr)
|
||
|
||
@MainActor
|
||
private func meshSeed(for peerID: PeerID) -> String {
|
||
if let full = getNoiseKeyForShortID(peerID)?.id.lowercased() {
|
||
return "noise:" + full
|
||
}
|
||
return peerID.id.lowercased()
|
||
}
|
||
|
||
@MainActor
|
||
private func getPeerPaletteColor(for peerID: PeerID, isDark: Bool) -> Color {
|
||
if peerID == meshService.myPeerID {
|
||
return .orange
|
||
}
|
||
|
||
meshPalette.ensurePalette(for: currentMeshPaletteSeeds())
|
||
if let color = meshPalette.color(for: peerID.id, isDark: isDark) {
|
||
return color
|
||
}
|
||
return Color(peerSeed: meshSeed(for: peerID), isDark: isDark)
|
||
}
|
||
|
||
@MainActor
|
||
private func currentMeshPaletteSeeds() -> [String: String] {
|
||
let myID = meshService.myPeerID
|
||
var seeds: [String: String] = [:]
|
||
for peer in allPeers where peer.peerID != myID {
|
||
seeds[peer.peerID.id] = meshSeed(for: peer.peerID)
|
||
}
|
||
return seeds
|
||
}
|
||
|
||
@MainActor
|
||
private func getNostrPaletteColor(for pubkeyHexLowercased: String, isDark: Bool) -> Color {
|
||
let myHex = currentGeohashIdentityHex()
|
||
if let myHex, pubkeyHexLowercased == myHex {
|
||
return .orange
|
||
}
|
||
|
||
nostrPalette.ensurePalette(for: currentNostrPaletteSeeds(excluding: myHex))
|
||
if let color = nostrPalette.color(for: pubkeyHexLowercased, isDark: isDark) {
|
||
return color
|
||
}
|
||
return Color(peerSeed: "nostr:" + pubkeyHexLowercased, isDark: isDark)
|
||
}
|
||
|
||
@MainActor
|
||
private func currentNostrPaletteSeeds(excluding myHex: String?) -> [String: String] {
|
||
var seeds: [String: String] = [:]
|
||
let excluded = myHex ?? ""
|
||
for person in visibleGeohashPeople() where person.id != excluded {
|
||
seeds[person.id] = "nostr:" + person.id
|
||
}
|
||
return seeds
|
||
}
|
||
|
||
@MainActor
|
||
private func currentGeohashIdentityHex() -> String? {
|
||
if case .location(let channel) = LocationChannelManager.shared.selectedChannel,
|
||
let identity = try? idBridge.deriveIdentity(forGeohash: channel.geohash) {
|
||
return identity.publicKeyHex.lowercased()
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// Clear the current public channel's timeline (visible + persistent buffer)
|
||
@MainActor
|
||
func clearCurrentPublicTimeline() {
|
||
// Clear messages from current timeline
|
||
messages.removeAll()
|
||
timelineStore.clear(channel: activeChannel)
|
||
|
||
// Delete associated media files (images, voice notes, files) in background
|
||
// Only delete from current chat to avoid removing private chat media
|
||
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)
|
||
|
||
// Only clear public media (mesh channel only - geohash media is separate)
|
||
// Note: This is conservative - only clears outgoing since we authored those
|
||
let outgoingDirs = [
|
||
filesDir.appendingPathComponent("voicenotes/outgoing", isDirectory: true),
|
||
filesDir.appendingPathComponent("images/outgoing", isDirectory: true),
|
||
filesDir.appendingPathComponent("files/outgoing", isDirectory: true)
|
||
]
|
||
|
||
for dir in outgoingDirs {
|
||
if FileManager.default.fileExists(atPath: dir.path) {
|
||
try? FileManager.default.removeItem(at: dir)
|
||
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true, attributes: nil)
|
||
}
|
||
}
|
||
} catch {
|
||
SecureLogger.error("Failed to clear media files: \(error)", category: .session)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Message Management
|
||
|
||
private func addMessage(_ message: BitchatMessage) {
|
||
// Check for duplicates
|
||
guard !messages.contains(where: { $0.id == message.id }) else { return }
|
||
messages.append(message)
|
||
trimMessagesIfNeeded()
|
||
}
|
||
|
||
// Update encryption status in appropriate places, not during view updates
|
||
@MainActor
|
||
private func updateEncryptionStatus(for peerID: PeerID) {
|
||
let noiseService = meshService.getNoiseService()
|
||
|
||
if noiseService.hasEstablishedSession(with: peerID) {
|
||
peerEncryptionStatus[peerID] = encryptionStatus(for: peerID)
|
||
} else if noiseService.hasSession(with: peerID) {
|
||
peerEncryptionStatus[peerID] = .noiseHandshaking
|
||
} else {
|
||
peerEncryptionStatus[peerID] = Optional.none
|
||
}
|
||
|
||
// Invalidate cache when encryption status changes
|
||
invalidateEncryptionCache(for: peerID)
|
||
|
||
// UI will update automatically via @Published properties
|
||
}
|
||
|
||
// MARK: - Fingerprint Management
|
||
|
||
func showFingerprint(for peerID: PeerID) {
|
||
showingFingerprintFor = peerID
|
||
}
|
||
|
||
// MARK: - Peer Lookup Helpers
|
||
|
||
func getPeer(byID peerID: PeerID) -> BitchatPeer? {
|
||
return peerIndex[peerID]
|
||
}
|
||
|
||
@MainActor
|
||
func getFingerprint(for peerID: PeerID) -> String? {
|
||
return unifiedPeerService.getFingerprint(for: peerID)
|
||
}
|
||
|
||
/// Check if fingerprint is verified using our persisted data
|
||
@MainActor
|
||
private func encryptionStatus(for peerID: PeerID) -> EncryptionStatus {
|
||
if let fp = getFingerprint(for: peerID), verifiedFingerprints.contains(fp) {
|
||
return .noiseVerified
|
||
} else {
|
||
return .noiseSecured
|
||
}
|
||
}
|
||
|
||
/// Helper to resolve nickname for a peer ID through various sources
|
||
@MainActor
|
||
private func resolveNickname(for peerID: PeerID) -> String {
|
||
// Guard against empty or very short peer IDs
|
||
guard !peerID.isEmpty else {
|
||
return "unknown"
|
||
}
|
||
|
||
// Check if this might already be a nickname (not a hex peer ID)
|
||
// Peer IDs are hex strings, so they only contain 0-9 and a-f
|
||
if !peerID.isHex {
|
||
// If it's already a nickname, just return it
|
||
return peerID.id
|
||
}
|
||
|
||
// First try direct peer nicknames from mesh service
|
||
let peerNicknames = meshService.getPeerNicknames()
|
||
if let nickname = peerNicknames[peerID] {
|
||
return nickname
|
||
}
|
||
|
||
// Try to resolve through fingerprint and social identity
|
||
if let fingerprint = getFingerprint(for: peerID) {
|
||
if let identity = identityManager.getSocialIdentity(for: fingerprint) {
|
||
// Prefer local petname if set
|
||
if let petname = identity.localPetname {
|
||
return petname
|
||
}
|
||
// Otherwise use their claimed nickname
|
||
return identity.claimedNickname
|
||
}
|
||
}
|
||
|
||
// Use anonymous with shortened peer ID
|
||
// Ensure we have at least 4 characters for the prefix
|
||
let prefixLength = min(4, peerID.id.count)
|
||
let prefix = String(peerID.id.prefix(prefixLength))
|
||
|
||
// Avoid "anonanon" by checking if ID already starts with "anon"
|
||
if prefix.starts(with: "anon") {
|
||
return "peer\(prefix)"
|
||
}
|
||
return "anon\(prefix)"
|
||
}
|
||
|
||
func getMyFingerprint() -> String {
|
||
let fingerprint = meshService.getNoiseService().getIdentityFingerprint()
|
||
return fingerprint
|
||
}
|
||
|
||
@MainActor
|
||
func verifyFingerprint(for peerID: PeerID) {
|
||
guard let fingerprint = getFingerprint(for: peerID) else { return }
|
||
|
||
// Update secure storage with verified status
|
||
identityManager.setVerified(fingerprint: fingerprint, verified: true)
|
||
saveIdentityState()
|
||
|
||
// Update local set for UI
|
||
verifiedFingerprints.insert(fingerprint)
|
||
|
||
// Update encryption status after verification
|
||
updateEncryptionStatus(for: peerID)
|
||
}
|
||
|
||
@MainActor
|
||
func unverifyFingerprint(for peerID: PeerID) {
|
||
guard let fingerprint = getFingerprint(for: peerID) else { return }
|
||
identityManager.setVerified(fingerprint: fingerprint, verified: false)
|
||
saveIdentityState()
|
||
verifiedFingerprints.remove(fingerprint)
|
||
updateEncryptionStatus(for: peerID)
|
||
}
|
||
|
||
@MainActor
|
||
func loadVerifiedFingerprints() {
|
||
// Load verified fingerprints directly from secure storage
|
||
verifiedFingerprints = identityManager.getVerifiedFingerprints()
|
||
// Log snapshot for debugging persistence
|
||
let sample = Array(verifiedFingerprints.prefix(TransportConfig.uiFingerprintSampleCount)).map { $0.prefix(8) }.joined(separator: ", ")
|
||
SecureLogger.info("🔐 Verified loaded: \(verifiedFingerprints.count) [\(sample)]", category: .security)
|
||
// Also log any offline favorites and whether we consider them verified
|
||
let offlineFavorites = unifiedPeerService.favorites.filter { !$0.isConnected }
|
||
for fav in offlineFavorites {
|
||
let fp = unifiedPeerService.getFingerprint(for: fav.peerID)
|
||
let isVer = fp.flatMap { verifiedFingerprints.contains($0) } ?? false
|
||
let fpShort = fp?.prefix(8) ?? "nil"
|
||
SecureLogger.info("⭐️ Favorite offline: \(fav.nickname) fp=\(fpShort) verified=\(isVer)", category: .security)
|
||
}
|
||
// Invalidate cached encryption statuses so offline favorites can show verified badges immediately
|
||
invalidateEncryptionCache()
|
||
// Trigger UI refresh of peer list
|
||
objectWillChange.send()
|
||
}
|
||
|
||
private func setupNoiseCallbacks() {
|
||
let noiseService = meshService.getNoiseService()
|
||
|
||
// Set up authentication callback
|
||
noiseService.onPeerAuthenticated = { [weak self] peerID, fingerprint in
|
||
DispatchQueue.main.async {
|
||
guard let self = self else { return }
|
||
|
||
SecureLogger.debug("🔐 Authenticated: \(peerID)", category: .security)
|
||
|
||
// Update encryption status
|
||
if self.verifiedFingerprints.contains(fingerprint) {
|
||
self.peerEncryptionStatus[peerID] = .noiseVerified
|
||
// Encryption: noiseVerified
|
||
} else {
|
||
self.peerEncryptionStatus[peerID] = .noiseSecured
|
||
// Encryption: noiseSecured
|
||
}
|
||
|
||
// Invalidate cache when encryption status changes
|
||
self.invalidateEncryptionCache(for: peerID)
|
||
|
||
// Cache shortID -> full Noise key mapping as soon as session authenticates
|
||
if self.shortIDToNoiseKey[peerID] == nil,
|
||
let keyData = self.meshService.getNoiseService().getPeerPublicKeyData(peerID) {
|
||
let stable = PeerID(hexData: keyData)
|
||
self.shortIDToNoiseKey[peerID] = stable
|
||
SecureLogger.debug("🗺️ Mapped short peerID to Noise key for header continuity: \(peerID) -> \(stable.id.prefix(8))…", category: .session)
|
||
}
|
||
|
||
// If a QR verification is pending but not sent yet, send it now that session is authenticated
|
||
if var pending = self.pendingQRVerifications[peerID], pending.sent == false {
|
||
self.meshService.sendVerifyChallenge(to: peerID, noiseKeyHex: pending.noiseKeyHex, nonceA: pending.nonceA)
|
||
pending.sent = true
|
||
self.pendingQRVerifications[peerID] = pending
|
||
SecureLogger.debug("📤 Sent deferred verify challenge to \(peerID) after handshake", category: .security)
|
||
}
|
||
|
||
// Schedule UI update
|
||
// UI will update automatically
|
||
}
|
||
}
|
||
|
||
// Set up handshake required callback
|
||
noiseService.onHandshakeRequired = { [weak self] peerID in
|
||
DispatchQueue.main.async {
|
||
guard let self = self else { return }
|
||
self.peerEncryptionStatus[peerID] = .noiseHandshaking
|
||
|
||
// Invalidate cache when encryption status changes
|
||
self.invalidateEncryptionCache(for: peerID)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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
|
||
private 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
|
||
|
||
func didReceiveMessage(_ message: BitchatMessage) {
|
||
Task { @MainActor in
|
||
// Early validation
|
||
guard !isMessageBlocked(message) else { return }
|
||
guard !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || message.isPrivate else { return }
|
||
|
||
// Route to appropriate handler
|
||
if message.isPrivate {
|
||
handlePrivateMessage(message)
|
||
} else {
|
||
handlePublicMessage(message)
|
||
}
|
||
|
||
// Post-processing
|
||
checkForMentions(message)
|
||
sendHapticFeedback(for: message)
|
||
}
|
||
}
|
||
|
||
// Low-level BLE events
|
||
func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) {
|
||
Task { @MainActor in
|
||
switch type {
|
||
case .privateMessage:
|
||
guard let pm = PrivateMessagePacket.decode(from: payload) else { return }
|
||
let senderName = unifiedPeerService.getPeer(by: peerID)?.nickname ?? "Unknown"
|
||
let pmMentions = parseMentions(from: pm.content)
|
||
let msg = BitchatMessage(
|
||
id: pm.messageID,
|
||
sender: senderName,
|
||
content: pm.content,
|
||
timestamp: timestamp,
|
||
isRelay: false,
|
||
originalSender: nil,
|
||
isPrivate: true,
|
||
recipientNickname: nickname,
|
||
senderPeerID: peerID,
|
||
mentions: pmMentions.isEmpty ? nil : pmMentions
|
||
)
|
||
handlePrivateMessage(msg)
|
||
// Send delivery ACK back over BLE
|
||
meshService.sendDeliveryAck(for: pm.messageID, to: peerID)
|
||
|
||
case .delivered:
|
||
guard let messageID = String(data: payload, encoding: .utf8) else { return }
|
||
if let name = unifiedPeerService.getPeer(by: peerID)?.nickname {
|
||
if let messages = privateChats[peerID], let idx = messages.firstIndex(where: { $0.id == messageID }) {
|
||
privateChats[peerID]?[idx].deliveryStatus = .delivered(to: name, at: Date())
|
||
objectWillChange.send()
|
||
}
|
||
}
|
||
|
||
case .readReceipt:
|
||
guard let messageID = String(data: payload, encoding: .utf8) else { return }
|
||
if let name = unifiedPeerService.getPeer(by: peerID)?.nickname {
|
||
if let messages = privateChats[peerID], let idx = messages.firstIndex(where: { $0.id == messageID }) {
|
||
privateChats[peerID]?[idx].deliveryStatus = .read(by: name, at: Date())
|
||
objectWillChange.send()
|
||
}
|
||
}
|
||
case .verifyChallenge:
|
||
// Parse and respond
|
||
guard let tlv = VerificationService.shared.parseVerifyChallenge(payload) else { return }
|
||
// Ensure intended for our noise key
|
||
let myNoiseHex = meshService.getNoiseService().getStaticPublicKeyData().hexEncodedString().lowercased()
|
||
guard tlv.noiseKeyHex.lowercased() == myNoiseHex else { return }
|
||
// Deduplicate: ignore if we've already responded to this nonce for this peer
|
||
if let last = lastVerifyNonceByPeer[peerID], last == tlv.nonceA { return }
|
||
lastVerifyNonceByPeer[peerID] = tlv.nonceA
|
||
// Record inbound challenge time keyed by stable fingerprint if available
|
||
if let fp = getFingerprint(for: peerID) {
|
||
lastInboundVerifyChallengeAt[fp] = Date()
|
||
// If we've already verified this fingerprint locally, treat this as mutual and toast immediately (responder side)
|
||
if verifiedFingerprints.contains(fp) {
|
||
let now = Date()
|
||
let last = lastMutualToastAt[fp] ?? .distantPast
|
||
if now.timeIntervalSince(last) > 60 { // 1-minute throttle
|
||
lastMutualToastAt[fp] = now
|
||
let name = unifiedPeerService.getPeer(by: peerID)?.nickname ?? resolveNickname(for: peerID)
|
||
NotificationService.shared.sendLocalNotification(
|
||
title: "Mutual verification",
|
||
body: "You and \(name) verified each other",
|
||
identifier: "verify-mutual-\(peerID)-\(UUID().uuidString)"
|
||
)
|
||
}
|
||
}
|
||
}
|
||
meshService.sendVerifyResponse(to: peerID, noiseKeyHex: tlv.noiseKeyHex, nonceA: tlv.nonceA)
|
||
// Silent response: no toast needed on responder
|
||
case .verifyResponse:
|
||
guard let resp = VerificationService.shared.parseVerifyResponse(payload) else { return }
|
||
// Check pending for this peer
|
||
guard let pending = pendingQRVerifications[peerID] else { return }
|
||
guard resp.noiseKeyHex.lowercased() == pending.noiseKeyHex.lowercased(), resp.nonceA == pending.nonceA else { return }
|
||
// Verify signature with expected sign key
|
||
let ok = VerificationService.shared.verifyResponseSignature(noiseKeyHex: resp.noiseKeyHex, nonceA: resp.nonceA, signature: resp.signature, signerPublicKeyHex: pending.signKeyHex)
|
||
if ok {
|
||
pendingQRVerifications.removeValue(forKey: peerID)
|
||
if let fp = getFingerprint(for: peerID) {
|
||
let short = fp.prefix(8)
|
||
SecureLogger.info("🔐 Marking verified fingerprint: \(short)", category: .security)
|
||
identityManager.setVerified(fingerprint: fp, verified: true)
|
||
saveIdentityState()
|
||
verifiedFingerprints.insert(fp)
|
||
let name = unifiedPeerService.getPeer(by: peerID)?.nickname ?? resolveNickname(for: peerID)
|
||
NotificationService.shared.sendLocalNotification(
|
||
title: "Verified",
|
||
body: "You verified \(name)",
|
||
identifier: "verify-success-\(peerID)-\(UUID().uuidString)"
|
||
)
|
||
// If we also recently responded to their challenge, flag mutual and toast (initiator side)
|
||
if let t = lastInboundVerifyChallengeAt[fp], Date().timeIntervalSince(t) < 600 {
|
||
let now = Date()
|
||
let lastToast = lastMutualToastAt[fp] ?? .distantPast
|
||
if now.timeIntervalSince(lastToast) > 60 {
|
||
lastMutualToastAt[fp] = now
|
||
NotificationService.shared.sendLocalNotification(
|
||
title: "Mutual verification",
|
||
body: "You and \(name) verified each other",
|
||
identifier: "verify-mutual-\(peerID)-\(UUID().uuidString)"
|
||
)
|
||
}
|
||
}
|
||
updateEncryptionStatus(for: peerID)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {
|
||
Task { @MainActor in
|
||
let normalized = content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
let publicMentions = parseMentions(from: normalized)
|
||
let msg = BitchatMessage(
|
||
id: messageID,
|
||
sender: nickname,
|
||
content: normalized,
|
||
timestamp: timestamp,
|
||
isRelay: false,
|
||
originalSender: nil,
|
||
isPrivate: false,
|
||
recipientNickname: nil,
|
||
senderPeerID: peerID,
|
||
mentions: publicMentions.isEmpty ? nil : publicMentions
|
||
)
|
||
handlePublicMessage(msg)
|
||
checkForMentions(msg)
|
||
sendHapticFeedback(for: msg)
|
||
}
|
||
}
|
||
|
||
// MARK: - QR Verification API
|
||
@MainActor
|
||
func beginQRVerification(with qr: VerificationService.VerificationQR) -> Bool {
|
||
// Find a matching peer by Noise key
|
||
let targetNoise = qr.noiseKeyHex.lowercased()
|
||
guard let peer = unifiedPeerService.peers.first(where: { $0.noisePublicKey.hexEncodedString().lowercased() == targetNoise }) else {
|
||
return false
|
||
}
|
||
let peerID = peer.peerID
|
||
// If we already have a pending verification with this peer, don't send another
|
||
if pendingQRVerifications[peerID] != nil {
|
||
return true
|
||
}
|
||
// Generate nonceA
|
||
var nonce = Data(count: 16)
|
||
_ = nonce.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, 16, $0.baseAddress!) }
|
||
var pending = PendingVerification(noiseKeyHex: qr.noiseKeyHex, signKeyHex: qr.signKeyHex, nonceA: nonce, startedAt: Date(), sent: false)
|
||
pendingQRVerifications[peerID] = pending
|
||
// If Noise session is established, send immediately; otherwise trigger handshake and send on auth
|
||
let noise = meshService.getNoiseService()
|
||
if noise.hasEstablishedSession(with: peerID) {
|
||
meshService.sendVerifyChallenge(to: peerID, noiseKeyHex: qr.noiseKeyHex, nonceA: nonce)
|
||
pending.sent = true
|
||
pendingQRVerifications[peerID] = pending
|
||
} else {
|
||
meshService.triggerHandshake(with: peerID)
|
||
}
|
||
return true
|
||
}
|
||
|
||
// 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) {
|
||
SecureLogger.debug("🤝 Peer connected: \(peerID)", category: .session)
|
||
|
||
// Handle all main actor work async
|
||
Task { @MainActor in
|
||
isConnected = true
|
||
|
||
// Register ephemeral session with identity manager
|
||
identityManager.registerEphemeralSession(peerID: peerID, handshakeState: .none)
|
||
|
||
// Intentionally do not resend favorites on reconnect.
|
||
// We only send our npub when a favorite is toggled on, or if our npub changes.
|
||
|
||
// Force UI refresh
|
||
objectWillChange.send()
|
||
|
||
// Cache mapping to full Noise key for session continuity on disconnect
|
||
if let peer = unifiedPeerService.getPeer(by: peerID) {
|
||
let noiseKeyHex = PeerID(hexData: peer.noisePublicKey)
|
||
shortIDToNoiseKey[peerID] = noiseKeyHex
|
||
}
|
||
|
||
// Flush any queued messages for this peer via router
|
||
messageRouter.flushOutbox(for: peerID)
|
||
}
|
||
}
|
||
|
||
func didDisconnectFromPeer(_ peerID: PeerID) {
|
||
SecureLogger.debug("👋 Peer disconnected: \(peerID)", category: .session)
|
||
|
||
// Remove ephemeral session from identity manager
|
||
identityManager.removeEphemeralSession(peerID: peerID)
|
||
|
||
// If the open PM is tied to this short peer ID, switch UI context to the full Noise key (offline favorite)
|
||
var derivedStableKeyHex = shortIDToNoiseKey[peerID]
|
||
if derivedStableKeyHex == nil,
|
||
let key = meshService.getNoiseService().getPeerPublicKeyData(peerID) {
|
||
derivedStableKeyHex = PeerID(hexData: key)
|
||
shortIDToNoiseKey[peerID] = derivedStableKeyHex
|
||
}
|
||
|
||
if let current = selectedPrivateChatPeer, current == peerID, let stableKeyHex = derivedStableKeyHex {
|
||
// Migrate messages view context to stable key so header shows favorite + Nostr globe
|
||
if let messages = privateChats[peerID] {
|
||
if privateChats[stableKeyHex] == nil { privateChats[stableKeyHex] = [] }
|
||
let existing = Set(privateChats[stableKeyHex]!.map { $0.id })
|
||
for msg in messages where !existing.contains(msg.id) {
|
||
let updated = BitchatMessage(
|
||
id: msg.id,
|
||
sender: msg.sender,
|
||
content: msg.content,
|
||
timestamp: msg.timestamp,
|
||
isRelay: msg.isRelay,
|
||
originalSender: msg.originalSender,
|
||
isPrivate: msg.isPrivate,
|
||
recipientNickname: msg.recipientNickname,
|
||
senderPeerID: msg.senderPeerID == meshService.myPeerID ? meshService.myPeerID : stableKeyHex,
|
||
mentions: msg.mentions,
|
||
deliveryStatus: msg.deliveryStatus
|
||
)
|
||
privateChats[stableKeyHex]?.append(updated)
|
||
}
|
||
privateChats[stableKeyHex]?.sort { $0.timestamp < $1.timestamp }
|
||
privateChats.removeValue(forKey: peerID)
|
||
}
|
||
if unreadPrivateMessages.contains(peerID) {
|
||
unreadPrivateMessages.remove(peerID)
|
||
unreadPrivateMessages.insert(stableKeyHex)
|
||
}
|
||
selectedPrivateChatPeer = stableKeyHex
|
||
objectWillChange.send()
|
||
}
|
||
|
||
// Update peer list immediately and force UI refresh
|
||
DispatchQueue.main.async { [weak self] in
|
||
// UnifiedPeerService updates automatically via subscriptions
|
||
self?.objectWillChange.send()
|
||
}
|
||
|
||
// Clear sent read receipts for this peer since they'll need to be resent after reconnection
|
||
// Only clear receipts for messages from this specific peer
|
||
if let messages = privateChats[peerID] {
|
||
for message in messages {
|
||
// Remove read receipts for messages FROM this peer (not TO this peer)
|
||
if message.senderPeerID == peerID {
|
||
sentReadReceipts.remove(message.id)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
func didUpdatePeerList(_ peers: [PeerID]) {
|
||
// UI updates must run on the main thread.
|
||
// The delegate callback is not guaranteed to be on the main thread.
|
||
DispatchQueue.main.async {
|
||
// Update through peer manager
|
||
// UnifiedPeerService updates automatically via subscriptions
|
||
self.isConnected = !peers.isEmpty
|
||
|
||
// Clean up stale unread peer IDs whenever peer list updates
|
||
self.cleanupStaleUnreadPeerIDs()
|
||
|
||
// Smart notification logic for "bitchatters nearby"
|
||
let meshPeers = peers.filter { peerID in
|
||
self.meshService.isPeerConnected(peerID) || self.meshService.isPeerReachable(peerID)
|
||
}
|
||
let meshPeerSet = Set(meshPeers)
|
||
|
||
if meshPeerSet.isEmpty {
|
||
self.scheduleNetworkEmptyTimer()
|
||
} else {
|
||
self.invalidateNetworkEmptyTimer()
|
||
// Trim out peers we no longer observe before comparing for new arrivals
|
||
self.recentlySeenPeers.formIntersection(meshPeerSet)
|
||
let newPeers = meshPeerSet.subtracting(self.recentlySeenPeers)
|
||
|
||
if !newPeers.isEmpty {
|
||
self.lastNetworkNotificationTime = Date()
|
||
self.recentlySeenPeers.formUnion(newPeers)
|
||
NotificationService.shared.sendNetworkAvailableNotification(peerCount: meshPeers.count)
|
||
SecureLogger.info(
|
||
"👥 Sent bitchatters nearby notification for \(meshPeers.count) mesh peers (new: \(newPeers.count))",
|
||
category: .session
|
||
)
|
||
self.scheduleNetworkResetTimer()
|
||
}
|
||
}
|
||
|
||
// Register ephemeral sessions for all connected peers
|
||
for peerID in peers {
|
||
self.identityManager.registerEphemeralSession(peerID: peerID, handshakeState: .none)
|
||
}
|
||
|
||
// Schedule UI refresh to ensure offline favorites are shown
|
||
// UI will update automatically
|
||
|
||
// Update encryption status for all peers
|
||
self.updateEncryptionStatusForPeers()
|
||
|
||
// Schedule UI update for peer list change
|
||
// UI will update automatically
|
||
|
||
// Check if we need to update private chat peer after reconnection
|
||
if self.selectedPrivateChatFingerprint != nil {
|
||
self.updatePrivateChatPeerIfNeeded()
|
||
}
|
||
|
||
// Don't end private chat when peer temporarily disconnects
|
||
// The fingerprint tracking will allow us to reconnect when they come back
|
||
}
|
||
}
|
||
|
||
// MARK: - Helper Methods
|
||
|
||
/// Clean up stale unread peer IDs that no longer exist in the peer list
|
||
@MainActor
|
||
private func cleanupStaleUnreadPeerIDs() {
|
||
let currentPeerIDs = Set(unifiedPeerService.peers.map { $0.peerID })
|
||
let staleIDs = unreadPrivateMessages.subtracting(currentPeerIDs)
|
||
|
||
if !staleIDs.isEmpty {
|
||
var idsToRemove: [PeerID] = []
|
||
for staleID in staleIDs {
|
||
// Don't remove temporary Nostr peer IDs that have messages
|
||
if staleID.isGeoDM {
|
||
// Check if we have messages from this temporary peer
|
||
if let messages = privateChats[staleID], !messages.isEmpty {
|
||
// Keep this ID - it has messages
|
||
continue
|
||
}
|
||
}
|
||
|
||
// Don't remove stable Noise key hexes (64 char hex strings) that have messages
|
||
// These are used for Nostr messages when peer is offline
|
||
if staleID.isNoiseKeyHex {
|
||
if let messages = privateChats[staleID], !messages.isEmpty {
|
||
// Keep this ID - it's a stable key with messages
|
||
continue
|
||
}
|
||
}
|
||
|
||
// Remove this stale ID
|
||
idsToRemove.append(staleID)
|
||
unreadPrivateMessages.remove(staleID)
|
||
}
|
||
|
||
if !idsToRemove.isEmpty {
|
||
SecureLogger.debug("🧹 Cleaned up \(idsToRemove.count) stale unread peer IDs", category: .session)
|
||
}
|
||
}
|
||
|
||
// Also clean up old sentReadReceipts to prevent unlimited growth
|
||
// Keep only receipts from messages we still have
|
||
cleanupOldReadReceipts()
|
||
}
|
||
|
||
@MainActor
|
||
private func scheduleNetworkResetTimer() {
|
||
networkResetTimer?.invalidate()
|
||
networkResetTimer = Timer.scheduledTimer(
|
||
timeInterval: networkResetGraceSeconds,
|
||
target: self,
|
||
selector: #selector(onNetworkResetTimerFired(_:)),
|
||
userInfo: nil,
|
||
repeats: false
|
||
)
|
||
}
|
||
|
||
@MainActor
|
||
@objc private func onNetworkResetTimerFired(_ timer: Timer) {
|
||
let activeMeshPeers = meshService
|
||
.currentPeerSnapshots()
|
||
.filter { snapshot in
|
||
snapshot.isConnected || meshService.isPeerReachable(snapshot.peerID)
|
||
}
|
||
if activeMeshPeers.isEmpty {
|
||
recentlySeenPeers.removeAll()
|
||
SecureLogger.debug("⏱️ Network notification window reset after quiet period", category: .session)
|
||
} else {
|
||
SecureLogger.debug("⏱️ Skipped network notification reset; still seeing \(activeMeshPeers.count) mesh peers", category: .session)
|
||
}
|
||
networkResetTimer = nil
|
||
}
|
||
|
||
@MainActor
|
||
private func scheduleNetworkEmptyTimer() {
|
||
guard networkEmptyTimer == nil else { return }
|
||
networkEmptyTimer = Timer.scheduledTimer(
|
||
timeInterval: TransportConfig.uiMeshEmptyConfirmationSeconds,
|
||
target: self,
|
||
selector: #selector(onNetworkEmptyTimerFired(_:)),
|
||
userInfo: nil,
|
||
repeats: false
|
||
)
|
||
SecureLogger.debug("⏳ Mesh empty — waiting before resetting notification state", category: .session)
|
||
}
|
||
|
||
@MainActor
|
||
private func invalidateNetworkEmptyTimer() {
|
||
if networkEmptyTimer != nil {
|
||
networkEmptyTimer?.invalidate()
|
||
networkEmptyTimer = nil
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
@objc private func onNetworkEmptyTimerFired(_ timer: Timer) {
|
||
let activeMeshPeers = meshService
|
||
.currentPeerSnapshots()
|
||
.filter { snapshot in
|
||
snapshot.isConnected || meshService.isPeerReachable(snapshot.peerID)
|
||
}
|
||
if activeMeshPeers.isEmpty {
|
||
recentlySeenPeers.removeAll()
|
||
SecureLogger.debug("⏳ Mesh empty — notification state reset after confirmation", category: .session)
|
||
} else {
|
||
SecureLogger.debug("⏳ Mesh empty timer cancelled; \(activeMeshPeers.count) mesh peers detected again", category: .session)
|
||
}
|
||
networkEmptyTimer = nil
|
||
}
|
||
|
||
private func cleanupOldReadReceipts() {
|
||
// Skip cleanup during startup phase or if privateChats is empty
|
||
// This prevents removing valid receipts before messages are loaded
|
||
if isStartupPhase || privateChats.isEmpty {
|
||
return
|
||
}
|
||
|
||
// Build set of all message IDs we still have
|
||
var validMessageIDs = Set<String>()
|
||
for (_, messages) in privateChats {
|
||
for message in messages {
|
||
validMessageIDs.insert(message.id)
|
||
}
|
||
}
|
||
|
||
// Remove receipts for messages we no longer have
|
||
let oldCount = sentReadReceipts.count
|
||
sentReadReceipts = sentReadReceipts.intersection(validMessageIDs)
|
||
|
||
let removedCount = oldCount - sentReadReceipts.count
|
||
if removedCount > 0 {
|
||
SecureLogger.debug("🧹 Cleaned up \(removedCount) old read receipts", category: .session)
|
||
}
|
||
}
|
||
|
||
func parseMentions(from content: String) -> [String] {
|
||
// Allow optional disambiguation suffix '#abcd' for duplicate nicknames
|
||
let regex = Patterns.mention
|
||
let nsContent = content as NSString
|
||
let nsLen = nsContent.length
|
||
let matches = regex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen))
|
||
|
||
var mentions: [String] = []
|
||
let peerNicknames = meshService.getPeerNicknames()
|
||
// Compose the valid mention tokens based on current peers (already suffixed where needed)
|
||
var validTokens = Set(peerNicknames.values)
|
||
// Always allow mentioning self by base nickname and suffixed disambiguator
|
||
validTokens.insert(nickname)
|
||
let selfSuffixToken = nickname + "#" + String(meshService.myPeerID.id.prefix(4))
|
||
validTokens.insert(selfSuffixToken)
|
||
|
||
for match in matches {
|
||
if let range = Range(match.range(at: 1), in: content) {
|
||
let mentionedName = String(content[range])
|
||
// Only include if it's a current valid token (base or suffixed)
|
||
if validTokens.contains(mentionedName) {
|
||
mentions.append(mentionedName)
|
||
}
|
||
}
|
||
}
|
||
|
||
return Array(Set(mentions)) // Remove duplicates
|
||
}
|
||
|
||
func isFavorite(fingerprint: String) -> Bool {
|
||
return identityManager.isFavorite(fingerprint: fingerprint)
|
||
}
|
||
|
||
// MARK: - Delivery Tracking
|
||
|
||
func didReceiveReadReceipt(_ receipt: ReadReceipt) {
|
||
// Find the message and update its read status
|
||
updateMessageDeliveryStatus(receipt.originalMessageID, status: .read(by: receipt.readerNickname, at: receipt.timestamp))
|
||
}
|
||
|
||
func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) {
|
||
updateMessageDeliveryStatus(messageID, status: status)
|
||
}
|
||
|
||
func updateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) {
|
||
|
||
// Helper function to check if we should skip this update
|
||
func shouldSkipUpdate(currentStatus: DeliveryStatus?, newStatus: DeliveryStatus) -> Bool {
|
||
guard let current = currentStatus else { return false }
|
||
|
||
// Don't downgrade from read to delivered
|
||
switch (current, newStatus) {
|
||
case (.read, .delivered):
|
||
return true
|
||
case (.read, .sent):
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
// Update in main messages
|
||
if let index = messages.firstIndex(where: { $0.id == messageID }) {
|
||
let currentStatus = messages[index].deliveryStatus
|
||
if !shouldSkipUpdate(currentStatus: currentStatus, newStatus: status) {
|
||
messages[index].deliveryStatus = status
|
||
}
|
||
}
|
||
|
||
// Update in private chats
|
||
for (peerID, chatMessages) in privateChats {
|
||
guard let index = chatMessages.firstIndex(where: { $0.id == messageID }) else { continue }
|
||
|
||
let currentStatus = chatMessages[index].deliveryStatus
|
||
guard !shouldSkipUpdate(currentStatus: currentStatus, newStatus: status) else { continue }
|
||
|
||
// Update delivery status directly (BitchatMessage is a class/reference type)
|
||
privateChats[peerID]?[index].deliveryStatus = status
|
||
}
|
||
|
||
// Trigger UI update for delivery status change
|
||
DispatchQueue.main.async { [weak self] in
|
||
self?.objectWillChange.send()
|
||
}
|
||
|
||
}
|
||
|
||
// MARK: - Helper for System Messages
|
||
func addSystemMessage(_ content: String, timestamp: Date = Date()) {
|
||
let systemMessage = BitchatMessage(
|
||
sender: "system",
|
||
content: content,
|
||
timestamp: timestamp,
|
||
isRelay: false
|
||
)
|
||
messages.append(systemMessage)
|
||
}
|
||
|
||
/// 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) {
|
||
let systemMessage = BitchatMessage(
|
||
sender: "system",
|
||
content: content,
|
||
timestamp: Date(),
|
||
isRelay: false
|
||
)
|
||
timelineStore.append(systemMessage, to: .mesh)
|
||
refreshVisibleMessages()
|
||
trimMessagesIfNeeded()
|
||
objectWillChange.send()
|
||
}
|
||
|
||
/// 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) {
|
||
let systemMessage = BitchatMessage(
|
||
sender: "system",
|
||
content: content,
|
||
timestamp: Date(),
|
||
isRelay: false
|
||
)
|
||
timelineStore.append(systemMessage, to: activeChannel)
|
||
refreshVisibleMessages(from: activeChannel)
|
||
// Track the content key so relayed copies of the same system-style message are ignored
|
||
let contentKey = deduplicationService.normalizedContentKey(systemMessage.content)
|
||
deduplicationService.recordContentKey(contentKey, timestamp: systemMessage.timestamp)
|
||
trimMessagesIfNeeded()
|
||
objectWillChange.send()
|
||
}
|
||
|
||
/// Add a system message only if viewing a geohash location channel (never post to mesh).
|
||
@MainActor
|
||
func addGeohashOnlySystemMessage(_ content: String) {
|
||
if case .location = activeChannel {
|
||
addPublicSystemMessage(content)
|
||
} else {
|
||
// Not on a location channel yet: queue to show when user switches
|
||
timelineStore.queueGeohashSystemMessage(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) {
|
||
if case .location(let ch) = activeChannel {
|
||
Task { @MainActor in
|
||
do {
|
||
let identity = try idBridge.deriveIdentity(forGeohash: ch.geohash)
|
||
let event = try NostrProtocol.createEphemeralGeohashEvent(
|
||
content: content,
|
||
geohash: ch.geohash,
|
||
senderIdentity: identity,
|
||
nickname: self.nickname,
|
||
teleported: LocationChannelManager.shared.teleported
|
||
)
|
||
let targetRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: ch.geohash, count: 5)
|
||
if targetRelays.isEmpty {
|
||
NostrRelayManager.shared.sendEvent(event)
|
||
} else {
|
||
NostrRelayManager.shared.sendEvent(event, to: targetRelays)
|
||
}
|
||
} catch {
|
||
SecureLogger.error("❌ Failed to send geohash raw message: \(error)", category: .session)
|
||
}
|
||
}
|
||
return
|
||
}
|
||
// Default: send over mesh
|
||
meshService.sendMessage(content,
|
||
mentions: [],
|
||
messageID: UUID().uuidString,
|
||
timestamp: Date())
|
||
}
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
// MARK: - Base64URL utils
|
||
static func base64URLDecode(_ s: String) -> Data? {
|
||
var str = s.replacingOccurrences(of: "-", with: "+")
|
||
.replacingOccurrences(of: "_", with: "/")
|
||
// Add padding if needed
|
||
let rem = str.count % 4
|
||
if rem > 0 { str.append(String(repeating: "=", count: 4 - rem)) }
|
||
return Data(base64Encoded: str)
|
||
}
|
||
|
||
//
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
/// Handle incoming public message
|
||
@MainActor
|
||
func handlePublicMessage(_ message: BitchatMessage) {
|
||
let finalMessage = processActionMessage(message)
|
||
|
||
// Drop if sender is blocked (covers geohash via Nostr pubkey mapping)
|
||
if isMessageBlocked(finalMessage) { return }
|
||
|
||
// Classify origin: geochat if senderPeerID starts with 'nostr:', else mesh (or system)
|
||
let isGeo = finalMessage.senderPeerID?.isGeoChat == true
|
||
|
||
// Apply per-sender and per-content rate limits (drop if exceeded)
|
||
// Treat action-style system messages (which carry a senderPeerID) the same as regular user messages
|
||
let shouldRateLimit = finalMessage.sender != "system" || finalMessage.senderPeerID != nil
|
||
if shouldRateLimit {
|
||
let senderKey = normalizedSenderKey(for: finalMessage)
|
||
let contentKey = deduplicationService.normalizedContentKey(finalMessage.content)
|
||
if !publicRateLimiter.allow(senderKey: senderKey, contentKey: contentKey) { return }
|
||
}
|
||
|
||
// Size cap: drop extremely large public messages early
|
||
if finalMessage.sender != "system" && finalMessage.content.count > 16000 { return }
|
||
|
||
// Persist mesh messages to mesh timeline always
|
||
if !isGeo && finalMessage.sender != "system" {
|
||
timelineStore.append(finalMessage, to: .mesh)
|
||
}
|
||
|
||
// Persist geochat messages to per-geohash timeline
|
||
if isGeo && finalMessage.sender != "system" {
|
||
if let gh = currentGeohash {
|
||
_ = timelineStore.appendIfAbsent(finalMessage, toGeohash: gh)
|
||
}
|
||
}
|
||
|
||
// Only add message to current timeline if it matches active channel or is system
|
||
let isSystem = finalMessage.sender == "system"
|
||
let channelMatches: Bool = {
|
||
switch activeChannel {
|
||
case .mesh: return !isGeo || isSystem
|
||
case .location: return isGeo || isSystem
|
||
}
|
||
}()
|
||
|
||
guard channelMatches else { return }
|
||
|
||
// Removed background nudge notification for generic "new chats!"
|
||
|
||
// Append via batching buffer (skip empty content) with simple dedup by ID
|
||
if !finalMessage.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||
if !messages.contains(where: { $0.id == finalMessage.id }) {
|
||
publicMessagePipeline.enqueue(finalMessage)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Check for mentions and send notifications
|
||
|
||
func checkForMentions(_ message: BitchatMessage) { // Determine our acceptable mention token. If any connected peer shares our nickname,
|
||
// require the disambiguated form '<nickname>#<peerIDprefix>' to trigger.
|
||
var myTokens: Set<String> = [nickname]
|
||
let meshPeers = meshService.getPeerNicknames()
|
||
let collisions = meshPeers.values.filter { $0.hasPrefix(nickname + "#") }
|
||
if !collisions.isEmpty {
|
||
let suffix = "#" + String(meshService.myPeerID.id.prefix(4))
|
||
myTokens = [nickname + suffix]
|
||
}
|
||
let isMentioned = (message.mentions?.contains { myTokens.contains($0) } ?? false)
|
||
|
||
if isMentioned && message.sender != nickname {
|
||
SecureLogger.info("🔔 Mention from \(message.sender)", category: .session)
|
||
NotificationService.shared.sendMentionNotification(from: message.sender, message: message.content)
|
||
}
|
||
}
|
||
|
||
/// Send haptic feedback for special messages (iOS only)
|
||
func sendHapticFeedback(for message: BitchatMessage) { #if os(iOS)
|
||
guard UIApplication.shared.applicationState == .active else { return }
|
||
|
||
// Build acceptable target tokens: base nickname and, if in a location channel, nickname with '#abcd'
|
||
var tokens: [String] = [nickname]
|
||
#if os(iOS)
|
||
switch activeChannel {
|
||
case .location(let ch):
|
||
if let id = try? idBridge.deriveIdentity(forGeohash: ch.geohash) {
|
||
let d = String(id.publicKeyHex.suffix(4))
|
||
tokens.append(nickname + "#" + d)
|
||
}
|
||
case .mesh:
|
||
break
|
||
}
|
||
#endif
|
||
|
||
let hugsMe = tokens.contains { message.content.contains("hugs \($0)") } || message.content.contains("hugs you")
|
||
let slapsMe = tokens.contains { message.content.contains("slaps \($0) around") } || message.content.contains("slaps you around")
|
||
|
||
let isHugForMe = message.content.contains("🫂") && hugsMe
|
||
let isSlapForMe = message.content.contains("🐟") && slapsMe
|
||
|
||
if isHugForMe && message.sender != nickname {
|
||
// Long warm haptic for hugs
|
||
let impactFeedback = UIImpactFeedbackGenerator(style: .medium)
|
||
impactFeedback.prepare()
|
||
|
||
for i in 0..<8 {
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + Double(i) * TransportConfig.uiBatchDispatchStaggerSeconds) {
|
||
impactFeedback.impactOccurred()
|
||
}
|
||
}
|
||
} else if isSlapForMe && message.sender != nickname {
|
||
// Sharp haptic for slaps
|
||
let impactFeedback = UIImpactFeedbackGenerator(style: .heavy)
|
||
impactFeedback.prepare()
|
||
impactFeedback.impactOccurred()
|
||
}
|
||
#endif
|
||
}
|
||
}
|
||
// End of ChatViewModel class
|
||
|
||
extension ChatViewModel: PublicMessagePipelineDelegate {
|
||
func pipelineCurrentMessages(_ pipeline: PublicMessagePipeline) -> [BitchatMessage] {
|
||
messages
|
||
}
|
||
|
||
func pipeline(_ pipeline: PublicMessagePipeline, setMessages messages: [BitchatMessage]) {
|
||
self.messages = messages
|
||
}
|
||
|
||
func pipeline(_ pipeline: PublicMessagePipeline, normalizeContent content: String) -> String {
|
||
deduplicationService.normalizedContentKey(content)
|
||
}
|
||
|
||
func pipeline(_ pipeline: PublicMessagePipeline, contentTimestampForKey key: String) -> Date? {
|
||
deduplicationService.contentTimestamp(forKey: key)
|
||
}
|
||
|
||
func pipeline(_ pipeline: PublicMessagePipeline, recordContentKey key: String, timestamp: Date) {
|
||
deduplicationService.recordContentKey(key, timestamp: timestamp)
|
||
}
|
||
|
||
func pipelineTrimMessages(_ pipeline: PublicMessagePipeline) {
|
||
trimMessagesIfNeeded()
|
||
}
|
||
|
||
func pipelinePrewarmMessage(_ pipeline: PublicMessagePipeline, message: BitchatMessage) {
|
||
_ = formatMessageAsText(message, colorScheme: currentColorScheme)
|
||
}
|
||
|
||
func pipelineSetBatchingState(_ pipeline: PublicMessagePipeline, isBatching: Bool) {
|
||
isBatchingPublic = isBatching
|
||
}
|
||
}
|