Files
bitchat/bitchat/ViewModels/ChatViewModel.swift
T
3ebfa85e90 Feature/nostr embedded bitchat (#448)
* UI: prefer mesh radio icon when connected; map short peer ID to full Noise key; rename ephemeral mapping to shortIDToNoiseKey; ensure header flips to purple globe on disconnect and keeps name.

* chore: stop tracking build artifacts; ignore .DerivedData and .Result*

* logging: add global threshold via BITCHAT_LOG_LEVEL and demote chatty logs to debug; keep critical errors/warnings and key state transitions

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-08-17 11:25:42 +02:00

3756 lines
162 KiB
Swift

//
// ChatViewModel.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
///
/// # ChatViewModel
///
/// The central business logic and state management component for BitChat.
/// Coordinates between the UI layer and the networking/encryption services.
///
/// ## Overview
/// ChatViewModel implements the MVVM pattern, serving as the binding layer between
/// SwiftUI views and the underlying BitChat services. It manages:
/// - Message state and delivery
/// - Peer connections and presence
/// - Private chat sessions
/// - Command processing
/// - UI state like autocomplete and notifications
///
/// ## Architecture
/// The ViewModel acts as:
/// - **BitchatDelegate**: Receives messages and events from SimplifiedBluetoothService
/// - **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 Foundation
import SwiftUI
import Combine
import CryptoKit
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.
class ChatViewModel: ObservableObject, BitchatDelegate {
// MARK: - Published Properties
@Published var messages: [BitchatMessage] = []
private let maxMessages = 1337 // Maximum messages before oldest are removed
@Published var isConnected = false
private var hasNotifiedNetworkAvailable = false
private var recentlySeenPeers: Set<String> = []
private var lastNetworkNotificationTime = Date.distantPast
@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 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
}
// Missing properties that were removed during refactoring
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
}
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
var meshService = SimplifiedBluetoothService()
private var nostrRelayManager: NostrRelayManager?
// PeerManager replaced by UnifiedPeerService
private var processedNostrEvents = Set<String>() // Simple deduplication
private let userDefaults = UserDefaults.standard
private let nicknameKey = "bitchat.nickname"
// 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
// Messages are naturally ephemeral - no persistent storage
// MARK: - Message Delivery Tracking
// Delivery tracking
private var cancellables = Set<AnyCancellable>()
// 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
if let data = try? JSONEncoder().encode(Array(sentReadReceipts)) {
UserDefaults.standard.set(data, forKey: "sentReadReceipts")
// Force synchronization for immediate persistence (ensures data is written to disk)
UserDefaults.standard.synchronize()
// Verify persistence by re-reading
if let verifyData = UserDefaults.standard.data(forKey: "sentReadReceipts"),
let _ = try? JSONDecoder().decode([String].self, from: verifyData) {
// Only log errors, not successful persistence
// Successfully persisted
} else {
SecureLogger.log("⚠️ Failed to verify persistence of read receipts",
category: SecureLogger.session, level: .error)
}
} else {
SecureLogger.log("❌ Failed to encode read receipts for persistence",
category: SecureLogger.session, level: .error)
}
}
}
// Track processed Nostr ACKs to avoid duplicate processing
private var processedNostrAcks: Set<String> = [] // "messageId:ackType:senderPubkey" format
// Track app startup phase to prevent marking old messages as unread
private var isStartupPhase = true
// Track Nostr pubkey mappings for unknown senders
private var nostrKeyMapping: [String: String] = [:] // senderPeerID -> nostrPubkey
// MARK: - Initialization
@MainActor
init() {
// 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()
self.privateChatManager = PrivateChatManager(meshService: meshService)
self.unifiedPeerService = UnifiedPeerService(meshService: meshService)
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() + 1.0) { [weak self] in
if let self = self {
_ = self.getMyFingerprint()
}
}
// Set nickname before starting services
meshService.setNickname(nickname)
// Start mesh service immediately
meshService.startServices()
// Initialize Nostr services
Task { @MainActor in
nostrRelayManager = NostrRelayManager.shared
nostrRelayManager?.connect()
// Small delay to ensure read receipts are fully loaded
// This prevents race conditions where messages arrive before initialization completes
try? await Task.sleep(nanoseconds: 200_000_000) // 0.2 seconds
// Set up Nostr message handling directly
setupNostrMessageHandling()
// End startup phase after 2 seconds
// During startup phase, we:
// 1. Skip cleanup of read receipts
// 2. Only block OLD messages from being marked as unread
Task { @MainActor in
try? await Task.sleep(nanoseconds: 2_000_000_000) // 2 seconds
self.isStartupPhase = false
}
// Bind unified peer service's peer list to our published property
let cancellable = unifiedPeerService.$peers
.receive(on: DispatchQueue.main)
.sink { [weak self] peers in
guard let self = self else { return }
// Update peers directly
self.allPeers = peers
// Force UI update
self.objectWillChange.send()
// 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.log("⚠️ Duplicate peer ID detected: \(peer.id) (\(peer.displayName))",
category: SecureLogger.session, level: .warning)
}
}
self.peerIndex = uniquePeers
// Schedule UI update if peers changed
if peers.count > 0 || self.allPeers.count > 0 {
// UI will update automatically
}
// Update private chat peer ID if needed when peers change
if self.selectedPrivateChatFingerprint != nil {
self.updatePrivateChatPeerIfNeeded()
}
}
self.cancellables.insert(cancellable)
}
// Set up Noise encryption callbacks
setupNoiseCallbacks()
// 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
)
#else
NotificationCenter.default.addObserver(
self,
selector: #selector(appDidBecomeActive),
name: UIApplication.didBecomeActiveNotification,
object: nil
)
// 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
)
#endif
}
// MARK: - Deinitialization
deinit {
// Force immediate save
userDefaults.synchronize()
}
// 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)
userDefaults.synchronize() // Force immediate save
// 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
}
}
// Get the peer's nickname to check for temporary Nostr peer IDs
let peerNickname = meshService.getPeerNicknames()[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
SecureIdentityStateManager.shared.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 = SecureIdentityStateManager.shared.getSocialIdentity(for: fingerprintStr) {
identity.claimedNickname = nickname
SecureIdentityStateManager.shared.updateSocialIdentity(identity)
} else {
let newIdentity = SocialIdentity(
fingerprint: fingerprintStr,
localPetname: nil,
claimedNickname: nickname,
trustLevel: .casual,
isFavorite: false,
isBlocked: false,
notes: nil
)
SecureIdentityStateManager.shared.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() + 0.5) { [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) {
guard !content.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 {
// Send as private message
sendPrivateMessage(content, to: selectedPeer)
} else {
}
} else {
// Parse mentions from the content
let mentions = parseMentions(from: content)
// Add message to local display
let message = BitchatMessage(
sender: nickname,
content: content,
timestamp: Date(),
isRelay: false,
originalSender: nil,
isPrivate: false,
recipientNickname: nil,
senderPeerID: meshService.myPeerID,
mentions: mentions.isEmpty ? nil : mentions
)
// Add to main messages immediately for user feedback
messages.append(message)
trimMessagesIfNeeded()
// Force immediate UI update for user's own messages
objectWillChange.send()
// Send via mesh with mentions
meshService.sendMessage(content, mentions: mentions)
}
}
/// 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.getPeerNicknames()[peerID] ?? "user"
addSystemMessage("cannot send message to \(nickname): user is blocked.")
return
}
// Determine routing method and recipient nickname
guard let noiseKey = Data(hexString: peerID) else { return }
let isConnected = meshService.isPeerConnected(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.getPeerNicknames()[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
if isConnected {
// Send via mesh
meshService.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname ?? "user", messageID: messageID)
} else if isMutualFavorite && hasNostrKey,
let recipientNostrPubkey = favoriteStatus?.peerNostrPublicKey {
// Mutual favorite offline - send via Nostr
sendViaNostr(content, to: recipientNostrPubkey, recipientPeerID: peerID, messageId: messageID)
} 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.")
}
}
// 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.getPeerNicknames()[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.log("📬 Transferred unread status from temp peer IDs to \(peerID)",
category: SecureLogger.session, level: .debug)
}
if consolidatedCount > 0 {
// Sort by timestamp
privateChats[peerID]?.sort { $0.timestamp < $1.timestamp }
SecureLogger.log("📥 Consolidated \(consolidatedCount) Nostr messages from temporary peer IDs to \(peerNickname)",
category: SecureLogger.session, level: .info)
}
}
// Trigger handshake if needed
let sessionState = meshService.getNoiseSessionState(for: peerID)
switch sessionState {
case .none, .failed:
meshService.triggerHandshake(with: peerID)
default:
break
}
// 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.log("📖 Handling read receipt for message \(receipt.originalMessageID) from Nostr",
category: SecureLogger.session, level: .info)
// 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.log("📱 Updating private chat peer ID due to key change: \(oldPeerID) -> \(newPeerID)",
category: SecureLogger.session, level: .info)
// 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.log("📱 Migrating private chat messages from \(oldPeerID) to \(newPeerID)",
category: SecureLogger.session, level: .debug)
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.getPeerNicknames()[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() + 0.2) {
self.markPrivateMessagesAsRead(from: peerID)
}
}
}
@objc private func userDidTakeScreenshot() {
// 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.getPeerNicknames()[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
meshService.sendPrivateMessage(screenshotMessage, to: peerID, recipientNickname: peerNickname, messageID: UUID().uuidString)
default:
// Don't send screenshot notification if no session exists
SecureLogger.log("Skipping screenshot notification to \(peerID) - no established session", category: SecureLogger.security, level: .debug)
}
}
// Show local notification immediately as system message
let localNotification = BitchatMessage(
sender: "system",
content: "you took a screenshot",
timestamp: Date(),
isRelay: false,
originalSender: nil,
isPrivate: true,
recipientNickname: meshService.getPeerNicknames()[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 everyone
meshService.sendMessage(screenshotMessage, mentions: [])
// Show local notification immediately as system message
let localNotification = BitchatMessage(
sender: "system",
content: "you took a screenshot",
timestamp: Date(),
isRelay: false
)
// Add system message
addMessage(localNotification)
}
}
@objc private func appWillResignActive() {
userDefaults.synchronize()
}
@objc func applicationWillTerminate() {
// Send leave message to all peers
meshService.stopServices()
// Force save any pending identity changes (verifications, favorites, etc)
SecureIdentityStateManager.shared.forceSave()
// Verify identity key is still there
_ = KeychainManager.shared.verifyIdentityKeyExists()
userDefaults.synchronize()
// Verify identity key after save
_ = KeychainManager.shared.verifyIdentityKeyExists()
}
@objc private func appWillTerminate() {
userDefaults.synchronize()
}
@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.getPeerNicknames()[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.log("📖 Resolved updated peer ID for read receipt: \(peerID) -> \(currentPeerID)",
category: SecureLogger.session, level: .info)
actualPeerID = currentPeerID
break
}
}
}
}
// If we know the original transport, use it for the read receipt
if originalTransport == "nostr" {
// Skip read receipts for Nostr messages - unnecessary complexity
// The radical simplification plan says to accept occasional loss
} else if meshService.getPeerNicknames()[actualPeerID] != nil {
// Use mesh for connected peers (default behavior)
meshService.sendReadReceipt(receipt, to: actualPeerID)
} else {
// Skip read receipts for offline peers - fire and forget principle
}
}
@MainActor
func markPrivateMessagesAsRead(from peerID: String) {
privateChatManager.markAsRead(from: peerID)
// 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 let nostrPubkey = peerNostrPubkey {
// 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)
sendNostrAcknowledgment(messageId: message.id, to: nostrPubkey, type: "READ", recipientPeerID: recipPeer)
sentReadReceipts.insert(message.id)
}
}
}
}
}
@MainActor
func getPrivateChatMessages(for peerID: String) -> [BitchatMessage] {
var allMessages: [BitchatMessage] = []
// Get messages stored under the ephemeral peer ID
if let ephemeralMessages = privateChats[peerID] {
allMessages.append(contentsOf: ephemeralMessages)
}
// Also check if we have messages stored under the stable Noise key hex
// This happens for Nostr messages which use stable keys to persist across reconnections
if let peer = unifiedPeerService.getPeer(by: peerID) {
let noiseKeyHex = peer.noisePublicKey.hexEncodedString()
// Only add if it's different from the ephemeral peer ID (to avoid duplicates)
if noiseKeyHex != peerID,
let nostrMessages = privateChats[noiseKeyHex] {
allMessages.append(contentsOf: nostrMessages)
}
}
// Sort by timestamp to maintain chronological order
return allMessages.sorted { $0.timestamp < $1.timestamp }
}
@MainActor
func getPeerIDForNickname(_ nickname: String) -> String? {
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)
_ = KeychainManager.shared.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
SecureIdentityStateManager.shared.clearAllIdentityData()
peerIDToPublicKeyFingerprint.removeAll()
// Clear persistent favorites from keychain
FavoritesPersistenceService.shared.clearAllFavorites()
// Clear identity data from secure storage
SecureIdentityStateManager.shared.clearAllIdentityData()
// 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()
// Force immediate UserDefaults synchronization
userDefaults.synchronize()
// 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: 100_000_000) // 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: - Formatting Helpers
func formatTimestamp(_ date: Date) -> String {
let formatter = DateFormatter()
formatter.dateFormat = "HH:mm:ss"
return formatter.string(from: date)
}
// MARK: - Autocomplete
func updateAutocomplete(for text: String, cursorPosition: Int) {
// Get peer nicknames but exclude self
let allPeers = meshService.getPeerNicknames().values
let peers = allPeers.filter { $0 != meshService.myNickname }
let (suggestions, range) = autocompleteService.getSuggestions(
for: text,
peers: Array(peers),
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
func getSenderColor(for message: BitchatMessage, colorScheme: ColorScheme) -> Color {
let isDark = colorScheme == .dark
let primaryColor = isDark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
return primaryColor
}
func formatMessageContent(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString {
let isDark = colorScheme == .dark
let contentText = message.content
var processedContent = AttributedString()
// Regular expressions for mentions and hashtags
let mentionPattern = "@([\\p{L}0-9_]+)"
let hashtagPattern = "#([a-zA-Z0-9_]+)"
let mentionRegex = try? NSRegularExpression(pattern: mentionPattern, options: [])
let hashtagRegex = try? NSRegularExpression(pattern: hashtagPattern, options: [])
let mentionMatches = mentionRegex?.matches(in: contentText, options: [], range: NSRange(location: 0, length: contentText.count)) ?? []
let hashtagMatches = hashtagRegex?.matches(in: contentText, options: [], range: NSRange(location: 0, length: contentText.count)) ?? []
// Combine and sort all matches
var allMatches: [(range: NSRange, type: String)] = []
for match in mentionMatches {
allMatches.append((match.range(at: 0), "mention"))
}
for match in hashtagMatches {
allMatches.append((match.range(at: 0), "hashtag"))
}
allMatches.sort { $0.range.location < $1.range.location }
var lastEndIndex = contentText.startIndex
for (matchRange, matchType) in allMatches {
// Add text before the match
if let range = Range(matchRange, in: contentText) {
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 match with appropriate styling
let matchText = String(contentText[range])
var matchStyle = AttributeContainer()
matchStyle.font = .system(size: 14, weight: .semibold, design: .monospaced)
if matchType == "mention" {
matchStyle.foregroundColor = Color.orange
} else {
// Hashtag
matchStyle.foregroundColor = Color.blue
matchStyle.underlineStyle = .single
}
processedContent.append(AttributedString(matchText).mergingAttributes(matchStyle))
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))
}
return processedContent
}
func formatMessageAsText(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString {
// Check cache first
let isDark = colorScheme == .dark
if let cachedText = message.getCachedFormattedText(isDark: isDark) {
return cachedText
}
// Not cached, format the message
var result = AttributedString()
let primaryColor = isDark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
if message.sender != "system" {
// Sender (at the beginning)
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: 14, weight: fontWeight, design: .monospaced)
result.append(sender.mergingAttributes(senderStyle))
// Process content with hashtags and mentions
let content = message.content
let hashtagPattern = "#([a-zA-Z0-9_]+)"
let mentionPattern = "@([\\p{L}0-9_]+)"
let hashtagRegex = try? NSRegularExpression(pattern: hashtagPattern, options: [])
let mentionRegex = try? NSRegularExpression(pattern: mentionPattern, options: [])
// Use NSDataDetector for URL detection
let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
let hashtagMatches = hashtagRegex?.matches(in: content, options: [], range: NSRange(location: 0, length: content.count)) ?? []
let mentionMatches = mentionRegex?.matches(in: content, options: [], range: NSRange(location: 0, length: content.count)) ?? []
let urlMatches = detector?.matches(in: content, options: [], range: NSRange(location: 0, length: content.count)) ?? []
// Combine and sort matches
var allMatches: [(range: NSRange, type: String)] = []
for match in hashtagMatches {
allMatches.append((match.range(at: 0), "hashtag"))
}
for match in mentionMatches {
allMatches.append((match.range(at: 0), "mention"))
}
for match in urlMatches {
allMatches.append((match.range, "url"))
}
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) {
let beforeText = String(content[lastEnd..<nsRange.lowerBound])
if !beforeText.isEmpty {
var beforeStyle = AttributeContainer()
beforeStyle.foregroundColor = primaryColor
beforeStyle.font = .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])
var matchStyle = AttributeContainer()
matchStyle.font = .system(size: 14, weight: .semibold, design: .monospaced)
if type == "hashtag" {
matchStyle.foregroundColor = Color.blue
matchStyle.underlineStyle = .single
} else if type == "mention" {
matchStyle.foregroundColor = Color.orange
} else if type == "url" {
matchStyle.foregroundColor = Color.blue
matchStyle.underlineStyle = .single
}
result.append(AttributedString(matchText).mergingAttributes(matchStyle))
lastEnd = nsRange.upperBound
}
}
// Add remaining text
if lastEnd < content.endIndex {
let remainingText = String(content[lastEnd...])
var remainingStyle = AttributeContainer()
remainingStyle.foregroundColor = primaryColor
remainingStyle.font = .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(" [\(formatTimestamp(message.timestamp))]")
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(" [\(formatTimestamp(message.timestamp))]")
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)
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(" [\(formatTimestamp(message.timestamp))]")
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 matches = regex?.matches(in: contentText, options: [], range: NSRange(location: 0, length: contentText.count)) ?? []
var lastEndIndex = contentText.startIndex
for match in matches {
// Add text before the mention
if let range = Range(match.range(at: 0), in: contentText) {
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))
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(" [\(formatTimestamp(message.timestamp))]")
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 {
let removeCount = messages.count - maxMessages
messages.removeFirst(removeCount)
}
}
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)
messages.sort { $0.timestamp < $1.timestamp }
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)
}
private func getFingerprint_old(for peerID: String) -> String? {
// Remove debug logging to prevent console spam during view updates
// First try to get fingerprint from mesh service's peer ID rotation mapping
if let fingerprint = meshService.getFingerprint(for: peerID) {
return fingerprint
}
// Check noise service (direct Noise session fingerprint)
if let fingerprint = meshService.getNoiseService().getPeerFingerprint(peerID) {
return fingerprint
}
// Last resort: check local mapping
if let fingerprint = peerIDToPublicKeyFingerprint[peerID] {
return fingerprint
}
return nil
}
// 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 = SecureIdentityStateManager.shared.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
SecureIdentityStateManager.shared.setVerified(fingerprint: fingerprint, verified: true)
// Update local set for UI
verifiedFingerprints.insert(fingerprint)
// Update encryption status after verification
updateEncryptionStatus(for: peerID)
}
func loadVerifiedFingerprints() {
// Load verified fingerprints directly from secure storage
verifiedFingerprints = SecureIdentityStateManager.shared.getVerifiedFingerprints()
}
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.log("🔐 Authenticated: \(peerID)", category: SecureLogger.security, level: .debug)
// 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.log("🗺️ Mapped short peerID to Noise key for header continuity: \(peerID) -> \(stable.prefix(8))…",
category: SecureLogger.session, level: .debug)
}
// 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)
}
}
// MARK: - Peer Connection Events
func didConnectToPeer(_ peerID: String) {
SecureLogger.log("🤝 Peer connected: \(peerID)", category: SecureLogger.session, level: .info)
// Handle all main actor work async
Task { @MainActor in
isConnected = true
// Register ephemeral session with identity manager
SecureIdentityStateManager.shared.registerEphemeralSession(peerID: peerID)
// Check if we favorite this peer and resend notification on reconnect
// This ensures Nostr key mapping is maintained across reconnections
if let peer = unifiedPeerService.getPeer(by: peerID),
let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: peer.noisePublicKey),
favoriteStatus.isFavorite {
// Resend favorite notification with our Nostr key after a short delay
try? await Task.sleep(nanoseconds: 500_000_000) // 0.5 seconds
meshService.sendFavoriteNotification(to: peerID, isFavorite: true)
SecureLogger.log("📤 Resent favorite notification to reconnected peer \(peerID)",
category: SecureLogger.session, level: .debug)
}
// 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
}
}
// Connection messages removed to reduce chat noise
}
func didDisconnectFromPeer(_ peerID: String) {
SecureLogger.log("👋 Peer disconnected: \(peerID)", category: SecureLogger.session, level: .info)
// Remove ephemeral session from identity manager
SecureIdentityStateManager.shared.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)
}
}
}
// Disconnection messages removed to reduce chat noise
}
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 {
// Only count mesh peers (actually connected via Bluetooth)
let meshPeers = peers.filter { peerID in
self.meshService.isPeerConnected(peerID)
}
// Check if we have new mesh peers we haven't seen recently
let currentPeerSet = Set(meshPeers)
let newPeers = currentPeerSet.subtracting(self.recentlySeenPeers)
let timeSinceLastNotification = Date().timeIntervalSince(self.lastNetworkNotificationTime)
// Send notification if:
// 1. We have mesh peers (not just Nostr-only)
// 2. There are new peers we haven't seen
// 3. Either it's been more than 5 minutes since last notification OR we haven't notified yet
if meshPeers.count > 0 && !newPeers.isEmpty &&
(timeSinceLastNotification > 300 || !self.hasNotifiedNetworkAvailable) {
self.hasNotifiedNetworkAvailable = true
self.lastNetworkNotificationTime = Date()
self.recentlySeenPeers = currentPeerSet
NotificationService.shared.sendNetworkAvailableNotification(peerCount: meshPeers.count)
SecureLogger.log("👥 Sent bitchatters nearby notification for \(meshPeers.count) mesh peers",
category: SecureLogger.session, level: .info)
}
} else {
// No peers - reset tracking
self.hasNotifiedNetworkAvailable = false
self.recentlySeenPeers.removeAll()
}
// Register ephemeral sessions for all connected peers
for peerID in peers {
SecureIdentityStateManager.shared.registerEphemeralSession(peerID: peerID)
}
// 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.log("🧹 Cleaned up \(idsToRemove.count) stale unread peer IDs",
category: SecureLogger.session, level: .debug)
}
}
// 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.log("🧹 Cleaned up \(removedCount) old read receipts",
category: SecureLogger.session, level: .debug)
}
}
private func parseMentions(from content: String) -> [String] {
let pattern = "@([\\p{L}0-9_]+)"
let regex = try? NSRegularExpression(pattern: pattern, options: [])
let matches = regex?.matches(in: content, options: [], range: NSRange(location: 0, length: content.count)) ?? []
var mentions: [String] = []
let peerNicknames = meshService.getPeerNicknames()
let allNicknames = Set(peerNicknames.values).union([nickname]) // Include self
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 valid nickname
if allNicknames.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.log("Failed to decode Nostr npub: \(error)", category: SecureLogger.session, level: .error)
}
}
// 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 SecureIdentityStateManager.shared.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)
}
// MARK: - Simplified Nostr Integration (Inlined from MessageRouter)
@MainActor
private func sendViaNostr(_ content: String, to recipientNostrPubkey: String, recipientPeerID: String, messageId: String) {
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else {
SecureLogger.log("No Nostr identity available", category: SecureLogger.session, level: .error)
return
}
// Decode npub to hex if necessary
var recipientHexPubkey = recipientNostrPubkey
if recipientNostrPubkey.hasPrefix("npub") {
do {
let (hrp, data) = try Bech32.decode(recipientNostrPubkey)
if hrp == "npub" {
recipientHexPubkey = data.hexEncodedString()
SecureLogger.log("Decoded npub to hex: \(recipientHexPubkey)", category: SecureLogger.session, level: .debug)
}
} catch {
SecureLogger.log("Failed to decode recipient npub: \(error)", category: SecureLogger.session, level: .error)
return
}
}
// Build embedded BitChat packet content (bitchat1:...)
guard let embeddedContent = meshService.buildNostrEmbeddedPrivateMessageContent(
content: content,
to: recipientPeerID,
messageID: messageId
) else {
SecureLogger.log("Failed to build embedded BitChat content for Nostr", category: SecureLogger.session, level: .error)
return
}
guard let event = try? NostrProtocol.createPrivateMessage(
content: embeddedContent,
recipientPubkey: recipientHexPubkey,
senderIdentity: senderIdentity
) else {
SecureLogger.log("Failed to create Nostr message for recipient: \(recipientHexPubkey)", category: SecureLogger.session, level: .error)
return
}
SecureLogger.log("📝 Created Nostr event ID: \(event.id.prefix(16))... for recipient: \(recipientHexPubkey.prefix(16))...",
category: SecureLogger.session, level: .info)
if let relayManager = nostrRelayManager {
relayManager.sendEvent(event)
SecureLogger.log("Sent Nostr message via relay", category: SecureLogger.session, level: .debug)
// Update delivery status to sent for Nostr messages
// Need to find which peerID (ephemeral or Noise key) contains this message
var messageUpdated = false
// First try to find the message in any private chat
for (chatPeerID, messages) in privateChats {
if let index = messages.firstIndex(where: { $0.id == messageId }) {
privateChats[chatPeerID]?[index].deliveryStatus = .sent
messageUpdated = true
SecureLogger.log("✅ Updated delivery status to sent for message \(messageId) in chat \(chatPeerID)",
category: SecureLogger.session, level: .debug)
objectWillChange.send()
break
}
}
if !messageUpdated {
SecureLogger.log("⚠️ Could not find message \(messageId) to update delivery status",
category: SecureLogger.session, level: .warning)
}
} else {
SecureLogger.log("NostrRelayManager is nil - cannot send message", category: SecureLogger.session, level: .error)
}
}
@MainActor
private func sendNostrAcknowledgment(messageId: String, to recipientNostrPubkey: String, type: String, recipientPeerID: String) {
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else {
SecureLogger.log("No Nostr identity for sending ACK", category: SecureLogger.session, level: .error)
return
}
// Decode npub to hex if necessary
var recipientHexPubkey = recipientNostrPubkey
if recipientNostrPubkey.hasPrefix("npub") {
do {
let (hrp, data) = try Bech32.decode(recipientNostrPubkey)
if hrp == "npub" {
recipientHexPubkey = data.hexEncodedString()
}
} catch {
SecureLogger.log("Failed to decode recipient npub for ACK: \(error)",
category: SecureLogger.session, level: .error)
return
}
}
// Build embedded BitChat ACK content
let ackType: NoisePayloadType? = (type == "DELIVERED") ? .delivered : (type == "READ" ? .readReceipt : nil)
guard let ackTypeUnwrapped = ackType,
let ackContent = meshService.buildNostrEmbeddedAckContent(type: ackTypeUnwrapped, messageID: messageId, to: recipientPeerID),
let event = try? NostrProtocol.createPrivateMessage(
content: ackContent,
recipientPubkey: recipientHexPubkey,
senderIdentity: senderIdentity
) else {
SecureLogger.log("Failed to create Nostr ACK for message \(messageId)",
category: SecureLogger.session, level: .error)
return
}
SecureLogger.log("📮 Sending \(type) ACK for message \(messageId.prefix(16))... to \(recipientHexPubkey.prefix(16))...",
category: SecureLogger.session, level: .info)
if let relayManager = nostrRelayManager {
relayManager.sendEvent(event)
}
}
@MainActor
private func setupNostrMessageHandling() {
guard let currentIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else {
SecureLogger.log("⚠️ No Nostr identity available for message handling", category: SecureLogger.session, level: .warning)
return
}
SecureLogger.log("🔑 Setting up Nostr subscription for pubkey: \(currentIdentity.publicKeyHex.prefix(16))...",
category: SecureLogger.session, level: .debug)
// Subscribe to Nostr messages
let filter = NostrFilter.giftWrapsFor(
pubkey: currentIdentity.publicKeyHex,
since: Date().addingTimeInterval(-86400) // 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.log("Ignoring non-embedded Nostr DM content", category: SecureLogger.session, level: .debug)
return
}
guard let packetData = Self.base64URLDecode(String(content.dropFirst("bitchat1:".count))),
let packet = BitchatPacket.from(packetData) else {
SecureLogger.log("Failed to decode embedded BitChat packet from Nostr DM", category: SecureLogger.session, level: .error)
return
}
// Only process typed noiseEncrypted envelope for private messages/receipts
guard packet.type == MessageType.noiseEncrypted.rawValue else {
SecureLogger.log("Unsupported embedded packet type: \(packet.type)", category: SecureLogger.session, level: .warning)
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.log("Failed to parse embedded NoisePayload", category: SecureLogger.session, level: .error)
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(16))
switch noisePayload.type {
case .privateMessage:
guard let (messageId, messageContent) = Self.decodePrivateMessageTLV(noisePayload.data) else { return }
// 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] = [] }
privateChats[targetPeerID]?.append(message)
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 !privateChats[ephemeralPeerID]!.contains(where: { $0.id == messageId }) {
privateChats[ephemeralPeerID]?.append(message)
trimPrivateChatMessagesIfNeeded(for: ephemeralPeerID)
}
}
// Send delivery ack via Nostr embedded if not previously read and we know sender's Noise key
if !wasReadBefore, let key = actualSenderNoiseKey {
sendNostrAcknowledgment(messageId: messageId, to: senderPubkey, type: "DELIVERED", recipientPeerID: key.hexEncodedString())
}
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), let key = actualSenderNoiseKey {
sendNostrAcknowledgment(messageId: messageId, to: senderPubkey, type: "READ", recipientPeerID: key.hexEncodedString())
sentReadReceipts.insert(messageId)
}
} 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()
}
}
} catch {
SecureLogger.log("Failed to decrypt Nostr message: \(error)", category: SecureLogger.session, level: .error)
}
}
@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.log("⚠️ Invalid ACK format: \(content)", category: SecureLogger.session, level: .warning)
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.log("📨 Received \(ackType) ACK for message \(messageId.prefix(16))... from \(senderPubkey.prefix(16))...",
category: SecureLogger.session, level: .debug)
// 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.log("⚠️ Unknown ACK type: \(ackType)", category: SecureLogger.session, level: .warning)
}
messageFound = true
SecureLogger.log("✅ Updated message \(messageId.prefix(16))... status to \(ackType) in chat \(chatPeerID.prefix(16))...",
category: SecureLogger.session, level: .info)
// Don't break - continue to update in all chats where this message exists
}
}
if messageFound {
objectWillChange.send()
} else {
SecureLogger.log("⚠️ Could not find message \(messageId) to update status from ACK",
category: SecureLogger.session, level: .warning)
}
}
// 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)
}
// Decode PrivateMessagePacket TLV (local minimal decoder)
private static func decodePrivateMessageTLV(_ data: Data) -> (String, String)? {
var offset = 0
var messageID: String?
var content: String?
while offset + 2 <= data.count {
let type = data[offset]; offset += 1
let length = Int(data[offset]); offset += 1
guard offset + length <= data.count else { return nil }
let value = data[offset..<offset+length]
offset += length
if type == 0x00 {
messageID = String(data: value, encoding: .utf8)
} else if type == 0x01 {
content = String(data: value, encoding: .utf8)
}
}
if let id = messageID, let c = content { return (id, c) }
return nil
}
@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.log("📝 Received Nostr npub in favorite notification: \(nostrPubkey ?? "none")",
category: SecureLogger.session, level: .info)
}
// 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.log("⚠️ Cannot get Noise key for peer \(peerID)",
category: SecureLogger.session, level: .warning)
return
}
// Update the favorite relationship
FavoritesPersistenceService.shared.updatePeerFavoritedUs(
peerNoisePublicKey: finalNoiseKey,
favorited: isFavorite,
peerNickname: senderNickname,
peerNostrPublicKey: nostrPubkey
)
// If they favorited us and provided their Nostr key, ensure it's stored
if isFavorite && nostrPubkey != nil {
SecureLogger.log("💾 Storing Nostr key association for \(senderNickname): \(nostrPubkey!.prefix(16))...",
category: SecureLogger.session, level: .info)
}
// Show system message
let action = isFavorite ? "favorited" : "unfavorited"
addSystemMessage("\(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(16)
// 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.log("📬 Stored Nostr message from unknown sender \(finalSenderNickname) in temporary peer \(tempPeerID)",
category: SecureLogger.session, level: .info)
}
@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.log("⚠️ Invalid hex public key format: \(nostrPubkey.prefix(16))...",
category: SecureLogger.session, level: .warning)
return nil
}
do {
npubToMatch = try Bech32.encode(hrp: "npub", data: pubkeyData)
} catch {
SecureLogger.log("⚠️ Failed to convert hex to npub: \(error)",
category: SecureLogger.session, level: .warning)
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.log("✅ Found Noise key for Nostr sender (npub match)",
// category: SecureLogger.session, level: .debug)
return noiseKey
}
// Also try hex comparison if stored value is hex
if !storedNostrKey.hasPrefix("npub") && storedNostrKey == nostrPubkey {
SecureLogger.log("✅ Found Noise key for Nostr sender (hex match)",
category: SecureLogger.session, level: .debug)
return noiseKey
}
}
}
SecureLogger.log("⚠️ No matching Noise key found for Nostr pubkey: \(nostrPubkey.prefix(16))... (tried npub: \(npubToMatch.prefix(16))...)",
category: SecureLogger.session, level: .debug)
return nil
}
@MainActor
private func sendFavoriteNotificationViaNostr(noisePublicKey: Data, isFavorite: Bool) {
// Send favorite notification via Nostr when we don't have ephemeral peer ID
if let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noisePublicKey),
let recipientNostrPubkey = favoriteStatus.peerNostrPublicKey {
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
let content = isFavorite ? "[FAVORITED]:\(senderIdentity.npub)" : "[UNFAVORITED]:\(senderIdentity.npub)"
let recipientPeerID = noisePublicKey.hexEncodedString()
guard let embedded = meshService.buildNostrEmbeddedPrivateMessageContent(content: content, to: recipientPeerID, messageID: UUID().uuidString),
let event = try? NostrProtocol.createPrivateMessage(
content: embedded,
recipientPubkey: recipientNostrPubkey,
senderIdentity: senderIdentity
) else { return }
if let relayManager = nostrRelayManager {
relayManager.sendEvent(event)
SecureLogger.log("Sent favorite notification via Nostr", category: SecureLogger.session, level: .debug)
}
}
}
@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) {
meshService.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
SecureLogger.log("📤 Sent favorite notification via BLE to \(peerID)", category: SecureLogger.session, level: .debug)
} else if let key = noiseKey,
let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: key),
let recipientNostrPubkey = favoriteStatus.peerNostrPublicKey {
// Send via Nostr for offline peers
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else {
SecureLogger.log("❌ No Nostr identity for sending favorite notification", category: SecureLogger.session, level: .error)
return
}
let content = isFavorite ? "[FAVORITED]:\(senderIdentity.npub)" : "[UNFAVORITED]:\(senderIdentity.npub)"
let recipientPeerID = key.hexEncodedString()
guard let embedded = meshService.buildNostrEmbeddedPrivateMessageContent(content: content, to: recipientPeerID, messageID: UUID().uuidString),
let event = try? NostrProtocol.createPrivateMessage(
content: embedded,
recipientPubkey: recipientNostrPubkey,
senderIdentity: senderIdentity
) else {
SecureLogger.log("❌ Failed to create Nostr message for favorite notification", category: SecureLogger.session, level: .error)
return
}
if let relayManager = nostrRelayManager {
relayManager.sendEvent(event)
SecureLogger.log("📤 Sent favorite notification via Nostr to \(favoriteStatus.peerNickname)", category: SecureLogger.session, level: .debug)
} else {
SecureLogger.log("❌ NostrRelayManager is nil - cannot send favorite notification", category: SecureLogger.session, level: .error)
}
} else {
SecureLogger.log("⚠️ Cannot send favorite notification - peer not connected and no Nostr pubkey", category: SecureLogger.session, level: .warning)
}
}
// 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) {
return isPeerBlocked(peerID)
}
return false
}
/// 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(-24 * 60 * 60)
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.log("📦 Partially migrating \(recentMessages.count) of \(messages.count) messages from \(oldPeerID)",
category: SecureLogger.session, level: .info)
}
SecureLogger.log("📦 Migrating \(recentMessages.count) recent messages from old peer ID \(oldPeerID) to \(peerID) (fingerprint match)",
category: SecureLogger.session, level: .info)
} 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.log("📦 Migrating \(recentMessages.count) recent messages from old peer ID \(oldPeerID) to \(peerID) (nickname match)",
category: SecureLogger.session, level: .warning)
}
}
}
}
// 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, avoiding duplicates
let existingIds = Set(existingMessages.map { $0.id })
for msg in migratedMessages where !existingIds.contains(msg.id) {
existingMessages.append(msg)
}
existingMessages.sort { $0.timestamp < $1.timestamp }
privateChats[peerID] = existingMessages
} else {
// Initialize with migrated messages
privateChats[peerID] = migratedMessages
}
trimPrivateChatMessagesIfNeeded(for: peerID)
// Update selectedPrivateChatPeer if it was pointing to an old ID
if needsSelectedUpdate {
selectedPrivateChatPeer = peerID
SecureLogger.log("📱 Updated selectedPrivateChatPeer from old ID to \(peerID) during migration",
category: SecureLogger.session, level: .info)
}
}
}
}
/// Handle incoming private message
@MainActor
private func handlePrivateMessage(_ message: BitchatMessage) {
SecureLogger.log("📥 handlePrivateMessage called for message from \(message.sender)", category: SecureLogger.session, level: .debug)
let senderPeerID = message.senderPeerID ?? getPeerIDForNickname(message.sender)
guard let peerID = senderPeerID else {
SecureLogger.log("⚠️ Could not get peer ID for sender \(message.sender)", category: SecureLogger.session, level: .warning)
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.log("📥 Consolidated \(nostrMessages.count) Nostr messages from stable key to ephemeral peer \(peerID)",
category: SecureLogger.session, level: .info)
}
}
}
// 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)
// Trigger UI update
objectWillChange.send()
// 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
if selectedPrivateChatPeer != peerID {
unreadPrivateMessages.insert(peerID)
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.getPeerNicknames()[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() + 0.1) { [weak self] in
self?.markPrivateMessagesAsRead(from: peerID)
}
}
}
/// Handle incoming public message
private func handlePublicMessage(_ message: BitchatMessage) {
let finalMessage = processActionMessage(message)
// Check if this is our own message being echoed back
if finalMessage.sender != nickname && finalMessage.sender != "system" {
// Skip empty messages
if !finalMessage.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
addMessage(finalMessage)
}
} else if finalMessage.sender != "system" {
// Check for duplicates
let messageExists = messages.contains { existingMsg in
if existingMsg.id == finalMessage.id {
return true
}
if existingMsg.content == finalMessage.content &&
existingMsg.sender == finalMessage.sender {
let timeDiff = abs(existingMsg.timestamp.timeIntervalSince(finalMessage.timestamp))
return timeDiff < 1.0
}
return false
}
if !messageExists && !finalMessage.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
addMessage(finalMessage)
}
} else {
// System message
if !finalMessage.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
addMessage(finalMessage)
}
}
}
/// Check for mentions and send notifications
private func checkForMentions(_ message: BitchatMessage) {
let isMentioned = message.mentions?.contains(nickname) ?? false
// Mention check: \(isMentioned ? "mentioned" : "not mentioned")
if isMentioned && message.sender != nickname {
SecureLogger.log("🔔 Mention from \(message.sender)",
category: SecureLogger.session, level: .info)
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 }
let isHugForMe = message.content.contains("🫂") &&
(message.content.contains("hugs \(nickname)") ||
message.content.contains("hugs you"))
let isSlapForMe = message.content.contains("🐟") &&
(message.content.contains("slaps \(nickname) around") ||
message.content.contains("slaps you around"))
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) * 0.15) {
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