Files
bitchat/bitchat/ViewModels/ChatViewModel.swift
T

6084 lines
275 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// ChatViewModel.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
///
/// # ChatViewModel
///
/// The central business logic and state management component for BitChat.
/// Coordinates between the UI layer and the networking/encryption services.
///
/// ## Overview
/// ChatViewModel implements the MVVM pattern, serving as the binding layer between
/// SwiftUI views and the underlying BitChat services. It manages:
/// - Message state and delivery
/// - Peer connections and presence
/// - Private chat sessions
/// - Command processing
/// - UI state like autocomplete and notifications
///
/// ## Architecture
/// The ViewModel acts as:
/// - **BitchatDelegate**: Receives messages and events from BLEService
/// - **State Manager**: Maintains all UI-relevant state with @Published properties
/// - **Command Processor**: Handles IRC-style commands (/msg, /who, etc.)
/// - **Message Router**: Directs messages to appropriate chats (public/private)
///
/// ## Key Features
///
/// ### Message Management
/// - Efficient message handling with duplicate detection
/// - Maintains separate public and private message queues
/// - Limits message history to prevent memory issues (1337 messages)
/// - Tracks delivery and read receipts
///
/// ### Privacy Features
/// - Ephemeral by design - no persistent message storage
/// - Supports verified fingerprints for secure communication
/// - Blocks messages from blocked users
/// - Emergency wipe capability (triple-tap)
///
/// ### User Experience
/// - Smart autocomplete for mentions and commands
/// - Unread message indicators
/// - Connection status tracking
/// - Favorite peers management
///
/// ## Command System
/// Supports IRC-style commands:
/// - `/nick <name>`: Change nickname
/// - `/msg <user> <message>`: Send private message
/// - `/who`: List connected peers
/// - `/slap <user>`: Fun interaction
/// - `/clear`: Clear message history
/// - `/help`: Show available commands
///
/// ## Performance Optimizations
/// - SwiftUI automatically optimizes UI updates
/// - Caches expensive computations (encryption status)
/// - Debounces autocomplete suggestions
/// - Efficient peer list management
///
/// ## Thread Safety
/// - All @Published properties trigger UI updates on main thread
/// - Background operations use proper queue management
/// - Atomic operations for critical state updates
///
/// ## Usage Example
/// ```swift
/// let viewModel = ChatViewModel()
/// viewModel.nickname = "Alice"
/// viewModel.startServices()
/// viewModel.sendMessage("Hello, mesh network!")
/// ```
///
import BitLogger
import Foundation
import SwiftUI
import CryptoKit
import Combine
import CommonCrypto
import CoreBluetooth
#if os(iOS)
import UIKit
#endif
/// 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 {
// Precompiled regexes and detectors reused across formatting
private enum Regexes {
static let hashtag: NSRegularExpression = {
try! NSRegularExpression(pattern: "#([a-zA-Z0-9_]+)", options: [])
}()
static let mention: NSRegularExpression = {
try! NSRegularExpression(pattern: "@([\\p{L}0-9_]+(?:#[a-fA-F0-9]{4})?)", options: [])
}()
static let cashu: NSRegularExpression = {
try! NSRegularExpression(pattern: "\\bcashu[AB][A-Za-z0-9._-]{40,}\\b", options: [])
}()
static let bolt11: NSRegularExpression = {
try! NSRegularExpression(pattern: "(?i)\\bln(bc|tb|bcrt)[0-9][a-z0-9]{50,}\\b", options: [])
}()
static let lnurl: NSRegularExpression = {
try! NSRegularExpression(pattern: "(?i)\\blnurl1[a-z0-9]{20,}\\b", options: [])
}()
static let lightningScheme: NSRegularExpression = {
try! NSRegularExpression(pattern: "(?i)\\blightning:[^\\s]+", options: [])
}()
static let linkDetector: NSDataDetector? = {
try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
}()
static let quickCashuPresence: NSRegularExpression = {
try! NSRegularExpression(pattern: "\\bcashu[AB][A-Za-z0-9._-]{40,}\\b", options: [])
}()
static let simplifyHTTPURL: NSRegularExpression = {
try! NSRegularExpression(pattern: "https?://[^\\s?#]+(?:[?#][^\\s]*)?", options: [.caseInsensitive])
}()
}
// MARK: - Spam resilience: token buckets
private struct TokenBucket {
var capacity: Double
var tokens: Double
var refillPerSec: Double
var lastRefill: Date
mutating func allow(cost: Double = 1.0, now: Date = Date()) -> Bool {
let dt = now.timeIntervalSince(lastRefill)
if dt > 0 {
tokens = min(capacity, tokens + dt * refillPerSec)
lastRefill = now
}
if tokens >= cost {
tokens -= cost
return true
}
return false
}
}
private var rateBucketsBySender: [String: TokenBucket] = [:]
private var rateBucketsByContent: [String: TokenBucket] = [:]
private let senderBucketCapacity: Double = TransportConfig.uiSenderRateBucketCapacity
private let senderBucketRefill: Double = TransportConfig.uiSenderRateBucketRefillPerSec // tokens per second
private let contentBucketCapacity: Double = TransportConfig.uiContentRateBucketCapacity
private let contentBucketRefill: Double = TransportConfig.uiContentRateBucketRefillPerSec // tokens per second
@MainActor
private func normalizedSenderKey(for message: BitchatMessage) -> String {
if let spid = message.senderPeerID {
if spid.hasPrefix("nostr:") || spid.hasPrefix("nostr_") {
let bare: String = {
if spid.hasPrefix("nostr:") { return String(spid.dropFirst(6)) }
if spid.hasPrefix("nostr_") { return String(spid.dropFirst(6)) }
return spid
}()
let full = (nostrKeyMapping[spid] ?? bare).lowercased()
return "nostr:" + full
} else if spid.count == 16, let full = getNoiseKeyForShortID(spid)?.lowercased() {
return "noise:" + full
} else {
return "mesh:" + spid.lowercased()
}
}
return "name:" + message.sender.lowercased()
}
private func normalizedContentKey(_ content: String) -> String {
// Lowercase, simplify URLs (strip query/fragment), collapse whitespace, bound length
let lowered = content.lowercased()
let ns = lowered as NSString
let range = NSRange(location: 0, length: ns.length)
var simplified = ""
var last = 0
for m in Regexes.simplifyHTTPURL.matches(in: lowered, options: [], range: range) {
if m.range.location > last {
simplified += ns.substring(with: NSRange(location: last, length: m.range.location - last))
}
let url = ns.substring(with: m.range)
if let q = url.firstIndex(where: { $0 == "?" || $0 == "#" }) {
simplified += String(url[..<q])
} else {
simplified += url
}
last = m.range.location + m.range.length
}
if last < ns.length { simplified += ns.substring(with: NSRange(location: last, length: ns.length - last)) }
let trimmed = simplified.trimmingCharacters(in: .whitespacesAndNewlines)
let collapsed = trimmed.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
let prefix = String(collapsed.prefix(TransportConfig.contentKeyPrefixLength))
// Fast djb2 hash
let h = prefix.djb2()
return String(format: "h:%016llx", h)
}
// Persistent recent content map (LRU) to speed near-duplicate checks
private var contentLRUMap: [String: Date] = [:]
private var contentLRUOrder: [String] = []
private let contentLRUCap = TransportConfig.contentLRUCap
private func recordContentKey(_ key: String, timestamp: Date) {
if contentLRUMap[key] == nil { contentLRUOrder.append(key) }
contentLRUMap[key] = timestamp
if contentLRUOrder.count > contentLRUCap {
let overflow = contentLRUOrder.count - contentLRUCap
for _ in 0..<overflow {
if let victim = contentLRUOrder.first {
contentLRUOrder.removeFirst()
contentLRUMap.removeValue(forKey: victim)
}
}
}
}
// 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 hasNotifiedNetworkAvailable = false
private var recentlySeenPeers: Set<String> = []
private var lastNetworkNotificationTime = Date.distantPast
private var networkResetTimer: 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 != "" {
meshService.setNickname(nickname)
}
}
}
// MARK: - Service Delegates
private let commandProcessor: CommandProcessor
private let messageRouter: MessageRouter
private let privateChatManager: PrivateChatManager
private let unifiedPeerService: UnifiedPeerService
private let autocompleteService: AutocompleteService
// Computed properties for compatibility
@MainActor
var connectedPeers: [String] { Array(unifiedPeerService.connectedPeerIDs) }
@Published var allPeers: [BitchatPeer] = []
var privateChats: [String: [BitchatMessage]] {
get { privateChatManager.privateChats }
set { privateChatManager.privateChats = newValue }
}
var selectedPrivateChatPeer: String? {
get { privateChatManager.selectedPeer }
set {
if let peer = newValue {
privateChatManager.startChat(with: peer)
} else {
privateChatManager.endChat()
}
}
}
var unreadPrivateMessages: Set<String> {
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)
}
}
//
private var peerIDToPublicKeyFingerprint: [String: 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: [String: String] = [:]
// Resolve full Noise key for a peer's short ID (used by UI header rendering)
@MainActor
func getNoiseKeyForShortID(_ shortPeerID: String) -> String? {
if let mapped = shortIDToNoiseKey[shortPeerID] { return mapped }
// Fallback: derive from active Noise session if available
if shortPeerID.count == 16,
let key = meshService.getNoiseService().getPeerPublicKeyData(shortPeerID) {
let stable = key.hexEncodedString()
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: String) -> String? {
// Check known peers for a noise key match
if let match = allPeers.first(where: { $0.noisePublicKey.hexEncodedString() == fullNoiseKeyHex }) {
return match.id
}
// Also search cache mapping
if let pair = shortIDToNoiseKey.first(where: { $0.value == fullNoiseKeyHex }) {
return pair.key
}
return nil
}
private var peerIndex: [String: 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 identityManager: SecureIdentityStateManagerProtocol
private var nostrRelayManager: NostrRelayManager?
// PeerManager replaced by UnifiedPeerService
private var processedNostrEvents = Set<String>() // Simple deduplication
private var processedNostrEventOrder: [String] = []
private let maxProcessedNostrEvents = TransportConfig.uiProcessedNostrEventsCap
private let userDefaults = UserDefaults.standard
private let keychain: KeychainManagerProtocol
private let nicknameKey = "bitchat.nickname"
// Location channel state (macOS supports manual geohash selection)
@Published private var activeChannel: ChannelID = .mesh
private var geoSubscriptionID: String? = nil
private var geoDmSubscriptionID: String? = nil
private var currentGeohash: String? = nil
private var geoNicknames: [String: String] = [:] // pubkeyHex(lowercased) -> nickname
// Show Tor status once per app launch
private var torStatusAnnounced = false
private var torProgressCancellable: AnyCancellable?
private var lastTorProgressAnnounced = -1
// Queue geohash-only system messages if user isn't on a location channel yet
private var pendingGeohashSystemMessages: [String] = []
// Track whether a Tor restart is pending so we only announce
// "tor restarted" after an actual restart, not the first launch.
private var torRestartPending: Bool = false
// Ensure we set up DM subscription only once per app session
private var nostrHandlersSetup: Bool = false
// MARK: - Caches
// Caches for expensive computations
private var encryptionStatusCache: [String: EncryptionStatus] = [:] // key: peerID
// 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: [String: EncryptionStatus] = [:] // peerID -> encryption status
@Published var verifiedFingerprints: Set<String> = [] // Set of verified fingerprints
@Published var showingFingerprintFor: String? = 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
// Messages are naturally ephemeral - no persistent storage
// Persist mesh public timeline across channel switches
private var meshTimeline: [BitchatMessage] = []
private let meshTimelineCap = TransportConfig.meshTimelineCap
// Persist per-geohash public timelines across switches
private var geoTimelines: [String: [BitchatMessage]] = [:] // geohash -> messages
private let geoTimelineCap = TransportConfig.geoTimelineCap
// Channel activity tracking for background nudges
private var lastPublicActivityAt: [String: Date] = [:] // channelKey -> last activity time
private var lastPublicActivityNotifyAt: [String: Date] = [:]
private let channelInactivityThreshold: TimeInterval = TransportConfig.uiChannelInactivityThresholdSeconds
// Geohash participants (per geohash: pubkey -> lastSeen)
private var geoParticipants: [String: [String: Date]] = [:]
@Published private(set) var geohashPeople: [GeoPerson] = []
private var geoParticipantsTimer: Timer? = nil
// Participants who indicated they teleported (by tag in their events)
@Published private(set) var teleportedGeo: Set<String> = [] // lowercased pubkey hex
// Sampling subscriptions for multiple geohashes (when channel sheet is open)
private var geoSamplingSubs: [String: String] = [:] // subID -> geohash
private var lastGeoNotificationAt: [String: Date] = [:] // geohash -> last notify time
// MARK: - Message Delivery Tracking
// Delivery tracking
private var cancellables = Set<AnyCancellable>()
// 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: [String: PendingVerification] = [:] // peerID -> pending
// Last handled challenge nonce per peer to avoid duplicate responses
private var lastVerifyNonceByPeer: [String: 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)
// Buffer incoming public messages and flush in small batches to reduce UI invalidations
private var publicBuffer: [BitchatMessage] = []
private var publicBufferTimer: Timer? = nil
private let basePublicFlushInterval: TimeInterval = TransportConfig.basePublicFlushInterval
private var dynamicPublicFlushInterval: TimeInterval = TransportConfig.basePublicFlushInterval
private var recentBatchSizes: [Int] = []
@Published private(set) var isBatchingPublic: Bool = false
private let lateInsertThreshold: TimeInterval = TransportConfig.uiLateInsertThreshold
// Track sent read receipts to avoid duplicates (persisted across launches)
// Note: Persistence happens automatically in didSet, no lifecycle observers needed
private 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
private var lastVerifyToastAt: [String: Date] = [:]
// Track processed Nostr ACKs to avoid duplicate processing
private var processedNostrAcks: Set<String> = [] // "messageId:ackType:senderPubkey" format
// Track which GeoDM messages we've already sent a delivery ACK for (by messageID)
private 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
private var torInitialReadyAnnounced: Bool = false
// Track Nostr pubkey mappings for unknown senders
private var nostrKeyMapping: [String: String] = [:] // senderPeerID -> nostrPubkey
// MARK: - Initialization
@MainActor
init(
keychain: KeychainManagerProtocol,
identityManager: SecureIdentityStateManagerProtocol
) {
self.keychain = keychain
self.identityManager = identityManager
self.meshService = BLEService(keychain: keychain, identityManager: identityManager)
// 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, identityManager: identityManager)
let nostrTransport = NostrTransport(keychain: keychain)
self.messageRouter = MessageRouter(mesh: meshService, nostr: nostrTransport)
// Route receipts from PrivateChatManager through MessageRouter
self.privateChatManager.messageRouter = self.messageRouter
// Allow UnifiedPeerService to route favorite notifications via mesh/Nostr
self.unifiedPeerService.messageRouter = self.messageRouter
self.autocompleteService = AutocompleteService()
// Wire up dependencies
self.commandProcessor.chatViewModel = self
// Subscribe to privateChatManager changes to trigger UI updates
privateChatManager.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()
// 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("starting tor...")
// Suppress incremental Tor progress messages
torProgressCancellable = nil
} else if !TorManager.shared.torEnforced && !torStatusAnnounced {
torStatusAnnounced = true
addGeohashOnlySystemMessage("development build: tor bypass enabled.")
}
// 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: [String: BitchatPeer] = [:]
for peer in peers {
// Keep the first occurrence of each peer ID
if uniquePeers[peer.id] == nil {
uniquePeers[peer.id] = peer
} else {
SecureLogger.warning("⚠️ Duplicate peer ID detected: \(peer.id) (\(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
let regional = LocationChannelManager.shared.availableChannels.map { $0.geohash }
let bookmarks = GeohashBookmarksStore.shared.bookmarks
let union = Array(Set(regional).union(bookmarks))
if TorManager.shared.isForeground() {
self.beginGeohashSampling(for: union)
} else {
self.endGeohashSampling()
}
}
}
}
.store(in: &self.cancellables)
}
// Set up Noise encryption callbacks
setupNoiseCallbacks()
// Observe location channel selection
LocationChannelManager.shared.$selectedChannel
.receive(on: DispatchQueue.main)
.sink { [weak self] channel in
guard let self = self else { return }
Task { @MainActor in
self.switchLocationChannel(to: channel)
}
}
.store(in: &cancellables)
// Initialize with current selection
Task { @MainActor in
self.switchLocationChannel(to: LocationChannelManager.shared.selectedChannel)
}
// Foreground-only: sample nearby geohashes + bookmarks (disabled in background)
LocationChannelManager.shared.$availableChannels
.receive(on: DispatchQueue.main)
.sink { [weak self] channels in
guard let self = self else { return }
let regional = channels.map { $0.geohash }
let bookmarks = GeohashBookmarksStore.shared.bookmarks
let union = Array(Set(regional).union(bookmarks))
Task { @MainActor in
if TorManager.shared.isForeground() {
self.beginGeohashSampling(for: union)
} else {
self.endGeohashSampling()
}
}
}
.store(in: &cancellables)
// Also observe bookmark changes to update sampling
GeohashBookmarksStore.shared.$bookmarks
.receive(on: DispatchQueue.main)
.sink { [weak self] bookmarks in
guard let self = self else { return }
let regional = LocationChannelManager.shared.availableChannels.map { $0.geohash }
let union = Array(Set(regional).union(bookmarks))
Task { @MainActor in
if TorManager.shared.isForeground() {
self.beginGeohashSampling(for: union)
} else {
self.endGeohashSampling()
}
}
}
.store(in: &cancellables)
// Kick off initial sampling if we have regional channels or bookmarks (foreground only)
do {
let regional = LocationChannelManager.shared.availableChannels.map { $0.geohash }
let bookmarks = GeohashBookmarksStore.shared.bookmarks
let union = Array(Set(regional).union(bookmarks))
if !union.isEmpty && TorManager.shared.isForeground() {
Task { @MainActor in self.beginGeohashSampling(for: union) }
}
}
// Refresh channels once when authorized to seed sampling
LocationChannelManager.shared.$permissionState
.receive(on: DispatchQueue.main)
.sink { state in
if state == .authorized { LocationChannelManager.shared.refreshChannels() }
}
.store(in: &cancellables)
// 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? NostrIdentityBridge.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
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(appWillTerminate),
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
)
#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(appWillTerminate),
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
)
#endif
}
// MARK: - Deinitialization
deinit {
// No need to force UserDefaults synchronization
}
// MARK: - Tor notifications
@objc private func handleTorWillStart() {
Task { @MainActor in
if !self.torStatusAnnounced && TorManager.shared.torEnforced {
self.torStatusAnnounced = true
// Post only in geohash channels (queue if not active)
self.addGeohashOnlySystemMessage("starting tor...")
}
}
}
@objc private func handleTorWillRestart() {
Task { @MainActor in
self.torRestartPending = true
// Post only in geohash channels (queue if not active)
self.addGeohashOnlySystemMessage("tor restarting to recover connectivity...")
}
}
@objc private func handleTorDidBecomeReady() {
Task { @MainActor in
// Only announce "restarted" if we actually restarted this session
if self.torRestartPending {
// Post only in geohash channels (queue if not active)
self.addGeohashOnlySystemMessage("tor restarted. network routing restored.")
self.torRestartPending = false
} else if TorManager.shared.torEnforced && !self.torInitialReadyAnnounced {
// Initial start completed
self.addGeohashOnlySystemMessage("tor started. routing all chats via tor for privacy.")
self.torInitialReadyAnnounced = true
}
}
}
// Resubscribe to the active geohash channel without clearing timeline
@MainActor
private func resubscribeCurrentGeohash() {
guard case .location(let ch) = activeChannel else { return }
guard let subID = geoSubscriptionID else {
// No existing subscription; set it up
switchLocationChannel(to: activeChannel)
return
}
// Ensure participant decay timer is running
startGeoParticipantsTimer()
// Unsubscribe + resubscribe
NostrRelayManager.shared.unsubscribe(id: subID)
let filter = NostrFilter.geohashEphemeral(
ch.geohash,
since: Date().addingTimeInterval(-TransportConfig.nostrGeohashInitialLookbackSeconds),
limit: TransportConfig.nostrGeohashInitialLimit
)
let subRelays = GeoRelayDirectory.shared.closestRelays(
toGeohash: ch.geohash,
count: TransportConfig.nostrGeoRelayCount
)
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in
guard let self = self else { return }
guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return }
if self.processedNostrEvents.contains(event.id) { return }
self.processedNostrEvents.insert(event.id)
if let gh = self.currentGeohash,
let myGeoIdentity = try? NostrIdentityBridge.deriveIdentity(forGeohash: gh),
myGeoIdentity.publicKeyHex.lowercased() == event.pubkey.lowercased() {
// Skip very recent self-echo from relay, but allow older events (e.g., after app restart)
let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at))
if Date().timeIntervalSince(eventTime) < 15 { return }
}
if let nickTag = event.tags.first(where: { $0.first == "n" }), nickTag.count >= 2 {
let nick = nickTag[1].trimmingCharacters(in: .whitespacesAndNewlines)
self.geoNicknames[event.pubkey.lowercased()] = nick
}
// Store mapping for geohash sender IDs used in messages (ensures consistent colors)
let key16 = "nostr_" + String(event.pubkey.prefix(TransportConfig.nostrConvKeyPrefixLength))
self.nostrKeyMapping[key16] = event.pubkey
let key8 = "nostr:" + String(event.pubkey.prefix(TransportConfig.nostrShortKeyDisplayLength))
self.nostrKeyMapping[key8] = event.pubkey
// Update participants last-seen for this pubkey
self.recordGeoParticipant(pubkeyHex: event.pubkey)
// Track teleported tag (only our format ["t","teleport"]) for icon state
let hasTeleportTag = event.tags.contains(where: { tag in
tag.count >= 2 && tag[0].lowercased() == "t" && tag[1].lowercased() == "teleport"
})
if hasTeleportTag {
let key = event.pubkey.lowercased()
// Do not mark our own key from historical events; rely on manager.teleported for self
let isSelf: Bool = {
if let gh = self.currentGeohash, let my = try? NostrIdentityBridge.deriveIdentity(forGeohash: gh) {
return my.publicKeyHex.lowercased() == key
}
return false
}()
if !isSelf {
Task { @MainActor in self.teleportedGeo = self.teleportedGeo.union([key]) }
}
}
let senderName = self.displayNameForNostrPubkey(event.pubkey)
let content = event.content.trimmingCharacters(in: .whitespacesAndNewlines)
// Clamp future timestamps to now to avoid future-dated messages skewing order
let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at))
let timestamp = min(rawTs, Date())
let mentions = self.parseMentions(from: content)
let msg = BitchatMessage(
id: event.id,
sender: senderName,
content: content,
timestamp: timestamp,
isRelay: false,
originalSender: nil,
isPrivate: false,
recipientNickname: nil,
senderPeerID: "nostr:\(event.pubkey.prefix(TransportConfig.nostrShortKeyDisplayLength))",
mentions: mentions.isEmpty ? nil : mentions
)
Task { @MainActor in
self.handlePublicMessage(msg)
self.checkForMentions(msg)
self.sendHapticFeedback(for: msg)
}
}
// Resubscribe geohash DMs for this identity
if let dmSub = geoDmSubscriptionID { NostrRelayManager.shared.unsubscribe(id: dmSub); geoDmSubscriptionID = nil }
do {
let id = try NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash)
let dmSub = "geo-dm-\(ch.geohash)"
geoDmSubscriptionID = dmSub
let dmFilter = NostrFilter.giftWrapsFor(pubkey: id.publicKeyHex, since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds))
NostrRelayManager.shared.subscribe(filter: dmFilter, id: dmSub) { [weak self] giftWrap in
guard let self = self else { return }
if self.processedNostrEvents.contains(giftWrap.id) { return }
self.recordProcessedEvent(giftWrap.id)
guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(giftWrap: giftWrap, recipientIdentity: id) else { return }
guard content.hasPrefix("bitchat1:") else { return }
guard let packetData = Self.base64URLDecode(String(content.dropFirst("bitchat1:".count))),
let packet = BitchatPacket.from(packetData) else { return }
guard packet.type == MessageType.noiseEncrypted.rawValue else { return }
guard let noisePayload = NoisePayload.decode(packet.payload) else { return }
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs))
let convKey = "nostr_" + String(senderPubkey.prefix(TransportConfig.nostrConvKeyPrefixLength))
self.nostrKeyMapping[convKey] = senderPubkey
switch noisePayload.type {
case .privateMessage:
guard let pm = PrivateMessagePacket.decode(from: noisePayload.data) else { return }
let messageId = pm.messageID
// Send delivery ACK immediately (once per message ID)
if !self.sentGeoDeliveryAcks.contains(messageId) {
let nt = NostrTransport(keychain: keychain)
nt.senderPeerID = self.meshService.myPeerID
nt.sendDeliveryAckGeohash(for: messageId, toRecipientHex: senderPubkey, from: id)
self.sentGeoDeliveryAcks.insert(messageId)
}
// Dedup storage
if self.privateChats[convKey]?.contains(where: { $0.id == messageId }) == true { return }
for (_, arr) in self.privateChats { if arr.contains(where: { $0.id == messageId }) { return } }
let senderName = self.displayNameForNostrPubkey(senderPubkey)
let isViewing = (self.selectedPrivateChatPeer == convKey)
// pared back: omit view-state log
let wasReadBefore = self.sentReadReceipts.contains(messageId)
let isRecentMessage = Date().timeIntervalSince(messageTimestamp) < 30
let shouldMarkUnread = !wasReadBefore && !isViewing && isRecentMessage
let msg = BitchatMessage(
id: messageId,
sender: senderName,
content: pm.content,
timestamp: messageTimestamp,
isRelay: false,
originalSender: nil,
isPrivate: true,
recipientNickname: self.nickname,
senderPeerID: convKey,
mentions: nil,
deliveryStatus: .delivered(to: self.nickname, at: Date())
)
// Respect geohash blocks
if identityManager.isNostrBlocked(pubkeyHexLowercased: senderPubkey) {
return
}
if self.privateChats[convKey] == nil { self.privateChats[convKey] = [] }
self.privateChats[convKey]?.append(msg)
self.trimPrivateChatMessagesIfNeeded(for: convKey)
if shouldMarkUnread { self.unreadPrivateMessages.insert(convKey) }
if isViewing {
// pared back: omit pre-send READ log
if !wasReadBefore {
let nt = NostrTransport(keychain: keychain)
nt.senderPeerID = self.meshService.myPeerID
nt.sendReadReceiptGeohash(messageId, toRecipientHex: senderPubkey, from: id)
self.sentReadReceipts.insert(messageId)
}
} else {
// Notify for truly unread and recent messages when not viewing
if shouldMarkUnread {
NotificationService.shared.sendPrivateMessageNotification(
from: senderName,
message: pm.content,
peerID: convKey
)
}
}
self.objectWillChange.send()
case .delivered:
if let messageID = String(data: noisePayload.data, encoding: .utf8) {
if let idx = self.privateChats[convKey]?.firstIndex(where: { $0.id == messageID }) {
self.privateChats[convKey]?[idx].deliveryStatus = .delivered(to: self.displayNameForNostrPubkey(senderPubkey), at: Date())
self.objectWillChange.send()
SecureLogger.info("GeoDM: recv DELIVERED for mid=\(messageID.prefix(8))… from=\(senderPubkey.prefix(8))…", category: .session)
} else {
SecureLogger.warning("GeoDM: delivered ack for unknown mid=\(messageID.prefix(8))… conv=\(convKey)", category: .session)
}
}
case .readReceipt:
if let messageID = String(data: noisePayload.data, encoding: .utf8) {
if let idx = self.privateChats[convKey]?.firstIndex(where: { $0.id == messageID }) {
self.privateChats[convKey]?[idx].deliveryStatus = .read(by: self.displayNameForNostrPubkey(senderPubkey), at: Date())
self.objectWillChange.send()
SecureLogger.info("GeoDM: recv READ for mid=\(messageID.prefix(8))… from=\(senderPubkey.prefix(8))…", category: .session)
} else {
SecureLogger.warning("GeoDM: read ack for unknown mid=\(messageID.prefix(8))… conv=\(convKey)", category: .session)
}
}
case .verifyChallenge, .verifyResponse:
// QR verification payloads over Nostr are not supported; ignore in geohash DMs
break
}
}
} catch { }
}
// 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: String) -> 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 = peer.noisePublicKey.hexEncodedString()
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 = "nostr_" + String(nostrHex.prefix(TransportConfig.nostrConvKeyPrefixLength))
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.hasPrefix("nostr_") {
// 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: String) {
// 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 peerID.count == 64, let noisePublicKey = Data(hexString: peerID) {
// 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
}?.id
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 ?? NostrIdentityBridge.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: String) -> Bool {
// Distinguish between ephemeral peer IDs (16 hex chars) and Noise public keys (64 hex chars)
if peerID.count == 64, let noisePublicKey = Data(hexString: peerID) {
// 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
// Called when we receive a peer's public key
@MainActor
func registerPeerPublicKey(peerID: String, publicKeyData: Data) {
// Create a fingerprint from the public key (full SHA256, not truncated)
let fingerprintStr = SHA256.hash(data: publicKeyData)
.compactMap { String(format: "%02x", $0) }
.joined()
// Only register if not already registered
if peerIDToPublicKeyFingerprint[peerID] != fingerprintStr {
peerIDToPublicKeyFingerprint[peerID] = fingerprintStr
}
// Update identity state manager with handshake completion
identityManager.updateHandshakeState(peerID: peerID, state: .completed(fingerprint: fingerprintStr))
// Update encryption status now that we have the fingerprint
updateEncryptionStatus(for: peerID)
// Check if we have a claimed nickname for this peer
let peerNicknames = meshService.getPeerNicknames()
if let nickname = peerNicknames[peerID], nickname != "Unknown" && nickname != "anon\(peerID.prefix(4))" {
// Update or create social identity with the claimed nickname
if var identity = identityManager.getSocialIdentity(for: fingerprintStr) {
identity.claimedNickname = nickname
identityManager.updateSocialIdentity(identity)
} else {
let newIdentity = SocialIdentity(
fingerprint: fingerprintStr,
localPetname: nil,
claimedNickname: nickname,
trustLevel: .casual,
isFavorite: false,
isBlocked: false,
notes: nil
)
identityManager.updateSocialIdentity(newIdentity)
}
}
// Check if this peer is the one we're in a private chat with
updatePrivateChatPeerIfNeeded()
// If we're in a private chat with this peer (by fingerprint), send pending read receipts
if let chatFingerprint = selectedPrivateChatFingerprint,
chatFingerprint == fingerprintStr {
// Send read receipts for any unread messages from this peer
// Use a small delay to ensure the connection is fully established
DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiReadReceiptRetryLongSeconds) { [weak self] in
self?.markPrivateMessagesAsRead(from: peerID)
}
}
}
@MainActor
func isPeerBlocked(_ peerID: String) -> Bool {
return unifiedPeerService.isBlocked(peerID)
}
// Helper method to find current peer ID for a fingerprint
@MainActor
private func getCurrentPeerIDForFingerprint(_ fingerprint: String) -> String? {
// 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
trimPrivateChatMessagesIfNeeded(for: currentPeerID)
}
// 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)
// Add message to local display
var displaySender = nickname
var localSenderPeerID = meshService.myPeerID
if case .location(let ch) = activeChannel,
let myGeoIdentity = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) {
let suffix = String(myGeoIdentity.publicKeyHex.suffix(4))
displaySender = nickname + "#" + suffix
localSenderPeerID = "nostr:\(myGeoIdentity.publicKeyHex.prefix(TransportConfig.nostrShortKeyDisplayLength))"
}
let message = BitchatMessage(
sender: displaySender,
content: trimmed,
timestamp: Date(),
isRelay: false,
senderPeerID: localSenderPeerID,
mentions: mentions.isEmpty ? nil : mentions
)
// Add to main messages immediately for user feedback
messages.append(message)
// Update content LRU for near-dup detection
let ckey = normalizedContentKey(message.content)
recordContentKey(ckey, timestamp: message.timestamp)
// Persist to channel-specific timelines
switch activeChannel {
case .mesh:
meshTimeline.append(message)
trimMeshTimelineIfNeeded()
case .location(let ch):
var arr = geoTimelines[ch.geohash] ?? []
arr.append(message)
if arr.count > geoTimelineCap {
arr = Array(arr.suffix(geoTimelineCap))
}
geoTimelines[ch.geohash] = arr
}
trimMessagesIfNeeded()
// Force immediate UI update for user's own messages
objectWillChange.send()
// Update channel activity time on send
switch activeChannel {
case .mesh:
lastPublicActivityAt["mesh"] = Date()
case .location(let ch):
lastPublicActivityAt["geo:\(ch.geohash)"] = Date()
}
if case .location(let ch) = activeChannel {
// Send to geohash channel via Nostr ephemeral
Task { @MainActor in
sendGeohash(ch: ch, content: trimmed)
}
} else {
// Send via mesh with mentions
meshService.sendMessage(content, mentions: mentions)
}
}
@MainActor
private func sendGeohash(ch: GeohashChannel, content: String) {
do {
let identity = try NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash)
let event = try NostrProtocol.createEphemeralGeohashEvent(
content: content,
geohash: ch.geohash,
senderIdentity: identity,
nickname: nickname,
teleported: LocationChannelManager.shared.teleported
)
let targetRelays = GeoRelayDirectory.shared.closestRelays(
toGeohash: ch.geohash,
count: TransportConfig.nostrGeoRelayCount
)
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
recordGeoParticipant(pubkeyHex: identity.publicKeyHex)
let shortKey = "nostr:" + identity.publicKeyHex.prefix(TransportConfig.nostrShortKeyDisplayLength)
nostrKeyMapping[shortKey] = identity.publicKeyHex
SecureLogger.debug("GeoTeleport: sent geo message pub=\(identity.publicKeyHex.prefix(8))… teleported=\(LocationChannelManager.shared.teleported)", category: .session)
// If we tagged this as teleported, also mark our pubkey in teleportedGeo for UI
// Only when not in our regional set (and regional list is known)
let hasRegional = !LocationChannelManager.shared.availableChannels.isEmpty
let inRegional = LocationChannelManager.shared.availableChannels.contains { $0.geohash == ch.geohash }
if LocationChannelManager.shared.teleported && hasRegional && !inRegional {
let key = identity.publicKeyHex.lowercased()
teleportedGeo = teleportedGeo.union([key])
SecureLogger.info("GeoTeleport: mark self teleported key=\(key.prefix(8))… total=\(teleportedGeo.count)", category: .session)
}
} catch {
SecureLogger.error("❌ Failed to send geohash message: \(error)", category: .session)
addSystemMessage("failed to send to location channel")
}
}
@MainActor
private func switchLocationChannel(to channel: ChannelID) {
// Flush pending public buffer to avoid cross-channel bleed
publicBufferTimer?.invalidate(); publicBufferTimer = nil
publicBuffer.removeAll(keepingCapacity: false)
activeChannel = channel
// Reset deduplication set and optionally hydrate timeline for mesh
processedNostrEvents.removeAll()
processedNostrEventOrder.removeAll()
switch channel {
case .mesh:
messages = meshTimeline
// Debug: log if any empty messages are present
let emptyMesh = messages.filter { $0.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }.count
if emptyMesh > 0 {
SecureLogger.debug("RenderGuard: mesh timeline contains \(emptyMesh) empty messages", category: .session)
}
stopGeoParticipantsTimer()
geohashPeople = []
teleportedGeo.removeAll()
case .location(let ch):
// Persist the cleaned/sorted timeline for this geohash
let deduped = geoTimelines[ch.geohash]?.cleanedAndDeduped() ?? []
geoTimelines[ch.geohash] = deduped
messages = deduped
}
// If switching to a location channel, flush any pending geohash-only system messages
if case .location = channel, !pendingGeohashSystemMessages.isEmpty {
for m in pendingGeohashSystemMessages {
addPublicSystemMessage(m)
}
pendingGeohashSystemMessages.removeAll(keepingCapacity: false)
}
// Unsubscribe previous
if let sub = geoSubscriptionID {
NostrRelayManager.shared.unsubscribe(id: sub)
geoSubscriptionID = nil
}
if let dmSub = geoDmSubscriptionID {
NostrRelayManager.shared.unsubscribe(id: dmSub)
geoDmSubscriptionID = nil
}
currentGeohash = nil
// Reset nickname cache for geochat participants
geoNicknames.removeAll()
geohashPeople = []
guard case .location(let ch) = channel else { return }
currentGeohash = ch.geohash
// Ensure self appears immediately in the people list; mark teleported state only when truly teleported
if let id = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) {
recordGeoParticipant(pubkeyHex: id.publicKeyHex)
let hasRegional = !LocationChannelManager.shared.availableChannels.isEmpty
let inRegional = LocationChannelManager.shared.availableChannels.contains { $0.geohash == ch.geohash }
let key = id.publicKeyHex.lowercased()
if LocationChannelManager.shared.teleported && hasRegional && !inRegional {
teleportedGeo = teleportedGeo.union([key])
SecureLogger.info("GeoTeleport: channel switch mark self teleported key=\(key.prefix(8))… total=\(teleportedGeo.count)", category: .session)
} else {
teleportedGeo.remove(key)
}
}
let subID = "geo-\(ch.geohash)"
geoSubscriptionID = subID
startGeoParticipantsTimer()
let ts = Date().addingTimeInterval(-TransportConfig.nostrGeohashInitialLookbackSeconds)
let filter = NostrFilter.geohashEphemeral(ch.geohash, since: ts, limit: TransportConfig.nostrGeohashInitialLimit)
let subRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: ch.geohash, count: 5)
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in
self?.handleNostrEvent(event)
}
subscribeToGeoChat(ch)
}
private func handleNostrEvent(_ event: NostrEvent) {
// Only handle ephemeral kind 20000 with matching tag
guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return }
// Deduplicate
if processedNostrEvents.contains(event.id) { return }
recordProcessedEvent(event.id)
// Log incoming tags for diagnostics
let tagSummary = event.tags.map { "[" + $0.joined(separator: ",") + "]" }.joined(separator: ",")
SecureLogger.debug("GeoTeleport: recv pub=\(event.pubkey.prefix(8))… tags=\(tagSummary)", category: .session)
// Track teleport tag for participants only our format ["t", "teleport"]
let hasTeleportTag: Bool = event.tags.contains { tag in
tag.count >= 2 && tag[0].lowercased() == "t" && tag[1].lowercased() == "teleport"
}
let isSelf: Bool = {
if let gh = currentGeohash, let my = try? NostrIdentityBridge.deriveIdentity(forGeohash: gh) {
return my.publicKeyHex.lowercased() == event.pubkey.lowercased()
}
return false
}()
if hasTeleportTag {
// Avoid marking our own key from historical events; rely on manager.teleported for self
if !isSelf {
let key = event.pubkey.lowercased()
Task { @MainActor in
teleportedGeo = teleportedGeo.union([key])
SecureLogger.info("GeoTeleport: mark peer teleported key=\(key.prefix(8))… total=\(teleportedGeo.count)", category: .session)
}
}
}
// Skip only very recent self-echo from relay; include older self events for hydration
if isSelf {
let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at))
if Date().timeIntervalSince(eventTime) < 15 {
return
}
}
// Cache nickname from tag if present
if let nickTag = event.tags.first(where: { $0.first == "n" }), nickTag.count >= 2 {
let nick = nickTag[1].trimmingCharacters(in: .whitespacesAndNewlines)
geoNicknames[event.pubkey.lowercased()] = nick
}
// If this pubkey is blocked, skip mapping, participants, and timeline
if identityManager.isNostrBlocked(pubkeyHexLowercased: event.pubkey) {
return
}
// Store mapping for geohash DM initiation
let key16 = "nostr_" + String(event.pubkey.prefix(TransportConfig.nostrConvKeyPrefixLength))
nostrKeyMapping[key16] = event.pubkey
let key8 = "nostr:" + String(event.pubkey.prefix(TransportConfig.nostrShortKeyDisplayLength))
nostrKeyMapping[key8] = event.pubkey
// Update participants last-seen for this pubkey
recordGeoParticipant(pubkeyHex: event.pubkey)
let senderName = displayNameForNostrPubkey(event.pubkey)
let content = event.content
// If this is a teleport presence event (no content), don't add to timeline
if let teleTag = event.tags.first(where: { $0.first == "t" }), teleTag.count >= 2, (teleTag[1] == "teleport"),
content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return
}
// Clamp future timestamps
let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at))
let mentions = parseMentions(from: content)
let msg = BitchatMessage(
id: event.id,
sender: senderName,
content: content,
timestamp: min(rawTs, Date()),
isRelay: false,
senderPeerID: "nostr:\(event.pubkey.prefix(TransportConfig.nostrShortKeyDisplayLength))",
mentions: mentions.isEmpty ? nil : mentions
)
Task { @MainActor in
handlePublicMessage(msg)
checkForMentions(msg)
sendHapticFeedback(for: msg)
}
}
@MainActor
private func subscribeToGeoChat(_ ch: GeohashChannel) {
guard let id = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) else { return }
let dmSub = "geo-dm-\(ch.geohash)"
geoDmSubscriptionID = dmSub
// pared back logging: subscribe debug only
// Log GeoDM subscribe only when Tor is ready to avoid early noise
if TorManager.shared.isReady {
SecureLogger.debug("GeoDM: subscribing DMs pub=\(id.publicKeyHex.prefix(8))… sub=\(dmSub)", category: .session)
}
let dmFilter = NostrFilter.giftWrapsFor(pubkey: id.publicKeyHex, since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds))
NostrRelayManager.shared.subscribe(filter: dmFilter, id: dmSub) { [weak self] giftWrap in
self?.handleGiftWrap(giftWrap, id: id)
}
}
private func handleGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
if processedNostrEvents.contains(giftWrap.id) {
return
}
recordProcessedEvent(giftWrap.id)
// Decrypt with per-geohash identity
guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(giftWrap: giftWrap, recipientIdentity: id) else {
SecureLogger.warning("GeoDM: failed decrypt giftWrap id=\(giftWrap.id.prefix(8))…", category: .session)
return
}
SecureLogger.debug("GeoDM: decrypted gift-wrap id=\(giftWrap.id.prefix(16))... from=\(senderPubkey.prefix(8))...", category: .session)
guard content.hasPrefix("bitchat1:"),
let packetData = Self.base64URLDecode(String(content.dropFirst("bitchat1:".count))),
let packet = BitchatPacket.from(packetData),
packet.type == MessageType.noiseEncrypted.rawValue,
let payload = NoisePayload.decode(packet.payload)
else {
return
}
let convKey = "nostr_" + String(senderPubkey.prefix(16))
nostrKeyMapping[convKey] = senderPubkey
switch payload.type {
case .privateMessage:
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs))
handlePrivateMessage(payload, senderPubkey: senderPubkey, convKey: convKey, id: id, messageTimestamp: messageTimestamp)
case .delivered:
handleDelivered(payload, senderPubkey: senderPubkey, convKey: convKey)
case .readReceipt:
handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: convKey)
// Explicitly list other cases so we get compile-time check if a new case is added in the future
case .verifyChallenge, .verifyResponse:
break
}
}
private func handlePrivateMessage(
_ payload: NoisePayload,
senderPubkey: String,
convKey: String,
id: NostrIdentity,
messageTimestamp: Date
) {
guard let pm = PrivateMessagePacket.decode(from: payload.data) else { return }
let messageId = pm.messageID
SecureLogger.info("GeoDM: recv PM <- sender=\(senderPubkey.prefix(8))… mid=\(messageId.prefix(8))…", category: .session)
// Send delivery ACK immediately (even if duplicate), once per messageID
if !sentGeoDeliveryAcks.contains(messageId) {
let nostrTransport = NostrTransport(keychain: keychain)
nostrTransport.senderPeerID = meshService.myPeerID
// pared back: omit pre-send log
nostrTransport.sendDeliveryAckGeohash(for: messageId, toRecipientHex: senderPubkey, from: id)
sentGeoDeliveryAcks.insert(messageId)
}
// Duplicate check
if privateChats[convKey]?.contains(where: { $0.id == messageId }) == true { return }
for (_, arr) in privateChats {
if arr.contains(where: { $0.id == messageId }) {
return
}
}
let senderName = displayNameForNostrPubkey(senderPubkey)
let msg = BitchatMessage(
id: messageId,
sender: senderName,
content: pm.content,
timestamp: messageTimestamp,
isRelay: false,
isPrivate: true,
recipientNickname: nickname,
senderPeerID: convKey,
deliveryStatus: .delivered(to: nickname, at: Date())
)
if privateChats[convKey] == nil {
privateChats[convKey] = []
}
privateChats[convKey]?.append(msg)
trimPrivateChatMessagesIfNeeded(for: convKey)
let isViewing = selectedPrivateChatPeer == convKey
let wasReadBefore = sentReadReceipts.contains(messageId)
let isRecentMessage = Date().timeIntervalSince(messageTimestamp) < 30
let shouldMarkUnread = !wasReadBefore && !isViewing && isRecentMessage
if shouldMarkUnread {
unreadPrivateMessages.insert(convKey)
}
// Send READ if viewing this conversation
if isViewing {
// pared back: omit pre-send READ log
if !wasReadBefore {
let nostrTransport = NostrTransport(keychain: keychain)
nostrTransport.senderPeerID = meshService.myPeerID
nostrTransport.sendReadReceiptGeohash(messageId, toRecipientHex: senderPubkey, from: id)
sentReadReceipts.insert(messageId)
}
} else {
// pared back: omit defer READ log
}
// Notify for truly unread and recent messages when not viewing
if !isViewing && shouldMarkUnread {
NotificationService.shared.sendPrivateMessageNotification(
from: senderName,
message: pm.content,
peerID: convKey
)
}
objectWillChange.send()
}
private func handleDelivered(_ payload: NoisePayload, senderPubkey: String, convKey: String) {
guard let messageID = String(data: payload.data, encoding: .utf8) else { return }
if let idx = privateChats[convKey]?.firstIndex(where: { $0.id == messageID }) {
privateChats[convKey]?[idx].deliveryStatus = .delivered(to: displayNameForNostrPubkey(senderPubkey), at: Date())
objectWillChange.send()
SecureLogger.info("GeoDM: recv DELIVERED for mid=\(messageID.prefix(8))… from=\(senderPubkey.prefix(8))…", category: .session)
} else {
SecureLogger.warning("GeoDM: delivered ack for unknown mid=\(messageID.prefix(8))… conv=\(convKey)", category: .session)
}
}
private func handleReadReceipt(_ payload: NoisePayload, senderPubkey: String, convKey: String) {
guard let messageID = String(data: payload.data, encoding: .utf8) else { return }
if let idx = privateChats[convKey]?.firstIndex(where: { $0.id == messageID }) {
privateChats[convKey]?[idx].deliveryStatus = .read(by: displayNameForNostrPubkey(senderPubkey), at: Date())
objectWillChange.send()
SecureLogger.info("GeoDM: recv READ for mid=\(messageID.prefix(8))… from=\(senderPubkey.prefix(8))…", category: .session)
} else {
SecureLogger.warning("GeoDM: read ack for unknown mid=\(messageID.prefix(8))… conv=\(convKey)", category: .session)
}
}
// MARK: - Geohash Participants
struct GeoPerson: Identifiable, Equatable {
let id: String // pubkey hex (lowercased)
let displayName: String
let lastSeen: Date
}
private func recordGeoParticipant(pubkeyHex: String) {
guard let gh = currentGeohash else { return }
let key = pubkeyHex.lowercased()
var map = geoParticipants[gh] ?? [:]
map[key] = Date()
geoParticipants[gh] = map
refreshGeohashPeople()
}
private func recordGeoParticipant(pubkeyHex: String, geohash: String) {
let key = pubkeyHex.lowercased()
var map = geoParticipants[geohash] ?? [:]
map[key] = Date()
geoParticipants[geohash] = map
// Only refresh list if this geohash is currently selected
if currentGeohash == geohash {
refreshGeohashPeople()
}
}
private func refreshGeohashPeople() {
guard let gh = currentGeohash else { geohashPeople = []; return }
let cutoff = Date().addingTimeInterval(-TransportConfig.uiRecentCutoffFiveMinutesSeconds)
var map = geoParticipants[gh] ?? [:]
// Prune expired entries
map = map.filter { $0.value >= cutoff }
// Remove blocked Nostr pubkeys
map = map.filter { !identityManager.isNostrBlocked(pubkeyHexLowercased: $0.key) }
geoParticipants[gh] = map
// Build display list
let people = map
.map { (pub, seen) in
GeoPerson(id: pub, displayName: displayNameForNostrPubkey(pub), lastSeen: seen)
}
.sorted { $0.lastSeen > $1.lastSeen }
geohashPeople = people
}
@MainActor
func isSelfSender(peerID: String?, displayName: String?) -> Bool {
guard let peerID else { return false }
if peerID == meshService.myPeerID { return true }
let lowerPeer = peerID.lowercased()
guard lowerPeer.hasPrefix("nostr") else { return false }
if let mapped = nostrKeyMapping[peerID]?.lowercased(),
let gh = currentGeohash,
let myIdentity = try? NostrIdentityBridge.deriveIdentity(forGeohash: gh) {
if mapped == myIdentity.publicKeyHex.lowercased() { return true }
}
if let gh = currentGeohash,
let myIdentity = try? NostrIdentityBridge.deriveIdentity(forGeohash: gh) {
let myLower = myIdentity.publicKeyHex.lowercased()
let shortLen = TransportConfig.nostrShortKeyDisplayLength
let shortKey = "nostr:" + myLower.prefix(shortLen)
if lowerPeer == shortKey { return true }
let suffix = myIdentity.publicKeyHex.suffix(4)
let expected = (nickname + "#" + suffix).lowercased()
if let display = displayName?.lowercased(), display == expected { return true }
}
return false
}
private func startGeoParticipantsTimer() {
stopGeoParticipantsTimer()
geoParticipantsTimer = Timer.scheduledTimer(withTimeInterval: 30.0, repeats: true) { [weak self] _ in
Task { @MainActor in
self?.refreshGeohashPeople()
}
}
}
private func stopGeoParticipantsTimer() {
geoParticipantsTimer?.invalidate()
geoParticipantsTimer = nil
}
// MARK: - Public helpers
/// Return the current, pruned, sorted people list for the active geohash without mutating state.
@MainActor
func visibleGeohashPeople() -> [GeoPerson] {
guard let gh = currentGeohash else { return [] }
let cutoff = Date().addingTimeInterval(-TransportConfig.uiRecentCutoffFiveMinutesSeconds)
let map = (geoParticipants[gh] ?? [:])
.filter { $0.value >= cutoff }
.filter { !identityManager.isNostrBlocked(pubkeyHexLowercased: $0.key) }
let people = map
.map { (pub, seen) in GeoPerson(id: pub, displayName: displayNameForNostrPubkey(pub), lastSeen: seen) }
.sorted { $0.lastSeen > $1.lastSeen }
return people
}
/// Returns the current participant count for a specific geohash, using the 5-minute activity window.
@MainActor
func geohashParticipantCount(for geohash: String) -> Int {
let cutoff = Date().addingTimeInterval(-TransportConfig.uiRecentCutoffFiveMinutesSeconds)
let map = geoParticipants[geohash] ?? [:]
return map.values.filter { $0 >= cutoff }.count
}
// 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
for (gh, var map) in geoParticipants {
map.removeValue(forKey: hex)
geoParticipants[gh] = map
}
refreshGeohashPeople()
// Remove their public messages from current geohash timeline and visible list
if let gh = currentGeohash {
if var arr = geoTimelines[gh] {
arr.removeAll { msg in
if let spid = msg.senderPeerID, spid.hasPrefix("nostr") {
if let full = nostrKeyMapping[spid]?.lowercased() { return full == hex }
}
return false
}
geoTimelines[gh] = arr
}
// Also filter currently bound messages if we are in geohash channel
switch activeChannel {
case .location:
messages.removeAll { msg in
if let spid = msg.senderPeerID, spid.hasPrefix("nostr") {
if let full = nostrKeyMapping[spid]?.lowercased() { return full == hex }
}
return false
}
default:
break
}
}
// Remove geohash DM conversation if exists
let convKey = "nostr_" + String(hex.prefix(TransportConfig.nostrConvKeyPrefixLength))
if privateChats[convKey] != nil {
privateChats.removeValue(forKey: convKey)
unreadPrivateMessages.remove(convKey)
}
// Remove mapping keys pointing to this pubkey to avoid accidental resolution
for (k, v) in nostrKeyMapping where v.lowercased() == hex { nostrKeyMapping.removeValue(forKey: k) }
addSystemMessage("blocked \(displayName) in geohash chats")
}
@MainActor
func unblockGeohashUser(pubkeyHexLowercased: String, displayName: String) {
identityManager.setNostrBlocked(pubkeyHexLowercased, isBlocked: false)
addSystemMessage("unblocked \(displayName) in geohash chats")
}
/// Begin sampling multiple geohashes (used by channel sheet) without changing active channel.
@MainActor
func beginGeohashSampling(for geohashes: [String]) {
// Disable sampling when app is backgrounded (Tor is stopped there)
if !TorManager.shared.isForeground() {
endGeohashSampling()
return
}
// Determine which to add and which to remove
let desired = Set(geohashes)
let current = Set(geoSamplingSubs.values)
let toAdd = desired.subtracting(current)
let toRemove = current.subtracting(desired)
//
for (subID, gh) in geoSamplingSubs where toRemove.contains(gh) {
NostrRelayManager.shared.unsubscribe(id: subID)
geoSamplingSubs.removeValue(forKey: subID)
}
// Subscribe new
for gh in toAdd {
let subID = "geo-sample-\(gh)"
geoSamplingSubs[subID] = gh
let filter = NostrFilter.geohashEphemeral(
gh,
since: Date().addingTimeInterval(-TransportConfig.nostrGeohashSampleLookbackSeconds),
limit: TransportConfig.nostrGeohashSampleLimit
)
let subRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: gh, count: 5)
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in
guard let self = self else { return }
guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return }
// Compute current participant count (5-minute window) BEFORE updating with this event
let cutoff = Date().addingTimeInterval(-TransportConfig.uiRecentCutoffFiveMinutesSeconds)
let existingCount: Int = {
let map = self.geoParticipants[gh] ?? [:]
return map.values.filter { $0 >= cutoff }.count
}()
// Update participants for this specific geohash
self.recordGeoParticipant(pubkeyHex: event.pubkey, geohash: gh)
// Notify only on rising-edge: previously zero people, now someone sends a chat
let content = event.content.trimmingCharacters(in: .whitespacesAndNewlines)
guard !content.isEmpty else { return }
// Respect geohash blocks
if identityManager.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased()) { return }
// Skip self identity for this geohash
if let my = try? NostrIdentityBridge.deriveIdentity(forGeohash: gh), my.publicKeyHex.lowercased() == event.pubkey.lowercased() { return }
// Only trigger when there were zero participants in this geohash recently
guard existingCount == 0 else { return }
// Avoid notifications for old sampled events when launching or (re)subscribing
let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at))
if Date().timeIntervalSince(eventTime) > 30 { return }
// Foreground-only notifications: app must be active, and not already viewing this geohash
#if os(iOS)
guard UIApplication.shared.applicationState == .active else { return }
if case .location(let ch) = self.activeChannel, ch.geohash == gh { return }
#elseif os(macOS)
guard NSApplication.shared.isActive else { return }
if case .location(let ch) = self.activeChannel, ch.geohash == gh { return }
#endif
// Cooldown per geohash
let now = Date()
let last = self.lastGeoNotificationAt[gh] ?? .distantPast
if now.timeIntervalSince(last) < TransportConfig.uiGeoNotifyCooldownSeconds { return }
// Compose a short preview
let preview: String = {
let maxLen = TransportConfig.uiGeoNotifySnippetMaxLen
if content.count <= maxLen { return content }
let idx = content.index(content.startIndex, offsetBy: maxLen)
return String(content[..<idx]) + "…"
}()
Task { @MainActor in
self.lastGeoNotificationAt[gh] = now
// Pre-populate the target geohash timeline so the triggering message appears when user opens it
var arr = self.geoTimelines[gh] ?? []
let senderSuffix = String(event.pubkey.suffix(4))
let nick = self.geoNicknames[event.pubkey.lowercased()]
let senderName = (nick?.isEmpty == false ? nick! : "anon") + "#" + senderSuffix
// Clamp future timestamps
let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at))
let ts = min(rawTs, Date())
let mentions = self.parseMentions(from: content)
let msg = BitchatMessage(
id: event.id,
sender: senderName,
content: content,
timestamp: ts,
isRelay: false,
originalSender: nil,
isPrivate: false,
recipientNickname: nil,
senderPeerID: "nostr:\(event.pubkey.prefix(TransportConfig.nostrShortKeyDisplayLength))",
mentions: mentions.isEmpty ? nil : mentions
)
if !arr.contains(where: { $0.id == msg.id }) {
arr.append(msg)
if arr.count > self.geoTimelineCap { arr = Array(arr.suffix(self.geoTimelineCap)) }
self.geoTimelines[gh] = arr
}
NotificationService.shared.sendGeohashActivityNotification(geohash: gh, bodyPreview: preview)
}
}
}
}
/// Stop sampling all extra geohashes.
@MainActor
func endGeohashSampling() {
for subID in geoSamplingSubs.keys { NostrRelayManager.shared.unsubscribe(id: subID) }
geoSamplingSubs.removeAll()
}
private 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? NostrIdentityBridge.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)"
}
// Helper: display name for current active channel (for notifications)
private func activeChannelDisplayName() -> String {
switch activeChannel {
case .mesh:
return "#mesh"
case .location(let ch):
return "#\(ch.geohash)"
}
}
// Dedup helper with small memory cap
private func recordProcessedEvent(_ id: String) {
processedNostrEvents.insert(id)
processedNostrEventOrder.append(id)
if processedNostrEventOrder.count > maxProcessedNostrEvents {
let overflow = processedNostrEventOrder.count - maxProcessedNostrEvents
for _ in 0..<overflow {
if let old = processedNostrEventOrder.first {
processedNostrEventOrder.removeFirst()
processedNostrEvents.remove(old)
}
}
}
}
/// Sends an encrypted private message to a specific peer.
/// - Parameters:
/// - content: The message content to encrypt and send
/// - peerID: The recipient's peer ID
/// - Note: Automatically establishes Noise encryption if not already active
@MainActor
func sendPrivateMessage(_ content: String, to peerID: String) {
guard !content.isEmpty else { return }
// Check if blocked
if unifiedPeerService.isBlocked(peerID) {
let nickname = meshService.peerNickname(peerID: peerID) ?? "user"
addSystemMessage("cannot send message to \(nickname): user is blocked.")
return
}
// Geohash DM routing: conversation keys start with "nostr_"
if peerID.hasPrefix("nostr_") {
sendGeohashDM(content, to: peerID)
}
// Determine routing method and recipient nickname
guard let noiseKey = Data(hexString: peerID) else { return }
let isConnected = meshService.isPeerConnected(peerID)
let isReachable = meshService.isPeerReachable(peerID)
let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey)
let isMutualFavorite = favoriteStatus?.isMutual ?? false
let hasNostrKey = favoriteStatus?.peerNostrPublicKey != nil
// Get nickname from various sources
var recipientNickname = meshService.peerNickname(peerID: peerID)
if recipientNickname == nil && favoriteStatus != nil {
recipientNickname = favoriteStatus?.peerNickname
}
recipientNickname = recipientNickname ?? "user"
// Generate message ID
let messageID = UUID().uuidString
// Create the message object
let message = BitchatMessage(
id: messageID,
sender: nickname,
content: content,
timestamp: Date(),
isRelay: false,
originalSender: nil,
isPrivate: true,
recipientNickname: recipientNickname,
senderPeerID: meshService.myPeerID,
mentions: nil,
deliveryStatus: .sending
)
// Add to local chat
if privateChats[peerID] == nil {
privateChats[peerID] = []
}
privateChats[peerID]?.append(message)
trimPrivateChatMessagesIfNeeded(for: peerID)
// Trigger UI update for sent message
objectWillChange.send()
// Send via appropriate transport (BLE if connected/reachable, else Nostr when possible)
if isConnected || isReachable || (isMutualFavorite && hasNostrKey) {
messageRouter.sendPrivate(content, to: peerID, recipientNickname: recipientNickname ?? "user", messageID: messageID)
// Optimistically mark as sent for both transports; delivery/read will update subsequently
if let idx = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
privateChats[peerID]?[idx].deliveryStatus = .sent
}
} else {
// Update delivery status to failed
if let index = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
privateChats[peerID]?[index].deliveryStatus = .failed(reason: "Peer not reachable")
}
addSystemMessage("Cannot send message to \(recipientNickname ?? "user") - peer is not reachable via mesh or Nostr.")
}
}
private func sendGeohashDM(_ content: String, to peerID: String) {
guard case .location(let ch) = activeChannel else {
addSystemMessage("cannot send: not in a location channel")
return
}
let messageID = UUID().uuidString
// Local echo in the DM thread
let message = BitchatMessage(
id: messageID,
sender: nickname,
content: content,
timestamp: Date(),
isRelay: false,
isPrivate: true,
recipientNickname: nickname,
senderPeerID: meshService.myPeerID,
deliveryStatus: .sending
)
if privateChats[peerID] == nil {
privateChats[peerID] = []
}
privateChats[peerID]?.append(message)
trimPrivateChatMessagesIfNeeded(for: peerID)
objectWillChange.send()
// Resolve recipient hex from mapping
guard let recipientHex = nostrKeyMapping[peerID] else {
if let msgIdx = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
privateChats[peerID]?[msgIdx].deliveryStatus = .failed(reason: "unknown recipient")
}
return
}
// Respect geohash blocks
if identityManager.isNostrBlocked(pubkeyHexLowercased: recipientHex) {
if let msgIdx = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
privateChats[peerID]?[msgIdx].deliveryStatus = .failed(reason: "user is blocked")
}
addSystemMessage("cannot send message: user is blocked.")
return
}
// Send via Nostr using per-geohash identity
do {
let id = try NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash)
// Prevent messaging ourselves
if recipientHex.lowercased() == id.publicKeyHex.lowercased() {
if let idx = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
privateChats[peerID]?[idx].deliveryStatus = .failed(reason: "cannot message yourself")
}
return
}
SecureLogger.debug("GeoDM: local send mid=\(messageID.prefix(8))… to=\(recipientHex.prefix(8))… conv=\(peerID)", category: .session)
let nostrTransport = NostrTransport(keychain: keychain)
nostrTransport.senderPeerID = meshService.myPeerID
nostrTransport.sendPrivateMessageGeohash(content: content, toRecipientHex: recipientHex, from: id, messageID: messageID)
if let msgIdx = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
privateChats[peerID]?[msgIdx].deliveryStatus = .sent
}
} catch {
if let idx = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
privateChats[peerID]?[idx].deliveryStatus = .failed(reason: "send error")
}
}
}
// MARK: - Geohash DMs initiation
@MainActor
func startGeohashDM(withPubkeyHex hex: String) {
let convKey = "nostr_" + String(hex.prefix(TransportConfig.nostrConvKeyPrefixLength))
nostrKeyMapping[convKey] = hex
selectedPrivateChatPeer = convKey
}
@MainActor
func fullNostrHex(forSenderPeerID senderID: String) -> String? {
return nostrKeyMapping[senderID]
}
@MainActor
func geohashDisplayName(for convKey: String) -> String {
guard let full = nostrKeyMapping[convKey] else {
let suffix = String(convKey.suffix(4))
return "anon#\(suffix)"
}
let suffix = String(full.suffix(4))
if let nick = geoNicknames[full.lowercased()], !nick.isEmpty {
return nick + "#" + suffix
}
return "anon#\(suffix)"
}
/// Add a local system message to a private chat (no network send)
@MainActor
func addLocalPrivateSystemMessage(_ content: String, to peerID: String) {
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)
trimPrivateChatMessagesIfNeeded(for: peerID)
objectWillChange.send()
}
// MARK: - Bluetooth State Management
/// Updates the Bluetooth state and shows appropriate alerts
/// - Parameter state: The current Bluetooth manager state
@MainActor
func updateBluetoothState(_ state: CBManagerState) {
bluetoothState = state
switch state {
case .poweredOff:
bluetoothAlertMessage = "Bluetooth is turned off. Please turn on Bluetooth in Settings to use BitChat."
showBluetoothAlert = true
case .unauthorized:
bluetoothAlertMessage = "BitChat needs Bluetooth permission to connect with nearby devices. Please enable Bluetooth access in Settings."
showBluetoothAlert = true
case .unsupported:
bluetoothAlertMessage = "This device does not support Bluetooth. BitChat requires Bluetooth to function."
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: String) {
// 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("cannot start chat with \(peerNickname): user is blocked.")
return
}
// Check mutual favorites for offline messaging
if let peer = unifiedPeerService.getPeer(by: peerID),
peer.isFavorite && !peer.theyFavoritedUs && !peer.isConnected {
addSystemMessage("cannot start chat with \(peerNickname): mutual favorite required for offline messaging.")
return
}
// Consolidate messages from stable Noise key if needed
// This ensures Nostr messages appear when opening a chat with an ephemeral peer ID
if let peer = unifiedPeerService.getPeer(by: peerID) {
let noiseKeyHex = peer.noisePublicKey.hexEncodedString()
// If we have messages stored under the stable Noise key hex but not under the ephemeral ID,
// or if we need to merge them, do so now
if noiseKeyHex != peerID {
if let nostrMessages = privateChats[noiseKeyHex], !nostrMessages.isEmpty {
// Check if there are ACTUALLY unread messages (not just the unread flag)
// Only transfer unread status if there are recent unread messages
var hasActualUnreadMessages = false
// Merge messages from stable key into ephemeral peer ID storage
if privateChats[peerID] == nil {
privateChats[peerID] = []
}
// Add any messages that aren't already in the ephemeral storage
let existingMessageIds = Set(privateChats[peerID]?.map { $0.id } ?? [])
for message in nostrMessages {
if !existingMessageIds.contains(message.id) {
// Create updated message with correct senderPeerID
// This is crucial for read receipts to work correctly
let updatedMessage = BitchatMessage(
id: message.id,
sender: message.sender,
content: message.content,
timestamp: message.timestamp,
isRelay: message.isRelay,
originalSender: message.originalSender,
isPrivate: message.isPrivate,
recipientNickname: message.recipientNickname,
senderPeerID: message.senderPeerID == meshService.myPeerID ? meshService.myPeerID : peerID, // Update peer ID if it's from them
mentions: message.mentions,
deliveryStatus: message.deliveryStatus
)
privateChats[peerID]?.append(updatedMessage)
// Check if this is an actually unread message
// Only mark as unread if:
// 1. Not a message we sent
// 2. Message is recent (< 60s old)
// Never mark old messages as unread during consolidation
if message.senderPeerID != meshService.myPeerID {
let messageAge = Date().timeIntervalSince(message.timestamp)
if messageAge < 60 && !sentReadReceipts.contains(message.id) {
hasActualUnreadMessages = true
}
}
}
}
// Sort by timestamp
privateChats[peerID]?.sort { $0.timestamp < $1.timestamp }
// Only transfer unread status if there are actual recent unread messages
if hasActualUnreadMessages {
unreadPrivateMessages.insert(peerID)
} else if unreadPrivateMessages.contains(noiseKeyHex) {
// Remove incorrect unread status from stable key
unreadPrivateMessages.remove(noiseKeyHex)
}
// Clean up the stable key storage to avoid duplication
privateChats.removeValue(forKey: noiseKeyHex)
// Consolidated Nostr messages from stable key
}
}
}
// Also consolidate messages from temporary Nostr peer IDs
// These are messages received via Nostr when we didn't know the sender's Noise key
// They're stored under "nostr_" + first 16 chars of Nostr pubkey
let currentPeerNickname = peerNickname.lowercased()
var tempPeerIDsToConsolidate: [String] = []
// Find all temporary Nostr peer IDs that have messages from the same nickname
for (storedPeerID, messages) in privateChats {
if storedPeerID.hasPrefix("nostr_") && storedPeerID != peerID {
// Check if ALL messages from this temporary peer have the same sender nickname
// This is more reliable than just checking the first message
let nicknamesMatch = messages.allSatisfy { msg in
msg.sender.lowercased() == currentPeerNickname
}
if nicknamesMatch && !messages.isEmpty {
tempPeerIDsToConsolidate.append(storedPeerID)
}
}
}
// Consolidate messages from temporary Nostr peer IDs
if !tempPeerIDsToConsolidate.isEmpty {
if privateChats[peerID] == nil {
privateChats[peerID] = []
}
let existingMessageIds = Set(privateChats[peerID]?.map { $0.id } ?? [])
var consolidatedCount = 0
var hadUnreadTemp = false
for tempPeerID in tempPeerIDsToConsolidate {
// Check if this temp peer ID had unread messages
if unreadPrivateMessages.contains(tempPeerID) {
hadUnreadTemp = true
}
if let tempMessages = privateChats[tempPeerID] {
for message in tempMessages {
if !existingMessageIds.contains(message.id) {
// Create a new message with the updated sender peer ID
let updatedMessage = BitchatMessage(
id: message.id,
sender: message.sender,
content: message.content,
timestamp: message.timestamp,
isRelay: message.isRelay,
originalSender: message.originalSender,
isPrivate: message.isPrivate,
recipientNickname: message.recipientNickname,
senderPeerID: peerID, // Update to match current peer
mentions: message.mentions,
deliveryStatus: message.deliveryStatus
)
privateChats[peerID]?.append(updatedMessage)
consolidatedCount += 1
}
}
// Remove the temporary storage
privateChats.removeValue(forKey: tempPeerID)
unreadPrivateMessages.remove(tempPeerID)
}
}
// If any temp peer ID had unread messages, mark the consolidated peer as unread
if hadUnreadTemp {
unreadPrivateMessages.insert(peerID)
SecureLogger.debug("📬 Transferred unread status from temp peer IDs to \(peerID)", category: .session)
}
if consolidatedCount > 0 {
// Sort by timestamp
privateChats[peerID]?.sort { $0.timestamp < $1.timestamp }
SecureLogger.info("📥 Consolidated \(consolidatedCount) Nostr messages from temporary peer IDs to \(peerNickname)", category: .session)
}
}
// Trigger handshake if needed (mesh peers only). Skip for Nostr geohash conv keys.
if !peerID.hasPrefix("nostr_") && !peerID.hasPrefix("nostr:") {
let sessionState = meshService.getNoiseSessionState(for: peerID)
switch sessionState {
case .none, .failed:
meshService.triggerHandshake(with: peerID)
default:
break
}
} else {
SecureLogger.debug("GeoDM: skipping mesh handshake for virtual peerID=\(peerID)", category: .session)
}
// Delegate to private chat manager but add already-acked messages first
// This prevents duplicate read receipts
// IMPORTANT: Only add messages WE sent to sentReadReceipts, not messages we received
if let messages = privateChats[peerID] {
for message in messages {
// Only track read receipts for messages WE sent (not received messages)
if message.sender == nickname {
// Check if message has been read or delivered
if let status = message.deliveryStatus {
switch status {
case .read, .delivered:
sentReadReceipts.insert(message.id)
privateChatManager.sentReadReceipts.insert(message.id)
default:
break
}
}
}
}
}
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 = oldKey.hexEncodedString()
let newPeerID = peerPublicKey.hexEncodedString()
// 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 = peerPublicKey.hexEncodedString()
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() {
// 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)
default:
// 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
trimPrivateChatMessagesIfNeeded(for: peerID)
} else {
// In public chat - send to active public channel
switch activeChannel {
case .mesh:
meshService.sendMessage(screenshotMessage, mentions: [])
case .location(let ch):
Task { @MainActor in
do {
let identity = try NostrIdentityBridge.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.recordGeoParticipant(pubkeyHex: identity.publicKeyHex)
} catch {
SecureLogger.error("❌ Failed to send geohash screenshot message: \(error)", category: .session)
self.addSystemMessage("failed to send to location channel")
}
}
}
// 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
}
@objc func applicationWillTerminate() {
// Send leave message to all peers
meshService.stopServices()
// Force save any pending identity changes (verifications, favorites, etc)
identityManager.forceSave()
// Verify identity key is still there
_ = keychain.verifyIdentityKeyExists()
// No need to force synchronize here
// Verify identity key after save
_ = keychain.verifyIdentityKeyExists()
}
@objc private func appWillTerminate() {
// No need to force synchronize here
}
@MainActor
private func sendReadReceipt(_ receipt: ReadReceipt, to peerID: String, 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),
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: String) {
privateChatManager.markAsRead(from: peerID)
// Handle GeoDM (nostr_*) read receipts directly via per-geohash identity
if peerID.hasPrefix("nostr_"),
let recipientHex = nostrKeyMapping[peerID],
case .location(let ch) = LocationChannelManager.shared.selectedChannel,
let id = try? NostrIdentityBridge.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)
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: String? = nil
var peerNostrPubkey: String? = nil
// First check if peerID is already a hex Noise key
if let noiseKey = Data(hexString: peerID),
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 = peer.noisePublicKey.hexEncodedString()
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 = (Data(hexString: peerID) != nil) ? peerID : (unifiedPeerService.getPeer(by: peerID)?.noisePublicKey.hexEncodedString() ?? 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: String) -> [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 = peer.noisePublicKey.hexEncodedString()
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) -> String? {
// 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 = "nostr_" + String(person.id.prefix(TransportConfig.nostrConvKeyPrefixLength))
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 = "nostr_" + String(pub.prefix(TransportConfig.nostrConvKeyPrefixLength))
nostrKeyMapping[convKey] = pub
return convKey
}
default:
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()
processedNostrAcks.removeAll()
// Clear all caches
invalidateEncryptionCache()
// IMPORTANT: Clear Nostr-related state
// Disconnect from Nostr relays and clear subscriptions
nostrRelayManager?.disconnect()
nostrRelayManager = nil
// Clear Nostr identity associations
NostrIdentityBridge.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()
}
// Force immediate UI update for panic mode
// UI updates immediately - no flushing needed
}
// 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? NostrIdentityBridge.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.hasPrefix("nostr:") {
if let myGeo = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) {
return spid == "nostr:\(myGeo.publicKeyHex.prefix(TransportConfig.nostrShortKeyDisplayLength))"
}
}
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 = .system(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.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? spid)") {
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 = Regexes.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
? .system(size: 14, weight: .bold, design: .monospaced)
: .system(size: 14, design: .monospaced)
result.append(AttributedString(content).mergingAttributes(plainStyle))
} else {
// Reuse compiled regexes and detector
let hashtagRegex = Regexes.hashtag
let mentionRegex = Regexes.mention
let cashuRegex = Regexes.cashu
let bolt11Regex = Regexes.bolt11
let lnurlRegex = Regexes.lnurl
let lightningSchemeRegex = Regexes.lightningScheme
let detector = Regexes.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
? .system(size: 14, weight: .bold, design: .monospaced)
: .system(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? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) {
return String(id.publicKeyHex.suffix(4))
}
return String(meshService.myPeerID.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 = .system(size: 14, weight: isSelf ? .bold : .semibold, design: .monospaced)
let mentionColor: Color = isMentionToMe ? .orange : baseColor
mentionStyle.foregroundColor = mentionColor
// Emit '@'
result.append(AttributedString("@").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
? .system(size: 14, weight: .bold, design: .monospaced)
: .system(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
? .system(size: 14, weight: .bold, design: .monospaced)
: .system(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
? .system(size: 14, weight: .bold, design: .monospaced)
: .system(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 = .system(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
? .system(size: 14, weight: .bold, design: .monospaced)
: .system(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 = .system(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 = .system(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 = .system(size: 10, design: .monospaced)
result.append(timestamp.mergingAttributes(timestampStyle))
}
// Cache the formatted text
message.setCachedFormattedText(result, isDark: isDark, isSelf: isSelf)
return result
}
func formatMessage(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString {
var result = AttributedString()
let isDark = colorScheme == .dark
let primaryColor = isDark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
if message.sender == "system" {
let content = AttributedString("* \(message.content) *")
var contentStyle = AttributeContainer()
contentStyle.foregroundColor = Color.gray
contentStyle.font = .system(size: 12, design: .monospaced).italic()
result.append(content.mergingAttributes(contentStyle))
// Add timestamp at the end for system messages
let timestamp = AttributedString(" [\(message.formattedTimestamp)]")
var timestampStyle = AttributeContainer()
timestampStyle.foregroundColor = Color.gray.opacity(0.5)
timestampStyle.font = .system(size: 10, design: .monospaced)
result.append(timestamp.mergingAttributes(timestampStyle))
} else {
let sender = AttributedString("<@\(message.sender)> ")
var senderStyle = AttributeContainer()
// Use consistent color for all senders
senderStyle.foregroundColor = primaryColor
// Bold the user's own nickname
let fontWeight: Font.Weight = message.sender == nickname ? .bold : .medium
senderStyle.font = .system(size: 12, weight: fontWeight, design: .monospaced)
result.append(sender.mergingAttributes(senderStyle))
// Process content to highlight mentions
let contentText = message.content
var processedContent = AttributedString()
// Regular expression to find @mentions
let pattern = "@([\\p{L}0-9_]+)"
let regex = try? NSRegularExpression(pattern: pattern, options: [])
let nsContent = contentText as NSString
let nsLen = nsContent.length
let matches = regex?.matches(in: contentText, options: [], range: NSRange(location: 0, length: nsLen)) ?? []
var lastEndIndex = contentText.startIndex
for match in matches {
// Add text before the mention
if let range = Range(match.range(at: 0), in: contentText) {
if lastEndIndex < range.lowerBound {
let beforeText = String(contentText[lastEndIndex..<range.lowerBound])
if !beforeText.isEmpty {
var normalStyle = AttributeContainer()
normalStyle.font = .system(size: 14, design: .monospaced)
normalStyle.foregroundColor = isDark ? Color.white : Color.black
processedContent.append(AttributedString(beforeText).mergingAttributes(normalStyle))
}
}
// Add the mention with highlight
let mentionText = String(contentText[range])
var mentionStyle = AttributeContainer()
mentionStyle.font = .system(size: 14, weight: .semibold, design: .monospaced)
mentionStyle.foregroundColor = Color.orange
processedContent.append(AttributedString(mentionText).mergingAttributes(mentionStyle))
if lastEndIndex < range.upperBound { lastEndIndex = range.upperBound }
}
}
// Add any remaining text
if lastEndIndex < contentText.endIndex {
let remainingText = String(contentText[lastEndIndex...])
var normalStyle = AttributeContainer()
normalStyle.font = .system(size: 14, design: .monospaced)
normalStyle.foregroundColor = isDark ? Color.white : Color.black
processedContent.append(AttributedString(remainingText).mergingAttributes(normalStyle))
}
result.append(processedContent)
if message.isRelay, let originalSender = message.originalSender {
let relay = AttributedString(" (via \(originalSender))")
var relayStyle = AttributeContainer()
relayStyle.foregroundColor = primaryColor.opacity(0.7)
relayStyle.font = .system(size: 11, design: .monospaced)
result.append(relay.mergingAttributes(relayStyle))
}
// 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 = .system(size: 10, design: .monospaced)
result.append(timestamp.mergingAttributes(timestampStyle))
}
return result
}
// MARK: - Noise Protocol Support
@MainActor
func updateEncryptionStatusForPeers() {
for peerID in connectedPeers {
updateEncryptionStatusForPeer(peerID)
}
}
@MainActor
func updateEncryptionStatusForPeer(_ peerID: String) {
let noiseService = meshService.getNoiseService()
if noiseService.hasEstablishedSession(with: peerID) {
// Check if fingerprint is verified using our persisted data
if let fingerprint = getFingerprint(for: peerID),
verifiedFingerprints.contains(fingerprint) {
peerEncryptionStatus[peerID] = .noiseVerified
} else {
peerEncryptionStatus[peerID] = .noiseSecured
}
} 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: String) -> 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:
// We have encryption, now check if it's verified
if let fingerprint = getFingerprint(for: peerID) {
if verifiedFingerprints.contains(fingerprint) {
status = .noiseVerified
} else {
status = .noiseSecured
}
} else {
// We have a session but no fingerprint yet - still secured
status = .noiseSecured
}
case .handshaking, .handshakeQueued:
// If we've ever established a session, show secured instead of handshaking
if hasEverEstablishedSession {
// Check if it was verified before
if let fingerprint = getFingerprint(for: peerID),
verifiedFingerprints.contains(fingerprint) {
status = .noiseVerified
} else {
status = .noiseSecured
}
} 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
if let fingerprint = getFingerprint(for: peerID),
verifiedFingerprints.contains(fingerprint) {
status = .noiseVerified
} else {
status = .noiseSecured
}
} 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
if let fingerprint = getFingerprint(for: peerID),
verifiedFingerprints.contains(fingerprint) {
status = .noiseVerified
} else {
status = .noiseSecured
}
} 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: String? = nil) {
if let peerID = peerID {
encryptionStatusCache.removeValue(forKey: peerID)
} else {
encryptionStatusCache.removeAll()
}
}
// MARK: - Message Handling
private func trimMessagesIfNeeded() {
if messages.count > maxMessages {
messages = Array(messages.suffix(maxMessages))
}
}
@MainActor
private func peerColor(for message: BitchatMessage, isDark: Bool) -> Color {
if let spid = message.senderPeerID {
if spid.hasPrefix("nostr:") || spid.hasPrefix("nostr_") {
let bare: String = {
if spid.hasPrefix("nostr:") { return String(spid.dropFirst(6)) }
if spid.hasPrefix("nostr_") { return String(spid.dropFirst(6)) }
return spid
}()
let full = nostrKeyMapping[spid]?.lowercased() ?? bare.lowercased()
return getNostrPaletteColor(for: full, isDark: isDark)
} else if spid.count == 16 {
// Mesh short ID
return getPeerPaletteColor(for: spid, isDark: isDark)
} else {
return getPeerPaletteColor(for: spid.lowercased(), isDark: isDark)
}
}
// Fallback when we only have a display name
return Color(peerSeed: message.sender.lowercased(), isDark: isDark)
}
// 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: String, isDark: Bool) -> Color {
return getPeerPaletteColor(for: peerID, isDark: isDark)
}
private func trimMeshTimelineIfNeeded() {
if meshTimeline.count > meshTimelineCap {
meshTimeline = Array(meshTimeline.suffix(meshTimelineCap))
}
}
// MARK: - Peer List Minimal-Distance Palette
private var peerPaletteLight: [String: (slot: Int, ring: Int, hue: Double)] = [:]
private var peerPaletteDark: [String: (slot: Int, ring: Int, hue: Double)] = [:]
private var peerPaletteSeeds: [String: String] = [:] // peerID -> seed used
@MainActor
private func meshSeed(for peerID: String) -> String {
if let full = getNoiseKeyForShortID(peerID)?.lowercased() {
return "noise:" + full
}
return peerID.lowercased()
}
@MainActor
private func getPeerPaletteColor(for peerID: String, isDark: Bool) -> Color {
// Ensure palette up to date for current peer set and seeds
rebuildPeerPaletteIfNeeded()
let entry = (isDark ? peerPaletteDark[peerID] : peerPaletteLight[peerID])
let orange = Color.orange
if peerID == meshService.myPeerID { return orange }
let saturation: Double = isDark ? 0.80 : 0.70
let baseBrightness: Double = isDark ? 0.75 : 0.45
let ringDelta = isDark ? TransportConfig.uiPeerPaletteRingBrightnessDeltaDark : TransportConfig.uiPeerPaletteRingBrightnessDeltaLight
if let e = entry {
let brightness = min(1.0, max(0.0, baseBrightness + ringDelta * Double(e.ring)))
return Color(hue: e.hue, saturation: saturation, brightness: brightness)
}
// Fallback to seed color if not in palette (e.g., transient)
return Color(peerSeed: meshSeed(for: peerID), isDark: isDark)
}
@MainActor
private func rebuildPeerPaletteIfNeeded() {
// Build current peer->seed map (excluding self)
let myID = meshService.myPeerID
var currentSeeds: [String: String] = [:]
for p in allPeers where p.id != myID {
currentSeeds[p.id] = meshSeed(for: p.id)
}
// If seeds unchanged and palette exists for both themes, skip
if currentSeeds == peerPaletteSeeds,
peerPaletteLight.keys.count == currentSeeds.count,
peerPaletteDark.keys.count == currentSeeds.count {
return
}
peerPaletteSeeds = currentSeeds
// Generate evenly spaced hue slots avoiding self-orange range
let slotCount = max(8, TransportConfig.uiPeerPaletteSlots)
let avoidCenter = 30.0 / 360.0
let avoidDelta = TransportConfig.uiColorHueAvoidanceDelta
var slots: [Double] = []
for i in 0..<slotCount {
let hue = Double(i) / Double(slotCount)
if abs(hue - avoidCenter) < avoidDelta { continue }
slots.append(hue)
}
if slots.isEmpty {
// Safety: if avoidance consumed all (shouldn't happen), fall back to full slots
for i in 0..<slotCount { slots.append(Double(i) / Double(slotCount)) }
}
// Helper to compute circular distance
func circDist(_ a: Double, _ b: Double) -> Double {
let d = abs(a - b)
return d > 0.5 ? 1.0 - d : d
}
// Assign slots to peers to maximize minimal distance, deterministically
let peers = currentSeeds.keys.sorted() // stable order
// Preferred slot index by seed (wrapping to available slots)
let prefIndex: [String: Int] = Dictionary(uniqueKeysWithValues: peers.map { id in
let h = (currentSeeds[id] ?? id).djb2()
// Map to available slot range deterministically
let idx = Int(h % UInt64(slots.count))
return (id, idx)
})
func assign(for seeds: [String: String]) -> [String: (slot: Int, ring: Int, hue: Double)] {
var mapping: [String: (slot: Int, ring: Int, hue: Double)] = [:]
var usedSlots = Set<Int>()
var usedHues: [Double] = []
// Keep previous assignments if still valid to minimize churn
let prev = peerPaletteLight.isEmpty ? peerPaletteDark : peerPaletteLight
for (id, entry) in prev {
if seeds.keys.contains(id), entry.slot < slots.count { // slot index still valid
mapping[id] = (entry.slot, entry.ring, slots[entry.slot])
usedSlots.insert(entry.slot)
usedHues.append(slots[entry.slot])
}
}
// First ring assignment using free slots
let unassigned = peers.filter { mapping[$0] == nil }
for id in unassigned {
// If a preferred slot free, take it
let preferred = prefIndex[id] ?? 0
if !usedSlots.contains(preferred) && preferred < slots.count {
mapping[id] = (preferred, 0, slots[preferred])
usedSlots.insert(preferred)
usedHues.append(slots[preferred])
continue
}
// Choose free slot maximizing minimal distance to used hues
var bestSlot: Int? = nil
var bestScore: Double = -1
for sIdx in 0..<slots.count where !usedSlots.contains(sIdx) {
let hue = slots[sIdx]
let minDist = usedHues.isEmpty ? 1.0 : usedHues.map { circDist(hue, $0) }.min() ?? 1.0
// Bias toward preferred index for stability
let bias = 1.0 - (Double((abs(sIdx - (prefIndex[id] ?? 0)) % slots.count)) / Double(slots.count))
let score = minDist + 0.05 * bias
if score > bestScore { bestScore = score; bestSlot = sIdx }
}
if let s = bestSlot {
mapping[id] = (s, 0, slots[s])
usedSlots.insert(s)
usedHues.append(slots[s])
}
}
// Overflow peers: assign additional rings by reusing slots with stable preference
let stillUnassigned = peers.filter { mapping[$0] == nil }
if !stillUnassigned.isEmpty {
for (idx, id) in stillUnassigned.enumerated() {
let preferred = prefIndex[id] ?? 0
// Spread over slots by rotating from preferred with a golden-step
let goldenStep = 7 // small prime step for dispersion
let s = (preferred + idx * goldenStep) % slots.count
mapping[id] = (s, 1, slots[s])
}
}
return mapping
}
let mapping = assign(for: currentSeeds)
peerPaletteLight = mapping
peerPaletteDark = mapping
}
// MARK: - Nostr People Minimal-Distance Palette (same algo)
private var nostrPaletteLight: [String: (slot: Int, ring: Int, hue: Double)] = [:]
private var nostrPaletteDark: [String: (slot: Int, ring: Int, hue: Double)] = [:]
private var nostrPaletteSeeds: [String: String] = [:] // pubkey -> seed used
@MainActor
private func getNostrPaletteColor(for pubkeyHexLowercased: String, isDark: Bool) -> Color {
rebuildNostrPaletteIfNeeded()
let entry = (isDark ? nostrPaletteDark[pubkeyHexLowercased] : nostrPaletteLight[pubkeyHexLowercased])
let myHex: String? = {
if case .location(let ch) = LocationChannelManager.shared.selectedChannel,
let id = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) {
return id.publicKeyHex.lowercased()
}
return nil
}()
if let me = myHex, pubkeyHexLowercased == me { return .orange }
let saturation: Double = isDark ? 0.80 : 0.70
let baseBrightness: Double = isDark ? 0.75 : 0.45
let ringDelta = isDark ? TransportConfig.uiPeerPaletteRingBrightnessDeltaDark : TransportConfig.uiPeerPaletteRingBrightnessDeltaLight
if let e = entry {
let brightness = min(1.0, max(0.0, baseBrightness + ringDelta * Double(e.ring)))
return Color(hue: e.hue, saturation: saturation, brightness: brightness)
}
// Fallback to seed color if not in palette (e.g., transient)
return Color(peerSeed: "nostr:" + pubkeyHexLowercased, isDark: isDark)
}
@MainActor
private func rebuildNostrPaletteIfNeeded() {
// Build seeds map from currently visible geohash people (excluding self)
let myHex: String? = {
if case .location(let ch) = LocationChannelManager.shared.selectedChannel,
let id = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) {
return id.publicKeyHex.lowercased()
}
return nil
}()
let people = visibleGeohashPeople()
var currentSeeds: [String: String] = [:]
for p in people where p.id != myHex { currentSeeds[p.id] = "nostr:" + p.id }
if currentSeeds == nostrPaletteSeeds,
nostrPaletteLight.keys.count == currentSeeds.count,
nostrPaletteDark.keys.count == currentSeeds.count {
return
}
nostrPaletteSeeds = currentSeeds
let slotCount = max(8, TransportConfig.uiPeerPaletteSlots)
let avoidCenter = 30.0 / 360.0
let avoidDelta = TransportConfig.uiColorHueAvoidanceDelta
var slots: [Double] = []
for i in 0..<slotCount {
let hue = Double(i) / Double(slotCount)
if abs(hue - avoidCenter) < avoidDelta { continue }
slots.append(hue)
}
if slots.isEmpty {
for i in 0..<slotCount { slots.append(Double(i) / Double(slotCount)) }
}
func circDist(_ a: Double, _ b: Double) -> Double {
let d = abs(a - b)
return d > 0.5 ? 1.0 - d : d
}
let peers = currentSeeds.keys.sorted()
let prefIndex: [String: Int] = Dictionary(uniqueKeysWithValues: peers.map { id in
let h = (currentSeeds[id] ?? id).djb2()
let idx = Int(h % UInt64(slots.count))
return (id, idx)
})
var mapping: [String: (slot: Int, ring: Int, hue: Double)] = [:]
var usedSlots = Set<Int>()
var usedHues: [Double] = []
let prev = nostrPaletteLight.isEmpty ? nostrPaletteDark : nostrPaletteLight
for (id, entry) in prev {
if peers.contains(id), entry.slot < slots.count {
mapping[id] = (entry.slot, entry.ring, slots[entry.slot])
usedSlots.insert(entry.slot)
usedHues.append(slots[entry.slot])
}
}
let unassigned = peers.filter { mapping[$0] == nil }
for id in unassigned {
let preferred = prefIndex[id] ?? 0
if !usedSlots.contains(preferred) && preferred < slots.count {
mapping[id] = (preferred, 0, slots[preferred])
usedSlots.insert(preferred)
usedHues.append(slots[preferred])
continue
}
var bestSlot: Int? = nil
var bestScore: Double = -1
for sIdx in 0..<slots.count where !usedSlots.contains(sIdx) {
let hue = slots[sIdx]
let minDist = usedHues.isEmpty ? 1.0 : usedHues.map { circDist(hue, $0) }.min() ?? 1.0
let bias = 1.0 - (Double((abs(sIdx - (prefIndex[id] ?? 0)) % slots.count)) / Double(slots.count))
let score = minDist + 0.05 * bias
if score > bestScore { bestScore = score; bestSlot = sIdx }
}
if let s = bestSlot {
mapping[id] = (s, 0, slots[s])
usedSlots.insert(s)
usedHues.append(slots[s])
}
}
let stillUnassigned = peers.filter { mapping[$0] == nil }
if !stillUnassigned.isEmpty {
for (idx, id) in stillUnassigned.enumerated() {
let preferred = prefIndex[id] ?? 0
let goldenStep = 7
let s = (preferred + idx * goldenStep) % slots.count
mapping[id] = (s, 1, slots[s])
}
}
nostrPaletteLight = mapping
nostrPaletteDark = mapping
}
// Clear the current public channel's timeline (visible + persistent buffer)
@MainActor
func clearCurrentPublicTimeline() {
switch activeChannel {
case .mesh:
messages.removeAll()
meshTimeline.removeAll()
case .location(let ch):
messages.removeAll()
geoTimelines[ch.geohash] = []
}
}
private func trimPrivateChatMessagesIfNeeded(for peerID: String) {
// Handled by PrivateChatManager
}
// 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()
}
private func addPrivateMessage(_ message: BitchatMessage, for peerID: String) {
// Deprecated - messages are now added directly in didReceiveMessage to avoid double processing
}
// Update encryption status in appropriate places, not during view updates
@MainActor
private func updateEncryptionStatus(for peerID: String) {
let noiseService = meshService.getNoiseService()
if noiseService.hasEstablishedSession(with: peerID) {
if let fingerprint = getFingerprint(for: peerID) {
if verifiedFingerprints.contains(fingerprint) {
peerEncryptionStatus[peerID] = .noiseVerified
} else {
peerEncryptionStatus[peerID] = .noiseSecured
}
} else {
// Session established but no fingerprint yet
peerEncryptionStatus[peerID] = .noiseSecured
}
} 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: String) {
showingFingerprintFor = peerID
}
// MARK: - Peer Lookup Helpers
func getPeer(byID peerID: String) -> BitchatPeer? {
return peerIndex[peerID]
}
@MainActor
func getFingerprint(for peerID: String) -> String? {
return unifiedPeerService.getFingerprint(for: peerID)
}
//
// Helper to resolve nickname for a peer ID through various sources
@MainActor
func resolveNickname(for peerID: String) -> 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
let isHexID = peerID.allSatisfy { $0.isHexDigit }
if !isHexID {
// If it's already a nickname, just return it
return peerID
}
// 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.count)
let prefix = String(peerID.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: String) {
guard let fingerprint = getFingerprint(for: peerID) else { return }
// Update secure storage with verified status
identityManager.setVerified(fingerprint: fingerprint, verified: true)
// Update local set for UI
verifiedFingerprints.insert(fingerprint)
// Update encryption status after verification
updateEncryptionStatus(for: peerID)
}
@MainActor
func unverifyFingerprint(for peerID: String) {
guard let fingerprint = getFingerprint(for: peerID) else { return }
identityManager.setVerified(fingerprint: fingerprint, verified: false)
identityManager.forceSave()
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.id)
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 = keyData.hexEncodedString()
self.shortIDToNoiseKey[peerID] = stable
SecureLogger.debug("🗺️ Mapped short peerID to Noise key for header continuity: \(peerID) -> \(stable.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: String, 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)
identityManager.forceSave()
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: String, nickname: String, content: String, timestamp: Date) {
Task { @MainActor in
let normalized = content.trimmingCharacters(in: .whitespacesAndNewlines)
let publicMentions = parseMentions(from: normalized)
let msg = BitchatMessage(
id: UUID().uuidString,
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.id
// 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: - Peer Connection Events
func didConnectToPeer(_ peerID: String) {
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 = peer.noisePublicKey.hexEncodedString()
shortIDToNoiseKey[peerID] = noiseKeyHex
}
// Flush any queued messages for this peer via router
messageRouter.flushOutbox(for: peerID)
}
//
}
func didDisconnectFromPeer(_ peerID: String) {
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: String? = shortIDToNoiseKey[peerID]
if derivedStableKeyHex == nil,
let key = meshService.getNoiseService().getPeerPublicKeyData(peerID) {
derivedStableKeyHex = key.hexEncodedString()
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: [String]) {
// 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"
if !peers.isEmpty {
// Cancel any pending reset if peers are back
self.networkResetTimer?.invalidate()
self.networkResetTimer = nil
// Count mesh peers that are connected OR recently reachable via mesh relays
let meshPeers = peers.filter { peerID in
self.meshService.isPeerConnected(peerID) || self.meshService.isPeerReachable(peerID)
}
// Rising-edge only: previously zero peers, now > 0 peers
let currentPeerSet = Set(meshPeers)
let hadNone = self.recentlySeenPeers.isEmpty
if meshPeers.count > 0 && hadNone && !self.hasNotifiedNetworkAvailable {
self.hasNotifiedNetworkAvailable = true
self.lastNetworkNotificationTime = Date()
self.recentlySeenPeers = currentPeerSet
NotificationService.shared.sendNetworkAvailableNotification(peerCount: meshPeers.count)
SecureLogger.info("👥 Sent bitchatters nearby notification for \(meshPeers.count) mesh peers", category: .session)
}
} else {
// No peers — immediately reset to allow next rising-edge to notify
self.hasNotifiedNetworkAvailable = false
self.recentlySeenPeers.removeAll()
if self.networkResetTimer != nil {
self.networkResetTimer?.invalidate()
self.networkResetTimer = nil
}
SecureLogger.debug("⏳ Mesh empty — reset network notification state", category: .session)
}
// 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.id })
let staleIDs = unreadPrivateMessages.subtracting(currentPeerIDs)
if !staleIDs.isEmpty {
var idsToRemove: [String] = []
for staleID in staleIDs {
// Don't remove temporary Nostr peer IDs that have messages
if staleID.hasPrefix("nostr_") {
// 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.count == 64, staleID.allSatisfy({ $0.isHexDigit }) {
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()
}
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)
}
}
private func parseMentions(from content: String) -> [String] {
// Allow optional disambiguation suffix '#abcd' for duplicate nicknames
let pattern = "@([\\p{L}0-9_]+(?:#[a-fA-F0-9]{4})?)"
let regex = try? NSRegularExpression(pattern: pattern, options: [])
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.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
}
@MainActor
func handlePeerFavoritedUs(peerID: String, favorited: Bool, nickname: String, nostrNpub: String? = nil) {
// Get peer's noise public key
guard let noisePublicKey = Data(hexString: peerID) else { return }
// Decode npub to hex if provided
var nostrPublicKey: String? = nil
if let npub = nostrNpub {
do {
let (hrp, data) = try Bech32.decode(npub)
if hrp == "npub" {
nostrPublicKey = data.hexEncodedString()
}
} catch {
SecureLogger.error("Failed to decode Nostr npub: \(error)", category: .session)
}
}
// Update favorite status in persistence service
FavoritesPersistenceService.shared.updatePeerFavoritedUs(
peerNoisePublicKey: noisePublicKey,
favorited: favorited,
peerNickname: nickname,
peerNostrPublicKey: nostrPublicKey
)
// Update peer list to reflect the change
// UnifiedPeerService updates automatically via subscriptions
}
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)
}
private 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
private 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
private func addMeshOnlySystemMessage(_ content: String) {
let systemMessage = BitchatMessage(
sender: "system",
content: content,
timestamp: Date(),
isRelay: false
)
// Persist to mesh timeline
meshTimeline.append(systemMessage)
trimMeshTimelineIfNeeded()
// Only show inline if mesh is the active channel
if case .mesh = activeChannel {
messages.append(systemMessage)
}
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
)
// Append to current visible messages
messages.append(systemMessage)
// Persist into the backing store for the active channel to survive rebinds
switch activeChannel {
case .mesh:
meshTimeline.append(systemMessage)
case .location(let ch):
var arr = geoTimelines[ch.geohash] ?? []
arr.append(systemMessage)
geoTimelines[ch.geohash] = arr
}
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
pendingGeohashSystemMessages.append(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 NostrIdentityBridge.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: [])
}
// MARK: - Simplified Nostr Integration (Inlined from MessageRouter)
//
@MainActor
private func setupNostrMessageHandling() {
guard let currentIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else {
SecureLogger.warning("⚠️ No Nostr identity available for message handling", category: .session)
return
}
SecureLogger.debug("🔑 Setting up Nostr subscription for pubkey: \(currentIdentity.publicKeyHex.prefix(16))...", category: .session)
// Subscribe to Nostr messages
let filter = NostrFilter.giftWrapsFor(
pubkey: currentIdentity.publicKeyHex,
since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds) // Last 24 hours
)
nostrRelayManager?.subscribe(filter: filter, id: "chat-messages") { [weak self] event in
Task { @MainActor in
self?.handleNostrMessage(event)
}
}
}
@MainActor
private func handleNostrMessage(_ giftWrap: NostrEvent) {
// Simple deduplication
if processedNostrEvents.contains(giftWrap.id) { return }
processedNostrEvents.insert(giftWrap.id)
// Client-side filtering: ignore messages older than 24 hours
// Add 15 minutes buffer since gift wrap timestamps are randomized ±15 minutes
let messageAge = Date().timeIntervalSince1970 - TimeInterval(giftWrap.created_at)
if messageAge > 87300 { // 24 hours + 15 minutes
return // Ignoring old message
}
// Processing Nostr message
guard let currentIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
do {
let (content, senderPubkey, rumorTimestamp) = try NostrProtocol.decryptPrivateMessage(
giftWrap: giftWrap,
recipientIdentity: currentIdentity
)
// Expect embedded BitChat packet content
guard content.hasPrefix("bitchat1:") else {
SecureLogger.debug("Ignoring non-embedded Nostr DM content", category: .session)
return
}
guard let packetData = Self.base64URLDecode(String(content.dropFirst("bitchat1:".count))),
let packet = BitchatPacket.from(packetData) else {
SecureLogger.error("Failed to decode embedded BitChat packet from Nostr DM", category: .session)
return
}
// Only process typed noiseEncrypted envelope for private messages/receipts
guard packet.type == MessageType.noiseEncrypted.rawValue else {
SecureLogger.warning("Unsupported embedded packet type: \(packet.type)", category: .session)
return
}
// Validate recipient
if let rid = packet.recipientID {
let ridHex = rid.map { String(format: "%02x", $0) }.joined()
if ridHex != meshService.myPeerID {
return
}
}
// Parse plaintext typed payload
guard let noisePayload = NoisePayload.decode(packet.payload) else {
SecureLogger.error("Failed to parse embedded NoisePayload", category: .session)
return
}
// Map sender by Nostr pubkey to Noise key when possible
let senderNoiseKey = findNoiseKey(for: senderPubkey)
let actualSenderNoiseKey = senderNoiseKey // may be nil
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTimestamp))
let senderNickname = (actualSenderNoiseKey != nil) ? (FavoritesPersistenceService.shared.getFavoriteStatus(for: actualSenderNoiseKey!)?.peerNickname ?? "Unknown") : "Unknown"
// Stable target ID if we know Noise key; otherwise temporary Nostr-based peer
let targetPeerID = actualSenderNoiseKey?.hexEncodedString() ?? ("nostr_" + senderPubkey.prefix(TransportConfig.nostrConvKeyPrefixLength))
switch noisePayload.type {
case .privateMessage:
guard let pm = PrivateMessagePacket.decode(from: noisePayload.data) else { return }
let messageId = pm.messageID
let messageContent = pm.content
// Favorite/unfavorite notifications embedded as private messages
if messageContent.hasPrefix("[FAVORITED]") || messageContent.hasPrefix("[UNFAVORITED]") {
if let key = actualSenderNoiseKey {
handleFavoriteNotificationFromMesh(messageContent, from: key.hexEncodedString(), senderNickname: senderNickname)
}
return
}
// Check for duplicate
var messageExistsLocally = false
if privateChats[targetPeerID]?.contains(where: { $0.id == messageId }) == true {
messageExistsLocally = true
}
if !messageExistsLocally {
for (_, messages) in privateChats {
if messages.contains(where: { $0.id == messageId }) { messageExistsLocally = true; break }
}
}
if messageExistsLocally { return }
let wasReadBefore = sentReadReceipts.contains(messageId)
// Is viewing?
var isViewingThisChat = false
if selectedPrivateChatPeer == targetPeerID {
isViewingThisChat = true
} else if let selectedPeer = selectedPrivateChatPeer,
let selectedPeerData = unifiedPeerService.getPeer(by: selectedPeer),
let key = actualSenderNoiseKey,
selectedPeerData.noisePublicKey == key {
isViewingThisChat = true
}
// Recency check
let isRecentMessage = Date().timeIntervalSince(messageTimestamp) < 30
let shouldMarkAsUnread = !wasReadBefore && !isViewingThisChat && (isRecentMessage || !isStartupPhase)
let message = BitchatMessage(
id: messageId,
sender: senderNickname,
content: messageContent,
timestamp: messageTimestamp,
isRelay: false,
originalSender: nil,
isPrivate: true,
recipientNickname: nickname,
senderPeerID: targetPeerID,
mentions: nil,
deliveryStatus: .delivered(to: nickname, at: Date())
)
if privateChats[targetPeerID] == nil { privateChats[targetPeerID] = [] }
if let idx = privateChats[targetPeerID]?.firstIndex(where: { $0.id == messageId }) {
privateChats[targetPeerID]?[idx] = message
} else {
privateChats[targetPeerID]?.append(message)
}
// Sanitize to avoid duplicate IDs
privateChatManager.sanitizeChat(for: targetPeerID)
trimPrivateChatMessagesIfNeeded(for: targetPeerID)
// Mirror to ephemeral if applicable
if let key = actualSenderNoiseKey,
let ephemeralPeerID = unifiedPeerService.peers.first(where: { $0.noisePublicKey == key })?.id,
ephemeralPeerID != targetPeerID {
if privateChats[ephemeralPeerID] == nil { privateChats[ephemeralPeerID] = [] }
if let idx = privateChats[ephemeralPeerID]?.firstIndex(where: { $0.id == messageId }) {
privateChats[ephemeralPeerID]?[idx] = message
} else {
privateChats[ephemeralPeerID]?.append(message)
trimPrivateChatMessagesIfNeeded(for: ephemeralPeerID)
}
privateChatManager.sanitizeChat(for: ephemeralPeerID)
}
// Send delivery ack via Nostr embedded
if !wasReadBefore {
if let key = actualSenderNoiseKey {
SecureLogger.debug("Sending DELIVERED ack for \(messageId.prefix(8))… via router", category: .session)
messageRouter.sendDeliveryAck(messageId, to: key.hexEncodedString())
} else if let id = try? NostrIdentityBridge.getCurrentNostrIdentity() {
// Fallback: no Noise mapping yet — send directly to sender's Nostr pubkey
let nt = NostrTransport(keychain: keychain)
nt.senderPeerID = meshService.myPeerID
nt.sendDeliveryAckGeohash(for: messageId, toRecipientHex: senderPubkey, from: id)
SecureLogger.debug("Sent DELIVERED ack directly to Nostr pub=\(senderPubkey.prefix(8))… for mid=\(messageId.prefix(8))…", category: .session)
}
}
if wasReadBefore {
// do nothing
} else if isViewingThisChat {
unreadPrivateMessages.remove(targetPeerID)
if let key = actualSenderNoiseKey,
let ephemeralPeerID = unifiedPeerService.peers.first(where: { $0.noisePublicKey == key })?.id {
unreadPrivateMessages.remove(ephemeralPeerID)
}
if !sentReadReceipts.contains(messageId) {
if let key = actualSenderNoiseKey {
let receipt = ReadReceipt(originalMessageID: messageId, readerID: meshService.myPeerID, readerNickname: nickname)
SecureLogger.debug("Viewing chat; sending READ ack for \(messageId.prefix(8))… via router", category: .session)
messageRouter.sendReadReceipt(receipt, to: key.hexEncodedString())
sentReadReceipts.insert(messageId)
} else if let id = try? NostrIdentityBridge.getCurrentNostrIdentity() {
let nt = NostrTransport(keychain: keychain)
nt.senderPeerID = meshService.myPeerID
nt.sendReadReceiptGeohash(messageId, toRecipientHex: senderPubkey, from: id)
sentReadReceipts.insert(messageId)
SecureLogger.debug("Viewing chat; sent READ ack directly to Nostr pub=\(senderPubkey.prefix(8))… for mid=\(messageId.prefix(8))…", category: .session)
}
}
} else {
if shouldMarkAsUnread {
unreadPrivateMessages.insert(targetPeerID)
if let key = actualSenderNoiseKey,
let ephemeralPeerID = unifiedPeerService.peers.first(where: { $0.noisePublicKey == key })?.id,
ephemeralPeerID != targetPeerID {
unreadPrivateMessages.insert(ephemeralPeerID)
}
if isRecentMessage {
NotificationService.shared.sendPrivateMessageNotification(
from: senderNickname,
message: messageContent,
peerID: targetPeerID
)
}
}
}
objectWillChange.send()
case .delivered:
guard let messageID = String(data: noisePayload.data, encoding: .utf8) else { return }
let peerName = senderNickname
// Update status to delivered
if let messages = privateChats[targetPeerID], let idx = messages.firstIndex(where: { $0.id == messageID }) {
privateChats[targetPeerID]?[idx].deliveryStatus = .delivered(to: peerName, at: Date())
objectWillChange.send()
}
case .readReceipt:
guard let messageID = String(data: noisePayload.data, encoding: .utf8) else { return }
let peerName = senderNickname
if let messages = privateChats[targetPeerID], let idx = messages.firstIndex(where: { $0.id == messageID }) {
privateChats[targetPeerID]?[idx].deliveryStatus = .read(by: peerName, at: Date())
objectWillChange.send()
}
case .verifyChallenge, .verifyResponse:
// Ignore verification payloads arriving via Nostr path for now
break
}
} catch {
SecureLogger.error("Failed to decrypt Nostr message: \(error)", category: .session)
}
}
@MainActor
private func handleFavoriteNotification(content: String, from nostrPubkey: String) {
let isFavorite = content.hasPrefix("FAVORITED")
guard let senderNoiseKey = findNoiseKey(for: nostrPubkey) else { return }
FavoritesPersistenceService.shared.updatePeerFavoritedUs(
peerNoisePublicKey: senderNoiseKey,
favorited: isFavorite,
peerNostrPublicKey: nostrPubkey
)
}
@MainActor
private func handleNostrAcknowledgment(content: String, from senderPubkey: String) {
// Parse ACK format: "ACK:TYPE:MESSAGE_ID"
let parts = content.split(separator: ":", maxSplits: 2)
guard parts.count >= 3 else {
SecureLogger.warning("⚠️ Invalid ACK format: \(content)", category: .session)
return
}
let ackType = String(parts[1])
let messageId = String(parts[2])
// Check if we've already processed this ACK
let ackKey = "\(messageId):\(ackType):\(senderPubkey)"
if processedNostrAcks.contains(ackKey) {
// Skip duplicate ACK
return
}
processedNostrAcks.insert(ackKey)
SecureLogger.debug("📨 Received \(ackType) ACK for message \(messageId.prefix(16))... from \(senderPubkey.prefix(16))...", category: .session)
// Verify the sender has a valid Noise key
guard findNoiseKey(for: senderPubkey) != nil else {
// Cannot find Noise key for ACK sender
return
}
// Find and update the message status in ALL private chats (both stable and ephemeral)
var messageFound = false
for (chatPeerID, messages) in privateChats {
if let index = messages.firstIndex(where: { $0.id == messageId }) {
// Update delivery status based on ACK type
switch ackType {
case "DELIVERED":
privateChats[chatPeerID]?[index].deliveryStatus = .delivered(to: "recipient", at: Date())
case "READ":
privateChats[chatPeerID]?[index].deliveryStatus = .read(by: "recipient", at: Date())
default:
SecureLogger.warning("⚠️ Unknown ACK type: \(ackType)", category: .session)
}
messageFound = true
SecureLogger.info("✅ Updated message \(messageId.prefix(16))... status to \(ackType) in chat \(chatPeerID.prefix(16))...", category: .session)
// Don't break - continue to update in all chats where this message exists
}
}
if messageFound {
objectWillChange.send()
} else {
SecureLogger.warning("⚠️ Could not find message \(messageId) to update status from ACK", category: .session)
}
}
// MARK: - Base64URL utils
private 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)
}
//
@MainActor
private func handleFavoriteNotificationFromMesh(_ content: String, from peerID: String, senderNickname: String) {
// Parse the message format: "[FAVORITED]:npub..." or "[UNFAVORITED]:npub..."
let isFavorite = content.hasPrefix("[FAVORITED]")
let parts = content.split(separator: ":")
// Extract Nostr public key if included
var nostrPubkey: String? = nil
if parts.count > 1 {
nostrPubkey = String(parts[1])
SecureLogger.info("📝 Received Nostr npub in favorite notification: \(nostrPubkey ?? "none")", category: .session)
}
// Get the noise public key for this peer
// Try both ephemeral ID and if that fails, get from peer service
var noiseKey: Data? = nil
// First try as hex-encoded Noise key (64 chars)
if peerID.count == 64 {
noiseKey = Data(hexString: peerID)
}
// If not a hex key, get from peer service (ephemeral ID)
if noiseKey == nil, let peer = unifiedPeerService.getPeer(by: peerID) {
noiseKey = peer.noisePublicKey
}
guard let finalNoiseKey = noiseKey else {
SecureLogger.warning("⚠️ Cannot get Noise key for peer \(peerID)", category: .session)
return
}
// Determine prior state to avoid duplicate system messages on repeated notifications
let prior = FavoritesPersistenceService.shared.getFavoriteStatus(for: finalNoiseKey)?.theyFavoritedUs ?? false
// Update the favorite relationship (idempotent storage)
FavoritesPersistenceService.shared.updatePeerFavoritedUs(
peerNoisePublicKey: finalNoiseKey,
favorited: isFavorite,
peerNickname: senderNickname,
peerNostrPublicKey: nostrPubkey
)
// If they favorited us and provided their Nostr key, ensure it's stored (log only)
if isFavorite && nostrPubkey != nil {
SecureLogger.info("💾 Storing Nostr key association for \(senderNickname): \(nostrPubkey!.prefix(16))...", category: .session)
}
// Only show a system message when the state changes, and only in mesh
if prior != isFavorite {
let action = isFavorite ? "favorited" : "unfavorited"
addMeshOnlySystemMessage("\(senderNickname) \(action) you")
}
}
@MainActor
private func handleNostrMessageFromUnknownSender(
messageId: String,
content: String,
senderPubkey: String,
senderNickname: String? = nil,
timestamp: Date
) {
// Check if we already have this message in local storage
for (_, messages) in privateChats {
if messages.contains(where: { $0.id == messageId }) {
return // Skipping duplicate message
}
}
// Check if we've read this message before (in a previous session)
let wasReadBefore = sentReadReceipts.contains(messageId)
// Try to find sender by checking all known peers for nickname matches
// This is a fallback when we receive Nostr messages from someone not in favorites
// For now, create a temporary peer ID based on Nostr pubkey
// This allows the message to be displayed even without Noise key mapping
let tempPeerID = "nostr_" + senderPubkey.prefix(TransportConfig.nostrConvKeyPrefixLength)
// Check if we're viewing this unknown sender's chat
let isViewingThisChat = selectedPrivateChatPeer == tempPeerID
// Check if message is recent (less than 30 seconds old)
let messageAgeSeconds = Date().timeIntervalSince(timestamp)
let isRecentMessage = messageAgeSeconds < 30
// Determine if we should mark as unread BEFORE adding to chats
// During startup phase, only block OLD messages from being marked as unread
// Recent messages should always be marked as unread if not previously read
let shouldMarkAsUnread = !wasReadBefore && !isViewingThisChat && (isRecentMessage || !isStartupPhase)
// Use provided nickname or try to extract from previous messages
var finalSenderNickname = senderNickname ?? "Unknown"
// If no nickname provided, check if we have any previous messages from this Nostr key
if senderNickname == nil {
for (_, messages) in privateChats {
if let previousMessage = messages.first(where: {
$0.senderPeerID == tempPeerID
}) {
finalSenderNickname = previousMessage.sender
break
}
}
}
// Create the message
let message = BitchatMessage(
id: messageId,
sender: finalSenderNickname,
content: content,
timestamp: timestamp,
isRelay: false,
originalSender: nil,
isPrivate: true,
recipientNickname: nickname,
senderPeerID: tempPeerID,
mentions: nil,
deliveryStatus: .delivered(to: nickname, at: Date())
)
// Store in private chats
if privateChats[tempPeerID] == nil {
privateChats[tempPeerID] = []
}
privateChats[tempPeerID]?.append(message)
trimPrivateChatMessagesIfNeeded(for: tempPeerID)
// For unknown senders (no Noise key), skip sending Nostr ACKs
// Handle based on read status
if wasReadBefore {
// Message was read in a previous session - don't mark as unread or notify
// Not marking previously-read message as unread
} else if isViewingThisChat {
// Viewing this chat - mark as read
// No read ACKs for unknown senders
} else {
// Not viewing and not previously read
// Use pre-calculated shouldMarkAsUnread to avoid UI flicker
if shouldMarkAsUnread {
unreadPrivateMessages.insert(tempPeerID)
// Only notify if it's a recent message
if isRecentMessage {
NotificationService.shared.sendPrivateMessageNotification(
from: finalSenderNickname,
message: content,
peerID: tempPeerID
)
} else {
// Not notifying for old message
}
}
// Not notifying for old message
}
SecureLogger.info("📬 Stored Nostr message from unknown sender \(finalSenderNickname) in temporary peer \(tempPeerID)", category: .session)
}
@MainActor
private func findNoiseKey(for nostrPubkey: String) -> Data? {
// Convert hex to npub if needed for comparison
let npubToMatch: String
if nostrPubkey.hasPrefix("npub") {
npubToMatch = nostrPubkey
} else {
// Try to convert hex to npub
guard let pubkeyData = Data(hexString: nostrPubkey) else {
SecureLogger.warning("⚠️ Invalid hex public key format: \(nostrPubkey.prefix(16))...", category: .session)
return nil
}
do {
npubToMatch = try Bech32.encode(hrp: "npub", data: pubkeyData)
} catch {
SecureLogger.warning("⚠️ Failed to convert hex to npub: \(error)", category: .session)
return nil
}
}
// Search through favorites for matching Nostr pubkey
for (noiseKey, relationship) in FavoritesPersistenceService.shared.favorites {
if let storedNostrKey = relationship.peerNostrPublicKey {
// Compare npub format
if storedNostrKey == npubToMatch {
// SecureLogger.debug("✅ Found Noise key for Nostr sender (npub match)", category: .session)
return noiseKey
}
// Also try hex comparison if stored value is hex
if !storedNostrKey.hasPrefix("npub") && storedNostrKey == nostrPubkey {
SecureLogger.debug("✅ Found Noise key for Nostr sender (hex match)", category: .session)
return noiseKey
}
}
}
SecureLogger.debug("⚠️ No matching Noise key found for Nostr pubkey: \(nostrPubkey.prefix(16))... (tried npub: \(npubToMatch.prefix(16))...)", category: .session)
return nil
}
@MainActor
private func sendFavoriteNotificationViaNostr(noisePublicKey: Data, isFavorite: Bool) {
let peerIDHex = noisePublicKey.hexEncodedString()
messageRouter.sendFavoriteNotification(to: peerIDHex, isFavorite: isFavorite)
}
@MainActor
func sendFavoriteNotification(to peerID: String, isFavorite: Bool) {
// Handle both ephemeral peer IDs and Noise key hex strings
var noiseKey: Data?
// First check if peerID is a hex-encoded Noise key
if let hexKey = Data(hexString: peerID) {
noiseKey = hexKey
} else {
// It's an ephemeral peer ID, get the Noise key from UnifiedPeerService
if let peer = unifiedPeerService.getPeer(by: peerID) {
noiseKey = peer.noisePublicKey
}
}
// Try mesh first for connected peers
if meshService.isPeerConnected(peerID) {
messageRouter.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
SecureLogger.debug("📤 Sent favorite notification via BLE to \(peerID)", category: .session)
} else if let key = noiseKey {
// Send via Nostr for offline peers (using router)
let recipientPeerID = key.hexEncodedString()
messageRouter.sendFavoriteNotification(to: recipientPeerID, isFavorite: isFavorite)
} else {
SecureLogger.warning("⚠️ Cannot send favorite notification - peer not connected and no Nostr pubkey", category: .session)
}
}
// MARK: - Message Processing Helpers
/// Check if a message should be blocked based on sender
@MainActor
private func isMessageBlocked(_ message: BitchatMessage) -> Bool {
if let peerID = message.senderPeerID ?? getPeerIDForNickname(message.sender) {
// Check mesh/known peers first
if isPeerBlocked(peerID) { return true }
// Check geohash (Nostr) blocks using mapping to full pubkey
if peerID.hasPrefix("nostr") {
if let full = nostrKeyMapping[peerID]?.lowercased() {
if identityManager.isNostrBlocked(pubkeyHexLowercased: full) { return true }
}
}
return false
}
return false
}
// MARK: - Geohash Nickname Resolution (for /block in geohash)
@MainActor
func nostrPubkeyForDisplayName(_ name: String) -> String? {
// Look up current visible geohash participants for an exact displayName match
for p in visibleGeohashPeople() {
if p.displayName == name { return p.id }
}
return nil
}
/// Process action messages (hugs, slaps) into system messages
private func processActionMessage(_ message: BitchatMessage) -> BitchatMessage {
let isActionMessage = message.content.hasPrefix("* ") && message.content.hasSuffix(" *") &&
(message.content.contains("🫂") || message.content.contains("🐟") ||
message.content.contains("took a screenshot"))
if isActionMessage {
return BitchatMessage(
id: message.id,
sender: "system",
content: String(message.content.dropFirst(2).dropLast(2)), // Remove * * wrapper
timestamp: message.timestamp,
isRelay: message.isRelay,
originalSender: message.originalSender,
isPrivate: message.isPrivate,
recipientNickname: message.recipientNickname,
senderPeerID: message.senderPeerID,
mentions: message.mentions,
deliveryStatus: message.deliveryStatus
)
}
return message
}
/// Migrate private chats when peer reconnects with new ID
@MainActor
private func migratePrivateChatsIfNeeded(for peerID: String, senderNickname: String) {
let currentFingerprint = getFingerprint(for: peerID)
if privateChats[peerID] == nil || privateChats[peerID]?.isEmpty == true {
var migratedMessages: [BitchatMessage] = []
var oldPeerIDsToRemove: [String] = []
// Only migrate messages from the last 24 hours to prevent old messages from flooding
let cutoffTime = Date().addingTimeInterval(-TransportConfig.uiMigrationCutoffSeconds)
for (oldPeerID, messages) in privateChats {
if oldPeerID != peerID {
let oldFingerprint = peerIDToPublicKeyFingerprint[oldPeerID]
// Filter messages to only recent ones
let recentMessages = messages.filter { $0.timestamp > cutoffTime }
// Skip if no recent messages
guard !recentMessages.isEmpty else { continue }
// Check fingerprint match first (most reliable)
if let currentFp = currentFingerprint,
let oldFp = oldFingerprint,
currentFp == oldFp {
migratedMessages.append(contentsOf: recentMessages)
// Only remove old peer ID if we migrated ALL its messages
if recentMessages.count == messages.count {
oldPeerIDsToRemove.append(oldPeerID)
} else {
// Keep old messages in original location but don't show in UI
SecureLogger.info("📦 Partially migrating \(recentMessages.count) of \(messages.count) messages from \(oldPeerID)", category: .session)
}
SecureLogger.info("📦 Migrating \(recentMessages.count) recent messages from old peer ID \(oldPeerID) to \(peerID) (fingerprint match)", category: .session)
} else if currentFingerprint == nil || oldFingerprint == nil {
// Check if this chat contains messages with this sender by nickname
let isRelevantChat = recentMessages.contains { msg in
(msg.sender == senderNickname && msg.sender != nickname) ||
(msg.sender == nickname && msg.recipientNickname == senderNickname)
}
if isRelevantChat {
migratedMessages.append(contentsOf: recentMessages)
// Only remove if all messages were migrated
if recentMessages.count == messages.count {
oldPeerIDsToRemove.append(oldPeerID)
}
SecureLogger.warning("📦 Migrating \(recentMessages.count) recent messages from old peer ID \(oldPeerID) to \(peerID) (nickname match)", category: .session)
}
}
}
}
// Remove old peer ID entries
if !oldPeerIDsToRemove.isEmpty {
// Track if we need to update selectedPrivateChatPeer
let needsSelectedUpdate = oldPeerIDsToRemove.contains { selectedPrivateChatPeer == $0 }
// Directly modify privateChats to minimize UI disruption
for oldPeerID in oldPeerIDsToRemove {
privateChats.removeValue(forKey: oldPeerID)
unreadPrivateMessages.remove(oldPeerID)
}
// Add or update messages for the new peer ID
if var existingMessages = privateChats[peerID] {
// Merge with existing messages, replace-by-id semantics
for msg in migratedMessages {
if let i = existingMessages.firstIndex(where: { $0.id == msg.id }) {
existingMessages[i] = msg
} else {
existingMessages.append(msg)
}
}
existingMessages.sort { $0.timestamp < $1.timestamp }
privateChats[peerID] = existingMessages
} else {
// Initialize with migrated messages
privateChats[peerID] = migratedMessages
}
trimPrivateChatMessagesIfNeeded(for: peerID)
privateChatManager.sanitizeChat(for: peerID)
// Update selectedPrivateChatPeer if it was pointing to an old ID
if needsSelectedUpdate {
selectedPrivateChatPeer = peerID
SecureLogger.info("📱 Updated selectedPrivateChatPeer from old ID to \(peerID) during migration", category: .session)
}
}
}
}
/// Handle incoming private message
@MainActor
private func handlePrivateMessage(_ message: BitchatMessage) {
SecureLogger.debug("📥 handlePrivateMessage called for message from \(message.sender)", category: .session)
let senderPeerID = message.senderPeerID ?? getPeerIDForNickname(message.sender)
guard let peerID = senderPeerID else {
SecureLogger.warning("⚠️ Could not get peer ID for sender \(message.sender)", category: .session)
return
}
// Check if this is a favorite/unfavorite notification
if message.content.hasPrefix("[FAVORITED]") || message.content.hasPrefix("[UNFAVORITED]") {
handleFavoriteNotificationFromMesh(message.content, from: peerID, senderNickname: message.sender)
return // Don't store as a regular message
}
// Migrate chats if needed
migratePrivateChatsIfNeeded(for: peerID, senderNickname: message.sender)
// IMPORTANT: Also consolidate messages from stable Noise key if this is an ephemeral peer
// This ensures Nostr messages appear in BLE chats
if peerID.count == 16 { // This is an ephemeral peer ID (8 bytes = 16 hex chars)
if let peer = unifiedPeerService.getPeer(by: peerID) {
let stableKeyHex = peer.noisePublicKey.hexEncodedString()
// If we have messages stored under the stable key, merge them
if stableKeyHex != peerID, let nostrMessages = privateChats[stableKeyHex], !nostrMessages.isEmpty {
// Merge messages from stable key into ephemeral peer ID storage
if privateChats[peerID] == nil {
privateChats[peerID] = []
}
// Add any messages that aren't already in the ephemeral storage
let existingMessageIds = Set(privateChats[peerID]?.map { $0.id } ?? [])
for nostrMessage in nostrMessages {
if !existingMessageIds.contains(nostrMessage.id) {
privateChats[peerID]?.append(nostrMessage)
}
}
// Sort by timestamp
privateChats[peerID]?.sort { $0.timestamp < $1.timestamp }
// Clean up the stable key storage to avoid duplication
privateChats.removeValue(forKey: stableKeyHex)
SecureLogger.info("📥 Consolidated \(nostrMessages.count) Nostr messages from stable key to ephemeral peer \(peerID)", category: .session)
}
}
}
// Initialize chat if needed
if privateChats[peerID] == nil {
var chats = privateChats
chats[peerID] = []
privateChats = chats
}
// Fix delivery status for incoming messages
var messageToStore = message
if message.sender != nickname {
if messageToStore.deliveryStatus == nil || messageToStore.deliveryStatus == .sending {
messageToStore.deliveryStatus = .delivered(to: nickname, at: Date())
}
}
// Process action messages
messageToStore = processActionMessage(messageToStore)
// Store message
var chats = privateChats
chats[peerID]?.append(messageToStore)
privateChats = chats
trimPrivateChatMessagesIfNeeded(for: peerID)
// UI updates via @Published reassignment above
// Handle fingerprint-based chat updates
if let chatFingerprint = selectedPrivateChatFingerprint,
let senderFingerprint = peerIDToPublicKeyFingerprint[peerID],
chatFingerprint == senderFingerprint && selectedPrivateChatPeer != peerID {
selectedPrivateChatPeer = peerID
}
updatePrivateChatPeerIfNeeded()
// Handle notifications and read receipts
// Check if we should send notification (only for truly unread and recent messages)
if selectedPrivateChatPeer != peerID {
unreadPrivateMessages.insert(peerID)
// Avoid notifying for messages that have been marked read already (resubscribe/dup cases)
if !sentReadReceipts.contains(message.id) {
NotificationService.shared.sendPrivateMessageNotification(
from: message.sender,
message: message.content,
peerID: peerID
)
}
} else {
// User is viewing this chat - no notification needed
unreadPrivateMessages.remove(peerID)
// Also clean up any old peer IDs from unread set that no longer exist
// This prevents stale unread indicators
cleanupStaleUnreadPeerIDs()
// Send read receipt if needed
if !sentReadReceipts.contains(message.id) {
let receipt = ReadReceipt(
originalMessageID: message.id,
readerID: meshService.myPeerID,
readerNickname: nickname
)
let recipientID = message.senderPeerID ?? peerID
Task { @MainActor in
var originalTransport: String? = nil
if let noiseKey = Data(hexString: recipientID),
let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
favoriteStatus.peerNostrPublicKey != nil,
self.meshService.peerNickname(peerID: recipientID) == nil {
originalTransport = "nostr"
}
self.sendReadReceipt(receipt, to: recipientID, originalTransport: originalTransport)
}
sentReadReceipts.insert(message.id)
}
// Mark other messages as read
DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiReadReceiptRetryShortSeconds) { [weak self] in
self?.markPrivateMessagesAsRead(from: peerID)
}
}
}
/// Handle incoming public message
@MainActor
private 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?.hasPrefix("nostr:") == true
// Apply per-sender and per-content rate limits (drop if exceeded)
if finalMessage.sender != "system" {
let senderKey = normalizedSenderKey(for: finalMessage)
let contentKey = normalizedContentKey(finalMessage.content)
let now = Date()
var sBucket = rateBucketsBySender[senderKey] ?? TokenBucket(capacity: senderBucketCapacity, tokens: senderBucketCapacity, refillPerSec: senderBucketRefill, lastRefill: now)
let senderAllowed = sBucket.allow(now: now)
rateBucketsBySender[senderKey] = sBucket
var cBucket = rateBucketsByContent[contentKey] ?? TokenBucket(capacity: contentBucketCapacity, tokens: contentBucketCapacity, refillPerSec: contentBucketRefill, lastRefill: now)
let contentAllowed = cBucket.allow(now: now)
rateBucketsByContent[contentKey] = cBucket
if !(senderAllowed && contentAllowed) { 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" {
meshTimeline.append(finalMessage)
trimMeshTimelineIfNeeded()
}
// Persist geochat messages to per-geohash timeline
if isGeo && finalMessage.sender != "system" {
if let gh = currentGeohash {
var arr = geoTimelines[gh] ?? []
// Dedup by message ID before appending to per-geohash timeline
if !arr.contains(where: { $0.id == finalMessage.id }) {
arr.append(finalMessage)
if arr.count > geoTimelineCap { arr = Array(arr.suffix(geoTimelineCap)) }
geoTimelines[gh] = arr
}
}
}
// 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 }) {
enqueuePublic(finalMessage)
}
}
}
// MARK: - Public message batching helpers
@MainActor
private func enqueuePublic(_ message: BitchatMessage) {
publicBuffer.append(message)
schedulePublicFlush()
}
@MainActor
private func schedulePublicFlush() {
if publicBufferTimer != nil { return }
publicBufferTimer = Timer.scheduledTimer(timeInterval: dynamicPublicFlushInterval,
target: self,
selector: #selector(onPublicBufferTimerFired(_:)),
userInfo: nil,
repeats: false)
}
@MainActor
private func flushPublicBuffer() {
publicBufferTimer?.invalidate()
publicBufferTimer = nil
guard !publicBuffer.isEmpty else { return }
// Dedup against existing by id and near-duplicate messages by content (within ~1s), across senders
var seenIDs = Set(messages.map { $0.id })
var added: [BitchatMessage] = []
var batchContentLatest: [String: Date] = [:]
for m in publicBuffer {
if seenIDs.contains(m.id) { continue }
let ckey = normalizedContentKey(m.content)
if let ts = contentLRUMap[ckey], abs(ts.timeIntervalSince(m.timestamp)) < 1.0 { continue }
if let ts = batchContentLatest[ckey], abs(ts.timeIntervalSince(m.timestamp)) < 1.0 { continue }
seenIDs.insert(m.id)
added.append(m)
batchContentLatest[ckey] = m.timestamp
}
publicBuffer.removeAll(keepingCapacity: true)
guard !added.isEmpty else { return }
// Indicate batching for conditional UI animations
isBatchingPublic = true
// Rough chronological order: sort the batch by timestamp before inserting
added.sort { $0.timestamp < $1.timestamp }
// Channel-aware insertion policy: geohash uses strict ordering; mesh allows small out-of-order appends
let threshold: TimeInterval = {
switch activeChannel {
case .location: return TransportConfig.uiLateInsertThresholdGeo
case .mesh: return TransportConfig.uiLateInsertThreshold
}
}()
let lastTs = messages.last?.timestamp ?? .distantPast
for m in added {
if m.timestamp < lastTs.addingTimeInterval(-threshold) {
let idx = insertionIndexByTimestamp(m.timestamp)
if idx >= messages.count { messages.append(m) } else { messages.insert(m, at: idx) }
} else if threshold == 0 {
// Strict ordering for geohash: always insert by timestamp
let idx = insertionIndexByTimestamp(m.timestamp)
if idx >= messages.count { messages.append(m) } else { messages.insert(m, at: idx) }
} else {
messages.append(m)
}
// Record content key for LRU
let ckey = normalizedContentKey(m.content)
recordContentKey(ckey, timestamp: m.timestamp)
}
trimMessagesIfNeeded()
// Update batch size stats and adjust interval
recentBatchSizes.append(added.count)
if recentBatchSizes.count > 10 { recentBatchSizes.removeFirst(recentBatchSizes.count - 10) }
let avg = recentBatchSizes.isEmpty ? 0.0 : Double(recentBatchSizes.reduce(0, +)) / Double(recentBatchSizes.count)
dynamicPublicFlushInterval = avg > 100.0 ? 0.12 : basePublicFlushInterval
// Prewarm formatting cache for current UI color scheme only
for m in added {
_ = self.formatMessageAsText(m, colorScheme: currentColorScheme)
}
// Reset batching flag (already on main actor)
isBatchingPublic = false
// If new items arrived during this flush, coalesce by flushing once more next tick
if !publicBuffer.isEmpty { schedulePublicFlush() }
}
// Timer selector to avoid @Sendable closure capture issues under Swift 6
@MainActor @objc
private func onPublicBufferTimerFired(_ timer: Timer) {
flushPublicBuffer()
}
@MainActor
private func insertionIndexByTimestamp(_ ts: Date) -> Int {
var low = 0
var high = messages.count
while low < high {
let mid = (low + high) / 2
if messages[mid].timestamp < ts {
low = mid + 1
} else {
high = mid
}
}
return low
}
/// Check for mentions and send notifications
private 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.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)
private 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? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) {
let d = String(id.publicKeyHex.suffix(4))
tokens.append(nickname + "#" + d)
}
default:
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