mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 23:45:18 +00:00
* Implement Nostr NIP-17 integration for offline mutual favorite messaging - Add Nostr relay connectivity and NIP-17 gift-wrapped private messages - Implement dual transport system: Bluetooth mesh + Nostr relays - Add favorites persistence with mutual detection and Nostr key exchange - Support offline messaging for mutual favorites via Nostr relays - Handle peer identity rotation with automatic favorite key updates - Fix UI to show all favorites (online and offline) in peer list - Add proper message routing based on peer availability - Update peer list icons: 📶 for mesh, 🌐 for Nostr, 🌙 for one-sided - Fix toolbar display for offline peers in private chat view - Add network entitlements for macOS and iOS - Implement automatic noise key updates when peers reconnect * Implement Nostr NIP-17 for private messaging between mutual favorites - Add support for NIP-17 gift-wrapped private messages with double encryption - Enable private messaging via Nostr when mutual favorites are offline - Fix peer reconnection issues: users now stay in private chat when peer reconnects - Fix read receipt delivery: send pending receipts when peer comes back online - Add message ID tracking through Nostr transport for proper delivery acknowledgments - Update peer noise key mapping when peers reconnect with different IDs - Check for Nostr messages when app becomes active - Implement 7-day message retrieval window for better reliability * Fix private message UI refresh and adjust PEOPLE header spacing - Fix UI not updating when receiving private messages on mesh - Add immediate batch processing for messages in active chat - Force UI update when viewing current chat peer - Ensure real-time message display without navigation - Reduce PEOPLE header spacing from 16 to 12 points for tighter UI * Fix build errors and unused value warnings in Nostr favorites integration * Implement read receipts via Nostr - Added sendReadReceipt method to MessageRouter to send receipts via mesh or Nostr - Added handleReadReceipt to process incoming read receipts from Nostr - Made ReadReceipt.readerID mutable to allow updates - Added missing notification names and error cases - Uncommented and enabled read receipt handling in ChatViewModel - Read receipts now work seamlessly via both mesh and Nostr transports * Fix ReadReceipt initialization - use correct constructor * Implement persistent message deduplication for Nostr - Added ProcessedMessagesService to track messages across app restarts - Store processed message IDs and last timestamp in UserDefaults - Skip already processed messages when receiving from Nostr - Adjust subscription filter to use smart timestamp (last processed or 24h) - Prevents duplicate messages when reconnecting to Nostr relays * Fix peer list UI not updating to Nostr mode on disconnect - Remove peer from peerNicknames when connection state changes to disconnected - Ensures UI properly reflects peer disconnection state - Peer list now correctly shows Nostr mode (🌐) when peer walks out of range * Update peer count to include Nostr peers and improve UI indicators - Peer count now shows total peers including those available via Nostr - Count appears purple when only Nostr peers are connected - Private message header shows purple globe icon for Nostr transport - Consistent visual language for Nostr connectivity across the app * Improve RSSI real-time updates and fix UI flashing - Reduce RSSI update timer from 10s to 5s and per-peripheral from 5s to 3s - Add RSSI change detection with 2 dBm threshold for responsive updates - Always update previous RSSI values to fix gradual change detection bug - Trigger RSSI read on peer authentication for immediate status - Fix UI flashing 'nobody around' by removing array clearing on updates - Add proper cleanup of RSSI tracking on disconnect and peer rotation * Fix favorite nickname updates and peer list filtering - Add updateNickname method to FavoritesPersistenceService to update nicknames while preserving favorite status - Update announce handler to check for existing favorites and update their nicknames - Remove dead BluetoothMeshService+PublicAPI.swift file - Move sendFavoriteNotification to main BluetoothMeshService - Fix peer list to only show connected peers and user's favorites (not peers who favorite the user) - Remove UI logic for showing peers who favorite us but we don't favorite back * Remove dead code and fix ghost connections Phase 1 - Remove abandoned peer ID rotation code: - Remove previousPeerID property and rotationGracePeriod constant - Remove grace period logic from isPeerIDOurs() - Remove previousPeerID handling from announce packets - Pass nil for previousPeerID in identity announcements Phase 2 - Fix ghost connections from relayed packets: - CRITICAL FIX: Only add peers to activePeers if they have a peripheral connection - Check for peripheral connection before marking peer as active - Prevents ghost connections when announce packets are relayed - Log warning when rejecting relayed announce without peripheral Phase 3 - Begin consolidating redundant peer tracking: - Create new PeerSession class to unify peer data in one place - Add helper methods for PeerSession management - Integrate PeerSession into announce packet handling - Update authentication state changes to use PeerSession - Update peripheral mapping and RSSI to sync with PeerSession - Update disconnect and leave handling to update PeerSession - Add consolidated getter methods for peer info This fixes the issue where peers appeared connected without actually having a Bluetooth connection, and begins the migration to a cleaner single-source-of-truth peer tracking system. * Fix multiple connect messages on peer restart - Move hasPeripheralConnection check outside sync block to fix scope issue - Add debug logging to track connect message conditions - Ensure connect messages only show on first connection or reconnection with peripheral * Optimize RSSI updates for better battery life - Add app state tracking to BluetoothMeshService - Only update RSSI when app is in foreground and peer list is visible - Add setPeerListVisible method to control RSSI updates - Remove individual periodic RSSI updates in favor of centralized timer - Update ContentView to notify mesh service of peer list visibility changes - Improve battery efficiency by avoiding unnecessary RSSI reads * Initialize peer list visibility state on view appear - Ensure RSSI timer state is properly initialized when view loads - Call setPeerListVisible with initial showSidebar value * Fix duplicate peers and multiple disconnect messages - Fixed duplicate peer entries when relay-connected by adding relay-connected peers to connectedNicknames set - Added deduplication logic for disconnect messages with 2-second window to prevent multiple disconnect notifications for same peer - Added cleanup for old disconnect notification tracking to prevent memory growth * Fix peer count indicator color logic - Show green for any mesh peer (direct Bluetooth or relay connected) - Show purple only for Nostr-only peers (no mesh connections) - Show red only when no peers are reachable at all - Fixed to use meshPeerCount instead of viewModel.isConnected which only checked direct connections * Fix relay connection issues and peripheral mapping cleanup - Fixed relay-connected peers being marked as directly connected when receiving identity announce - Added proper cleanup of temp peripheral mappings when discovering real peer ID - Fixed disconnect notification deduplication cleanup - Improved debug logging to show actual connection state (direct/relay/nostr/offline) - Added debug logging for relay connection detection - Fixed compiler warning about unused variable * Fix Unknown peer disconnect notifications and disable faulty relay detection - Add check to prevent disconnect notifications for Unknown peers that never announced - Disable relay connection detection until proper relay tracking is implemented - In a 2-peer network, peers should never show as relay-connected * Fix RSSI updates and peer visibility after reconnection - Add updatePeers() call in didUpdatePeerList to refresh RSSI values in UI - Track version hello times to better detect direct connections - Allow peers to be marked active if recent version hello received - Fix thread safety for version hello tracking - Clean up old version hello times to prevent memory leaks * Remove RSSI tracking completely and replace with radio icon for mesh connections * Fix build errors after RSSI removal - Add missing peripheralID declaration in didDiscover delegate method - Remove obsolete setPeerListVisible calls from ContentView * Center private message header elements using ZStack layout - Replace HStack with ZStack for perfect centering - Globe/nick/lock cluster now always centered regardless of button sizes - Back and favorite buttons positioned in overlay HStack * Fix private chat view showing Unknown when peer reconnects with new ID - Update FavoritesPersistenceService to notify with both old and new keys - Handle peer ID changes in ChatViewModel to migrate private chat data - Update selectedPrivateChatPeer when favorite's noise key changes - Maintain chat history and unread status across peer ID changes * Fix read receipts after peer reconnection and replace nos.lol relay - Updated sendReadReceipt to resolve current peer ID when peers reconnect with new IDs - Enhanced MessageRouter to check favorites for current noise keys - Replaced nos.lol relay with relay.snort.social to avoid PoW requirements * Fix message routing to use Nostr when peers are disconnected Changed message routing logic to check actual peer connection status using isPeerConnected() instead of just checking if peer exists in nickname list. This ensures that offline mutual favorites correctly route messages through Nostr instead of attempting Bluetooth handshakes. Also added safety check to prevent starting private chat with ourselves. * Add debug logging for Nostr timestamp randomization Added logging to track the random offset being applied to Nostr event timestamps to debug why messages appear 8-9 minutes in the future. * Fix Nostr timestamp issue by reducing randomization range Temporarily reduced the timestamp randomization from +/-15 minutes to +/-1 minute to address messages appearing 8-9 minutes in the future. Added detailed UTC/local time logging to help debug the issue. The random offset should have been evenly distributed but was consistently showing positive offsets. This change mitigates the issue while we investigate the root cause. * Fix message routing for offline favorites and reduce Nostr timestamp randomization - Fix transport selection to properly detect disconnected peers using isPeerConnected() - Change from checking peer nicknames to checking actual connection status - Reduce Nostr timestamp randomization from ±15 minutes to ±1 minute - Add detailed timestamp logging for debugging * Improve PM header UI and encryption status display - Show transport icons (radio/link/globe) in PM header matching peer list - Always show lock icon if noise session ever established (no handshake icon) - Change verified icon from shield to checkmark seal - Use consistent green color (textColor) for PM header and encryption icons * Update AI_CONTEXT.md with comprehensive Nostr implementation details - Add Nostr and MessageRouter to architecture diagram - Document NIP-17 gift wrap implementation - Explain favorites integration and mutual requirement - Detail message routing logic and transport selection - Add security considerations and debugging tips - Update common tasks with Nostr-specific guidance * Fix data consistency issues in favorites, chat migration, and bloom filter - Fix favorites deduplication to use public key instead of nickname Prevents losing favorites when multiple peers use same nickname - Fix private chat migration to use fingerprints instead of nicknames Prevents merging unrelated conversations that share nicknames Fallback to nickname matching only for legacy data without fingerprints - Fix bloom filter reset to preserve messages from last 10 minutes Prevents duplicate message processing after bloom filter resets Keeps processedMessages for 10 minutes while bloom filter resets every 5 * Add mutual favorites internet messaging to app info * Remove excessive debug/info logging for production readiness - Removed ~140 debug/info level logs across core services - Preserved critical logs: errors, warnings, security events, state changes - Kept logs for: peer join/leave, favorite status, mutual relationships - Cleaned up verbose logging in: Bluetooth mesh, Nostr, message routing - Improved performance by reducing log I/O overhead 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * Implement performance optimizations and fix build warnings - Add UI update debouncing (50ms) to prevent excessive SwiftUI refreshes - Implement memory bounds for processedMessages with LRU eviction - Add encryption queue cleanup for disconnected peers - Optimize peer lookups from O(n) to O(1) with indexed dictionary - Fix multiple compiler warnings (unused variables, missing break statements) - Optimize peer counting with single-pass reduce operation - Fix ViewBuilder control flow issue in ContentView - Fix Dictionary initialization type mismatches with Array wrapper * Add TTL-based cleanup for Noise handshake sessions - Add session TTL (5 minutes) and max session limit (50) to NoiseHandshakeCoordinator - Clean up old established sessions to prevent unbounded memory growth - Move handshake cleanup timer out of DEBUG conditional for production use - Run cleanup every 60 seconds in production (vs 30s in debug) - Clean up crypto state immediately on peer disconnect - Prevents memory leaks from accumulating Noise sessions * Pre-compute and store fingerprints in PeerSession for O(1) lookups - Store fingerprint in PeerSession when peer authenticates - Update getPeerFingerprint() and getFingerprint() to check PeerSession first - Replace all noiseService.getPeerFingerprint() calls with optimized version - Eliminates repeated SHA256 calculations during message processing - Improves performance for favorite checks and encryption status updates * Implement exponential backoff for Nostr relay connections - Add reconnection tracking fields to Relay struct (attempts, timing) - Replace fixed 5-second delay with exponential backoff (1s → 2s → 4s... max 5min) - Stop reconnection attempts after 10 failures to prevent infinite retries - Reset attempt counter on successful connection - Add utility methods: retryConnection(), getRelayStatuses(), resetAllConnections() - DNS failures still bypass retry logic as before - Improves battery life and reduces server load from constant reconnection attempts --------- Co-authored-by: jack <jackjackbits@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com>
3522 lines
151 KiB
Swift
3522 lines
151 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 BluetoothMeshService
|
|
/// - **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
|
|
/// - Batches incoming messages for performance (100ms window)
|
|
/// - 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
|
|
/// - Message batching reduces 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
|
|
#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 connectedPeers: [String] = []
|
|
@Published var allPeers: [BitchatPeer] = [] // Unified peer list including favorites
|
|
private var peerIndex: [String: BitchatPeer] = [:] // Quick lookup by peer ID
|
|
|
|
// MARK: - Message Batching Properties
|
|
|
|
// Message batching for performance
|
|
private var pendingMessages: [BitchatMessage] = []
|
|
private var pendingPrivateMessages: [String: [BitchatMessage]] = [:] // peerID -> messages
|
|
private var messageBatchTimer: Timer?
|
|
private let messageBatchInterval: TimeInterval = 0.1 // 100ms batching window
|
|
|
|
// UI update debouncing for performance
|
|
private var uiUpdateTimer: Timer?
|
|
private let uiUpdateInterval: TimeInterval = 0.05 // 50ms debounce window
|
|
private var pendingUIUpdate = false
|
|
@Published var nickname: String = "" {
|
|
didSet {
|
|
// Trim whitespace whenever nickname is set
|
|
let trimmed = nickname.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if trimmed != nickname {
|
|
nickname = trimmed
|
|
}
|
|
}
|
|
}
|
|
@Published var isConnected = false
|
|
@Published var privateChats: [String: [BitchatMessage]] = [:] // peerID -> messages
|
|
@Published var selectedPrivateChatPeer: String? = nil
|
|
private var selectedPrivateChatFingerprint: String? = nil // Track by fingerprint for persistence across reconnections
|
|
@Published var unreadPrivateMessages: Set<String> = []
|
|
@Published var autocompleteSuggestions: [String] = []
|
|
@Published var showAutocomplete: Bool = false
|
|
@Published var autocompleteRange: NSRange? = nil
|
|
@Published var selectedAutocompleteIndex: Int = 0
|
|
|
|
// MARK: - Autocomplete Properties
|
|
|
|
// Autocomplete optimization
|
|
private let mentionRegex = try? NSRegularExpression(pattern: "@([a-zA-Z0-9_]*)$", options: [])
|
|
private var cachedNicknames: [String] = []
|
|
private var lastNicknameUpdate: Date = .distantPast
|
|
|
|
// Temporary property to fix compilation
|
|
@Published var showPasswordPrompt = false
|
|
|
|
// MARK: - Services and Storage
|
|
|
|
var meshService = BluetoothMeshService()
|
|
private var nostrRelayManager: NostrRelayManager?
|
|
private var messageRouter: MessageRouter?
|
|
private var peerManager: PeerManager?
|
|
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
|
|
|
|
@Published var favoritePeers: Set<String> = [] // Now stores public key fingerprints instead of peer IDs
|
|
private var peerIDToPublicKeyFingerprint: [String: String] = [:] // Maps ephemeral peer IDs to persistent fingerprints
|
|
private var blockedUsers: Set<String> = [] // Stores public key fingerprints of blocked users
|
|
|
|
// 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
|
|
|
|
// Messages are naturally ephemeral - no persistent storage
|
|
|
|
// MARK: - Message Delivery Tracking
|
|
|
|
// Delivery tracking
|
|
private var deliveryTrackerCancellable: AnyCancellable?
|
|
private var cancellables = Set<AnyCancellable>()
|
|
|
|
// Track sent read receipts to avoid duplicates
|
|
private var sentReadReceipts: Set<String> = [] // messageID set
|
|
|
|
// Track Nostr pubkey mappings for unknown senders
|
|
private var nostrKeyMapping: [String: String] = [:] // senderPeerID -> nostrPubkey
|
|
|
|
// MARK: - Initialization
|
|
|
|
init() {
|
|
loadNickname()
|
|
loadFavorites()
|
|
loadBlockedUsers()
|
|
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()
|
|
}
|
|
}
|
|
|
|
// Start mesh service immediately
|
|
meshService.startServices()
|
|
|
|
// Set up message retry service
|
|
MessageRetryService.shared.meshService = meshService
|
|
|
|
// Initialize Nostr services
|
|
Task { @MainActor in
|
|
nostrRelayManager = NostrRelayManager.shared
|
|
nostrRelayManager?.connect()
|
|
|
|
messageRouter = MessageRouter(
|
|
meshService: meshService,
|
|
nostrRelay: nostrRelayManager!
|
|
)
|
|
|
|
// Initialize peer manager
|
|
peerManager = PeerManager(meshService: meshService)
|
|
peerManager?.updatePeers()
|
|
|
|
// Bind peer manager's peer list to our published property
|
|
peerManager?.$peers
|
|
.receive(on: DispatchQueue.main)
|
|
.sink { [weak self] peers in
|
|
SecureLogger.log("📱 UI: Received \(peers.count) peers from PeerManager",
|
|
category: SecureLogger.session, level: .info)
|
|
for peer in peers {
|
|
SecureLogger.log(" - \(peer.displayName): connected=\(peer.isConnected), state=\(peer.connectionState)",
|
|
category: SecureLogger.session, level: .debug)
|
|
}
|
|
// Update peers directly
|
|
self?.allPeers = peers
|
|
// Update peer index for O(1) lookups
|
|
self?.peerIndex = Dictionary(uniqueKeysWithValues: peers.map { ($0.id, $0) })
|
|
// Schedule UI update if peers changed
|
|
if peers.count > 0 || self?.allPeers.count ?? 0 > 0 {
|
|
self?.scheduleUIUpdate()
|
|
}
|
|
|
|
// Update private chat peer ID if needed when peers change
|
|
if self?.selectedPrivateChatFingerprint != nil {
|
|
self?.updatePrivateChatPeerIfNeeded()
|
|
}
|
|
}
|
|
.store(in: &cancellables)
|
|
}
|
|
|
|
// Set up Noise encryption callbacks
|
|
setupNoiseCallbacks()
|
|
|
|
// Request notification permission
|
|
NotificationService.shared.requestAuthorization()
|
|
|
|
// Subscribe to delivery status updates
|
|
deliveryTrackerCancellable = DeliveryTracker.shared.deliveryStatusUpdated
|
|
.receive(on: DispatchQueue.main)
|
|
.sink { [weak self] (messageID, status) in
|
|
self?.updateMessageDeliveryStatus(messageID, status: status)
|
|
}
|
|
|
|
// Listen for retry notifications
|
|
NotificationCenter.default.addObserver(
|
|
self,
|
|
selector: #selector(handleRetryMessage),
|
|
name: Notification.Name("bitchat.retryMessage"),
|
|
object: nil
|
|
)
|
|
|
|
// Listen for Nostr messages
|
|
NotificationCenter.default.addObserver(
|
|
self,
|
|
selector: #selector(handleNostrMessage),
|
|
name: .nostrMessageReceived,
|
|
object: nil
|
|
)
|
|
|
|
NotificationCenter.default.addObserver(
|
|
self,
|
|
selector: #selector(handleNostrReadReceipt),
|
|
name: .readReceiptReceived,
|
|
object: nil
|
|
)
|
|
|
|
// 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
|
|
NotificationCenter.default.addObserver(
|
|
self,
|
|
selector: #selector(handleDeliveryAcknowledgment),
|
|
name: .messageDeliveryAcknowledged,
|
|
object: nil
|
|
)
|
|
|
|
// 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 {
|
|
// Clean up timers
|
|
messageBatchTimer?.invalidate()
|
|
uiUpdateTimer?.invalidate()
|
|
|
|
// 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
|
|
|
|
private func loadFavorites() {
|
|
// Load favorites from secure storage
|
|
favoritePeers = SecureIdentityStateManager.shared.getFavorites()
|
|
}
|
|
|
|
private func saveFavorites() {
|
|
// Favorites are now saved automatically in SecureIdentityStateManager
|
|
// This method is kept for compatibility
|
|
}
|
|
|
|
// MARK: - Blocked Users Management
|
|
|
|
private func loadBlockedUsers() {
|
|
// Load blocked users from secure storage
|
|
let allIdentities = SecureIdentityStateManager.shared.getAllSocialIdentities()
|
|
blockedUsers = Set(allIdentities.filter { $0.isBlocked }.map { $0.fingerprint })
|
|
}
|
|
|
|
private func saveBlockedUsers() {
|
|
// Blocked users are now saved automatically in SecureIdentityStateManager
|
|
// This method is kept for compatibility
|
|
}
|
|
|
|
|
|
func toggleFavorite(peerID: String) {
|
|
|
|
Task { @MainActor [weak self] in
|
|
guard let self = self else { return }
|
|
|
|
// Try to find the favorite relationship first by Noise key, then by Nostr key
|
|
var noisePublicKey: Data?
|
|
var currentStatus: FavoritesPersistenceService.FavoriteRelationship?
|
|
|
|
// First try as Noise key
|
|
if let noiseKey = Data(hexString: peerID) {
|
|
currentStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey)
|
|
noisePublicKey = noiseKey
|
|
}
|
|
|
|
// getFavoriteStatusByNostrKey not implemented
|
|
// If not found, try as Nostr key
|
|
// if currentStatus == nil {
|
|
// currentStatus = FavoritesPersistenceService.shared.getFavoriteStatusByNostrKey(peerID)
|
|
// noisePublicKey = currentStatus?.peerNoisePublicKey
|
|
// }
|
|
|
|
// If still no noise key, we can't proceed
|
|
guard let finalNoiseKey = noisePublicKey else {
|
|
SecureLogger.log("❌ Could not find noise key for peer: \(peerID)", category: SecureLogger.session, level: .error)
|
|
return
|
|
}
|
|
|
|
let isFavorite = currentStatus?.isFavorite ?? false
|
|
|
|
SecureLogger.log("📊 Current favorite status for \(peerID): isFavorite=\(isFavorite), isMutual=\(currentStatus?.isMutual ?? false)",
|
|
category: SecureLogger.session, level: .info)
|
|
|
|
if isFavorite {
|
|
// Remove from favorites
|
|
FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: finalNoiseKey)
|
|
|
|
// Send unfavorite notification via mesh or Nostr
|
|
Task {
|
|
try? await self.messageRouter?.sendFavoriteNotification(to: finalNoiseKey, isFavorite: false)
|
|
}
|
|
} else {
|
|
// Add to favorites
|
|
let peers = self.allPeers
|
|
let nickname = self.meshService.getPeerNicknames()[peerID] ?? peers.first(where: { $0.id == peerID })?.displayName ?? "Unknown"
|
|
let nostrPublicKey = peers.first(where: { $0.id == peerID })?.nostrPublicKey
|
|
|
|
// Ensure we have a Nostr identity created
|
|
if (try? NostrIdentityBridge.getCurrentNostrIdentity()) != nil {
|
|
}
|
|
|
|
FavoritesPersistenceService.shared.addFavorite(
|
|
peerNoisePublicKey: finalNoiseKey,
|
|
peerNostrPublicKey: nostrPublicKey ?? currentStatus?.peerNostrPublicKey,
|
|
peerNickname: nickname
|
|
)
|
|
|
|
// Send favorite notification via mesh or Nostr
|
|
Task {
|
|
try? await self.messageRouter?.sendFavoriteNotification(to: finalNoiseKey, isFavorite: true)
|
|
}
|
|
}
|
|
|
|
// Update the peer list to reflect the change
|
|
self.peerManager?.updatePeers()
|
|
|
|
// Check if we now have a mutual favorite relationship
|
|
if let updatedStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: finalNoiseKey),
|
|
updatedStatus.isMutual {
|
|
}
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
func isFavorite(peerID: String) -> Bool {
|
|
// First try as Noise public key
|
|
if let noisePublicKey = Data(hexString: peerID) {
|
|
if let status = FavoritesPersistenceService.shared.getFavoriteStatus(for: noisePublicKey) {
|
|
return status.isFavorite
|
|
}
|
|
}
|
|
|
|
// getFavoriteStatusByNostrKey not implemented
|
|
// If not found, try as Nostr public key
|
|
// if let status = FavoritesPersistenceService.shared.getFavoriteStatusByNostrKey(peerID) {
|
|
// return status.isFavorite
|
|
// }
|
|
|
|
return false
|
|
}
|
|
|
|
// MARK: - Public Key and Identity Management
|
|
|
|
// Called when we receive a peer's public key
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func isPeerBlocked(_ peerID: String) -> Bool {
|
|
// Check if we have the public key fingerprint for this peer
|
|
if let fingerprint = peerIDToPublicKeyFingerprint[peerID] {
|
|
return SecureIdentityStateManager.shared.isBlocked(fingerprint: fingerprint)
|
|
}
|
|
|
|
// Try to get fingerprint from mesh service
|
|
if let fingerprint = meshService.getPeerFingerprint(peerID) {
|
|
return SecureIdentityStateManager.shared.isBlocked(fingerprint: fingerprint)
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// Helper method to find current peer ID for a fingerprint
|
|
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
|
|
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 {
|
|
SecureLogger.log("📱 Updating private chat peer from \(oldPeerID) to \(currentPeerID)",
|
|
category: SecureLogger.session, level: .debug)
|
|
|
|
// Migrate messages from old peer ID to new peer ID
|
|
if let oldMessages = privateChats[oldPeerID] {
|
|
if privateChats[currentPeerID] == nil {
|
|
privateChats[currentPeerID] = []
|
|
}
|
|
privateChats[currentPeerID]?.append(contentsOf: oldMessages)
|
|
// Sort by timestamp
|
|
privateChats[currentPeerID]?.sort { $0.timestamp < $1.timestamp }
|
|
// Remove duplicates
|
|
var seen = Set<String>()
|
|
privateChats[currentPeerID] = privateChats[currentPeerID]?.filter { msg in
|
|
if seen.contains(msg.id) {
|
|
return false
|
|
}
|
|
seen.insert(msg.id)
|
|
return true
|
|
}
|
|
trimPrivateChatMessagesIfNeeded(for: currentPeerID)
|
|
privateChats.removeValue(forKey: oldPeerID)
|
|
}
|
|
|
|
// Migrate unread status
|
|
if unreadPrivateMessages.contains(oldPeerID) {
|
|
unreadPrivateMessages.remove(oldPeerID)
|
|
unreadPrivateMessages.insert(currentPeerID)
|
|
}
|
|
|
|
selectedPrivateChatPeer = currentPeerID
|
|
|
|
// Schedule UI update for encryption status change
|
|
DispatchQueue.main.async { [weak self] in
|
|
self?.scheduleUIUpdate()
|
|
}
|
|
|
|
// Also refresh the peer list to update encryption status
|
|
Task { @MainActor in
|
|
peerManager?.updatePeers()
|
|
}
|
|
} else if selectedPrivateChatPeer == nil {
|
|
// Just set the peer ID if we don't have one
|
|
selectedPrivateChatPeer = currentPeerID
|
|
DispatchQueue.main.async { [weak self] in
|
|
self?.scheduleUIUpdate()
|
|
}
|
|
}
|
|
|
|
// 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 peer is available on mesh (actually connected, not just known)
|
|
let meshPeers = meshService.getPeerNicknames()
|
|
let peerAvailableOnMesh = meshService.isPeerConnected(peerID)
|
|
let recipientNickname: String
|
|
|
|
if let meshNickname = meshPeers[peerID] {
|
|
recipientNickname = meshNickname
|
|
} else {
|
|
// Try to get from favorites
|
|
guard let noiseKey = Data(hexString: peerID),
|
|
let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey) else {
|
|
return
|
|
}
|
|
recipientNickname = favoriteStatus.peerNickname
|
|
}
|
|
|
|
// Check if the recipient is blocked
|
|
if isPeerBlocked(peerID) {
|
|
let systemMessage = BitchatMessage(
|
|
sender: "system",
|
|
content: "cannot send message to \(recipientNickname): user is blocked.",
|
|
timestamp: Date(),
|
|
isRelay: false
|
|
)
|
|
messages.append(systemMessage)
|
|
return
|
|
}
|
|
|
|
// IMPORTANT: When sending a message, it means we're viewing this chat
|
|
// Send read receipts for any delivered messages from this peer
|
|
markPrivateMessagesAsRead(from: peerID)
|
|
|
|
// Create the message locally
|
|
let message = BitchatMessage(
|
|
sender: nickname,
|
|
content: content,
|
|
timestamp: Date(),
|
|
isRelay: false,
|
|
originalSender: nil,
|
|
isPrivate: true,
|
|
recipientNickname: recipientNickname,
|
|
senderPeerID: meshService.myPeerID,
|
|
deliveryStatus: .sending
|
|
)
|
|
|
|
// Add to our private chat history
|
|
if privateChats[peerID] == nil {
|
|
privateChats[peerID] = []
|
|
}
|
|
privateChats[peerID]?.append(message)
|
|
trimPrivateChatMessagesIfNeeded(for: peerID)
|
|
|
|
// Track the message for delivery confirmation
|
|
let isFavorite = isFavorite(peerID: peerID)
|
|
DeliveryTracker.shared.trackMessage(message, recipientID: peerID, recipientNickname: recipientNickname, isFavorite: isFavorite)
|
|
|
|
// Immediate UI update for user's own messages
|
|
objectWillChange.send()
|
|
|
|
// Determine how to send the message
|
|
guard let noiseKey = Data(hexString: peerID) else { return }
|
|
|
|
if peerAvailableOnMesh {
|
|
// Send via mesh with the same message ID
|
|
meshService.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: message.id)
|
|
} else if let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
|
|
favoriteStatus.isMutual {
|
|
// Mutual favorite offline - send via Nostr
|
|
SecureLogger.log("🌐 Sending private message to offline mutual favorite \(recipientNickname) via Nostr",
|
|
category: SecureLogger.session, level: .info)
|
|
|
|
Task {
|
|
do {
|
|
try await messageRouter?.sendMessage(content, to: noiseKey, messageId: message.id)
|
|
} catch {
|
|
SecureLogger.log("Failed to send message via Nostr: \(error)",
|
|
category: SecureLogger.session, level: .error)
|
|
// DeliveryTracker will handle timeout automatically
|
|
}
|
|
}
|
|
} else {
|
|
// Not reachable - show error to user
|
|
SecureLogger.log("⚠️ Cannot send message to \(recipientNickname) - not available on mesh or Nostr",
|
|
category: SecureLogger.session, level: .warning)
|
|
|
|
// Add system message to inform user
|
|
let systemMessage = BitchatMessage(
|
|
sender: "system",
|
|
content: "Cannot send message to \(recipientNickname) - peer is not reachable via mesh or Nostr.",
|
|
timestamp: Date(),
|
|
isRelay: false
|
|
)
|
|
addMessageToBatch(systemMessage)
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
SecureLogger.log("⚠️ Attempted to start private chat with self, ignoring",
|
|
category: SecureLogger.session, level: .warning)
|
|
return
|
|
}
|
|
|
|
// Try to get nickname from mesh first, then from peer list
|
|
let peerNickname = meshService.getPeerNicknames()[peerID] ??
|
|
peerIndex[peerID]?.displayName ??
|
|
"unknown"
|
|
|
|
// Check if the peer is blocked
|
|
if isPeerBlocked(peerID) {
|
|
let systemMessage = BitchatMessage(
|
|
sender: "system",
|
|
content: "cannot start chat with \(peerNickname): user is blocked.",
|
|
timestamp: Date(),
|
|
isRelay: false
|
|
)
|
|
messages.append(systemMessage)
|
|
return
|
|
}
|
|
|
|
// Check if this is a moon peer (we favorite them but they don't favorite us) AND they're offline
|
|
// Only require mutual favorites for offline Nostr messaging
|
|
if let peer = peerIndex[peerID],
|
|
peer.isFavorite && !peer.theyFavoritedUs && !peer.isConnected && !peer.isRelayConnected {
|
|
let systemMessage = BitchatMessage(
|
|
sender: "system",
|
|
content: "cannot start chat with \(peerNickname): mutual favorite required for offline messaging.",
|
|
timestamp: Date(),
|
|
isRelay: false
|
|
)
|
|
messages.append(systemMessage)
|
|
return
|
|
}
|
|
|
|
// Trigger handshake if we don't have a session yet
|
|
let sessionState = meshService.getNoiseSessionState(for: peerID)
|
|
switch sessionState {
|
|
case .none, .failed:
|
|
// Initiate handshake when opening PM
|
|
meshService.triggerHandshake(with: peerID)
|
|
default:
|
|
break
|
|
}
|
|
|
|
selectedPrivateChatPeer = peerID
|
|
// Also track by fingerprint for persistence across reconnections
|
|
selectedPrivateChatFingerprint = peerIDToPublicKeyFingerprint[peerID]
|
|
unreadPrivateMessages.remove(peerID)
|
|
|
|
// Check if we need to migrate messages from an old peer ID
|
|
// This happens when peer IDs change between sessions
|
|
if privateChats[peerID] == nil || privateChats[peerID]?.isEmpty == true {
|
|
|
|
// Get the fingerprint for this peer
|
|
let currentFingerprint = getFingerprint(for: peerID)
|
|
|
|
// Look for messages under other peer IDs with the same fingerprint
|
|
var migratedMessages: [BitchatMessage] = []
|
|
var oldPeerIDsToRemove: [String] = []
|
|
|
|
for (oldPeerID, messages) in privateChats {
|
|
if oldPeerID != peerID {
|
|
// Check if this old peer ID has the same fingerprint (same actual peer)
|
|
let oldFingerprint = peerIDToPublicKeyFingerprint[oldPeerID]
|
|
|
|
if let currentFp = currentFingerprint,
|
|
let oldFp = oldFingerprint,
|
|
currentFp == oldFp {
|
|
// Same peer with different ID - migrate all messages
|
|
migratedMessages.append(contentsOf: messages)
|
|
oldPeerIDsToRemove.append(oldPeerID)
|
|
|
|
SecureLogger.log("📦 Migrating \(messages.count) messages from old peer ID \(oldPeerID) to \(peerID) based on fingerprint match",
|
|
category: SecureLogger.session, level: .info)
|
|
} else if currentFingerprint == nil || oldFingerprint == nil {
|
|
// Fallback: use nickname matching only if we don't have fingerprints
|
|
// This is less reliable but handles legacy data
|
|
let messagesWithPeer = messages.filter { msg in
|
|
// Message is FROM the peer to us
|
|
(msg.sender == peerNickname && msg.sender != nickname) ||
|
|
// OR message is FROM us TO the peer
|
|
(msg.sender == nickname && (msg.recipientNickname == peerNickname ||
|
|
// Also check if this was a private message in a chat that only has us and one other person
|
|
(msg.isPrivate && messages.allSatisfy { m in
|
|
m.sender == nickname || m.sender == peerNickname
|
|
})))
|
|
}
|
|
|
|
if !messagesWithPeer.isEmpty {
|
|
// Check if ALL messages in this chat are between us and this peer
|
|
let allMessagesAreWithPeer = messages.allSatisfy { msg in
|
|
(msg.sender == peerNickname || msg.sender == nickname) &&
|
|
(msg.recipientNickname == nil || msg.recipientNickname == peerNickname || msg.recipientNickname == nickname)
|
|
}
|
|
|
|
if allMessagesAreWithPeer {
|
|
// This entire chat history likely belongs to this peer, migrate it all
|
|
migratedMessages.append(contentsOf: messages)
|
|
oldPeerIDsToRemove.append(oldPeerID)
|
|
|
|
SecureLogger.log("📦 Migrating \(messages.count) messages from old peer ID \(oldPeerID) to \(peerID) based on nickname match (no fingerprints available)",
|
|
category: SecureLogger.session, level: .warning)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Remove old peer ID entries that were fully migrated
|
|
for oldPeerID in oldPeerIDsToRemove {
|
|
privateChats.removeValue(forKey: oldPeerID)
|
|
unreadPrivateMessages.remove(oldPeerID)
|
|
}
|
|
|
|
// Initialize chat history with migrated messages if any
|
|
if !migratedMessages.isEmpty {
|
|
privateChats[peerID] = migratedMessages.sorted { $0.timestamp < $1.timestamp }
|
|
trimPrivateChatMessagesIfNeeded(for: peerID)
|
|
} else {
|
|
privateChats[peerID] = []
|
|
}
|
|
}
|
|
|
|
_ = privateChats[peerID] ?? []
|
|
|
|
// Send read receipts for unread messages from this peer
|
|
// Add a small delay to ensure UI has updated
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
|
|
self?.markPrivateMessagesAsRead(from: peerID)
|
|
}
|
|
|
|
// Also try immediately in case messages are already there
|
|
markPrivateMessagesAsRead(from: peerID)
|
|
}
|
|
|
|
func endPrivateChat() {
|
|
selectedPrivateChatPeer = nil
|
|
selectedPrivateChatFingerprint = nil
|
|
}
|
|
|
|
// MARK: - Message Retry Handling
|
|
|
|
@objc private func handleRetryMessage(_ notification: Notification) {
|
|
guard let messageID = notification.userInfo?["messageID"] as? String else { return }
|
|
|
|
// Find the message to retry
|
|
if let message = messages.first(where: { $0.id == messageID }) {
|
|
SecureLogger.log("Retrying message \(messageID) to \(message.recipientNickname ?? "unknown")",
|
|
category: SecureLogger.session, level: .info)
|
|
|
|
// Resend the message through mesh service
|
|
if message.isPrivate,
|
|
let peerID = getPeerIDForNickname(message.recipientNickname ?? "") {
|
|
// Update status to sending
|
|
updateMessageDeliveryStatus(messageID, status: .sending)
|
|
|
|
// Resend via mesh service
|
|
meshService.sendMessage(message.content,
|
|
mentions: message.mentions ?? [],
|
|
to: peerID,
|
|
messageID: messageID,
|
|
timestamp: message.timestamp)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Nostr Message Handling
|
|
|
|
@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,
|
|
let senderNoiseKey = notification.userInfo?["senderNoiseKey"] as? Data else { return }
|
|
|
|
let senderHexId = senderNoiseKey.hexEncodedString()
|
|
|
|
SecureLogger.log("✅ Handling delivery acknowledgment for message \(messageId) from \(senderHexId)",
|
|
category: SecureLogger.session, level: .info)
|
|
|
|
// 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
|
|
scheduleUIUpdate()
|
|
}
|
|
|
|
// 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())
|
|
scheduleUIUpdate()
|
|
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)
|
|
}
|
|
|
|
@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] {
|
|
privateChats[newPeerID] = messages
|
|
privateChats.removeValue(forKey: oldPeerID)
|
|
}
|
|
|
|
// 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
|
|
scheduleUIUpdate()
|
|
} 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)
|
|
privateChats[newPeerID] = messages
|
|
privateChats.removeValue(forKey: oldPeerID)
|
|
}
|
|
|
|
// 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
|
|
addMessageToBatch(systemMessage)
|
|
|
|
// Update peer manager to refresh UI
|
|
peerManager?.updatePeers()
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
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
|
|
)
|
|
if privateChats[peerID] == nil {
|
|
privateChats[peerID] = []
|
|
}
|
|
privateChats[peerID]?.append(localNotification)
|
|
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
|
|
)
|
|
// System messages can be batched
|
|
addMessageToBatch(localNotification)
|
|
}
|
|
}
|
|
|
|
@objc private func appWillResignActive() {
|
|
// Flush any pending messages when app goes to background
|
|
flushMessageBatchImmediately()
|
|
|
|
// Flush any pending UI updates
|
|
flushUIUpdateImmediately()
|
|
|
|
userDefaults.synchronize()
|
|
}
|
|
|
|
@objc func applicationWillTerminate() {
|
|
// Flush any pending messages immediately
|
|
flushMessageBatchImmediately()
|
|
|
|
// 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() {
|
|
// Flush any pending messages immediately
|
|
flushMessageBatchImmediately()
|
|
|
|
// Flush any pending UI updates
|
|
flushUIUpdateImmediately()
|
|
|
|
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" {
|
|
// Message was received via Nostr, send read receipt via Nostr
|
|
if let noiseKey = Data(hexString: actualPeerID) {
|
|
Task { @MainActor in
|
|
try? await messageRouter?.sendReadReceipt(for: receipt.originalMessageID, to: noiseKey, preferredTransport: .nostr)
|
|
}
|
|
} else if let nostrPubkey = nostrKeyMapping[actualPeerID] {
|
|
// This is a Nostr-only peer we haven't mapped to a Noise key yet
|
|
Task { @MainActor in
|
|
if let tempNoiseKey = Data(hexString: nostrPubkey) {
|
|
try? await messageRouter?.sendReadReceipt(for: receipt.originalMessageID, to: tempNoiseKey, preferredTransport: .nostr)
|
|
}
|
|
}
|
|
}
|
|
} else if meshService.getPeerNicknames()[actualPeerID] != nil {
|
|
// Use mesh for connected peers (default behavior)
|
|
meshService.sendReadReceipt(receipt, to: actualPeerID)
|
|
} else {
|
|
// Try Nostr for offline peers
|
|
if let noiseKey = Data(hexString: actualPeerID) {
|
|
Task { @MainActor in
|
|
try? await messageRouter?.sendReadReceipt(for: receipt.originalMessageID, to: noiseKey)
|
|
}
|
|
} else if let nostrPubkey = nostrKeyMapping[actualPeerID] {
|
|
// This is a Nostr-only peer we haven't mapped to a Noise key yet
|
|
Task { @MainActor in
|
|
if let tempNoiseKey = Data(hexString: nostrPubkey) {
|
|
try? await messageRouter?.sendReadReceipt(for: receipt.originalMessageID, to: tempNoiseKey)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
func markPrivateMessagesAsRead(from peerID: String) {
|
|
// Get the nickname for this peer
|
|
let peerNickname = meshService.getPeerNicknames()[peerID] ?? ""
|
|
|
|
// First ensure we have the latest messages (in case of migration)
|
|
if let messages = privateChats[peerID], !messages.isEmpty {
|
|
} else {
|
|
|
|
// Look through ALL private chats to find messages from this nickname
|
|
for (_, chatMessages) in privateChats {
|
|
let relevantMessages = chatMessages.filter { msg in
|
|
msg.sender == peerNickname && msg.sender != nickname
|
|
}
|
|
if !relevantMessages.isEmpty {
|
|
}
|
|
}
|
|
}
|
|
|
|
guard let messages = privateChats[peerID], !messages.isEmpty else {
|
|
return
|
|
}
|
|
|
|
|
|
// Find messages from the peer that haven't been read yet
|
|
var readReceiptsSent = 0
|
|
for (_, message) in messages.enumerated() {
|
|
// Only send read receipts for messages from the other peer (not our own)
|
|
// Check multiple conditions to ensure we catch all messages from the peer
|
|
let isOurMessage = message.sender == nickname
|
|
let isFromPeerByNickname = !peerNickname.isEmpty && message.sender == peerNickname
|
|
|
|
// Check if message is from this peer by comparing IDs
|
|
// Handle both Noise and Nostr keys
|
|
var isFromPeerByID = false
|
|
if let msgSenderID = message.senderPeerID {
|
|
// Direct match
|
|
isFromPeerByID = msgSenderID == peerID
|
|
|
|
// If no direct match, check if we're comparing Nostr keys
|
|
if !isFromPeerByID {
|
|
// Check if the peerID has a favorite entry with a Nostr key that matches
|
|
if let noiseKey = Data(hexString: peerID),
|
|
let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
|
|
let nostrKey = favoriteStatus.peerNostrPublicKey {
|
|
isFromPeerByID = msgSenderID == nostrKey
|
|
}
|
|
|
|
// getFavoriteStatusByNostrKey not implemented
|
|
// Or check if the message sender ID maps to this peer's Noise key
|
|
// if !isFromPeerByID,
|
|
// let senderFavorite = FavoritesPersistenceService.shared.getFavoriteStatusByNostrKey(msgSenderID),
|
|
// senderFavorite.peerNoisePublicKey.hexEncodedString() == peerID {
|
|
// isFromPeerByID = true
|
|
// }
|
|
}
|
|
}
|
|
|
|
let isPrivateToUs = message.isPrivate && message.recipientNickname == nickname
|
|
|
|
// This is a message FROM the peer if it's not from us AND (matches nickname OR peer ID OR is private to us)
|
|
let isFromPeer = !isOurMessage && (isFromPeerByNickname || isFromPeerByID || isPrivateToUs)
|
|
|
|
if message.id == message.id { // Always true, for debugging
|
|
}
|
|
|
|
if isFromPeer {
|
|
if let status = message.deliveryStatus {
|
|
switch status {
|
|
case .sent, .delivered:
|
|
// Create and send read receipt for sent or delivered messages
|
|
// Check if we've already sent a receipt for this message
|
|
if !sentReadReceipts.contains(message.id) {
|
|
let receipt = ReadReceipt(
|
|
originalMessageID: message.id,
|
|
readerID: meshService.myPeerID,
|
|
readerNickname: nickname
|
|
)
|
|
|
|
// Send read receipt to the message sender
|
|
// Use the message's senderPeerID if available (for Nostr messages)
|
|
// Otherwise use the current peerID
|
|
let recipientID = message.senderPeerID ?? peerID
|
|
|
|
// Check if message was delivered via Nostr
|
|
var originalTransport: String? = nil
|
|
if let status = message.deliveryStatus {
|
|
switch status {
|
|
case .delivered(let transport, _):
|
|
originalTransport = transport
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
|
|
sendReadReceipt(receipt, to: recipientID, originalTransport: originalTransport)
|
|
sentReadReceipts.insert(message.id)
|
|
readReceiptsSent += 1
|
|
} else {
|
|
}
|
|
case .read:
|
|
// Already read, no need to send another receipt
|
|
break
|
|
default:
|
|
// Message not yet delivered, can't mark as read
|
|
break
|
|
}
|
|
} else {
|
|
// No delivery status - this might be an older message
|
|
// Send read receipt anyway for backwards compatibility
|
|
if !sentReadReceipts.contains(message.id) {
|
|
let receipt = ReadReceipt(
|
|
originalMessageID: message.id,
|
|
readerID: meshService.myPeerID,
|
|
readerNickname: nickname
|
|
)
|
|
// Use the message's senderPeerID if available (for Nostr messages)
|
|
let recipientID = message.senderPeerID ?? peerID
|
|
|
|
// For backwards compatibility, check if this might be a Nostr message
|
|
// by checking if the sender has a Nostr key
|
|
let receiptToSend = receipt
|
|
let recipientToSend = recipientID
|
|
Task { @MainActor in
|
|
var originalTransport: String? = nil
|
|
if let noiseKey = Data(hexString: recipientToSend),
|
|
let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
|
|
favoriteStatus.peerNostrPublicKey != nil,
|
|
self.meshService.getPeerNicknames()[recipientToSend] == nil {
|
|
// Peer has Nostr key and is not on mesh, likely received via Nostr
|
|
originalTransport = "nostr"
|
|
}
|
|
|
|
self.sendReadReceipt(receiptToSend, to: recipientToSend, originalTransport: originalTransport)
|
|
}
|
|
sentReadReceipts.insert(message.id)
|
|
readReceiptsSent += 1
|
|
} else {
|
|
}
|
|
}
|
|
} else {
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
func getPrivateChatMessages(for peerID: String) -> [BitchatMessage] {
|
|
let messages = privateChats[peerID] ?? []
|
|
if !messages.isEmpty {
|
|
}
|
|
return messages
|
|
}
|
|
|
|
func getPeerIDForNickname(_ nickname: String) -> String? {
|
|
let nicknames = meshService.getPeerNicknames()
|
|
return nicknames.first(where: { $0.value == nickname })?.key
|
|
}
|
|
|
|
|
|
// MARK: - Emergency Functions
|
|
|
|
// PANIC: Emergency data clearing for activist safety
|
|
@MainActor
|
|
func panicClearAllData() {
|
|
// Flush any pending messages immediately before clearing
|
|
flushMessageBatchImmediately()
|
|
|
|
// Clear all messages
|
|
messages.removeAll()
|
|
privateChats.removeAll()
|
|
unreadPrivateMessages.removeAll()
|
|
|
|
// First run aggressive cleanup to get rid of all legacy items
|
|
_ = KeychainManager.shared.aggressiveCleanupLegacyItems()
|
|
|
|
// Then delete all current keychain data
|
|
_ = KeychainManager.shared.deleteAllKeychainData()
|
|
|
|
// Clear UserDefaults identity fallbacks
|
|
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
|
|
|
|
// Clear message retry queue
|
|
MessageRetryService.shared.clearRetryQueue()
|
|
|
|
|
|
// Reset nickname to anonymous
|
|
nickname = "anon\(Int.random(in: 1000...9999))"
|
|
saveNickname()
|
|
|
|
// Clear favorites
|
|
favoritePeers.removeAll()
|
|
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()
|
|
|
|
// Clear all caches
|
|
invalidateEncryptionCache()
|
|
|
|
// 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()
|
|
|
|
// Force immediate UI update for panic mode
|
|
flushUIUpdateImmediately()
|
|
|
|
}
|
|
|
|
|
|
|
|
// 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) {
|
|
// Quick early exit for empty text
|
|
guard cursorPosition > 0 else {
|
|
if showAutocomplete {
|
|
showAutocomplete = false
|
|
autocompleteSuggestions = []
|
|
autocompleteRange = nil
|
|
}
|
|
return
|
|
}
|
|
|
|
// Find @ symbol before cursor
|
|
let beforeCursor = String(text.prefix(cursorPosition))
|
|
|
|
// Use cached regex
|
|
guard let regex = mentionRegex,
|
|
let match = regex.firstMatch(in: beforeCursor, options: [], range: NSRange(location: 0, length: beforeCursor.count)) else {
|
|
if showAutocomplete {
|
|
showAutocomplete = false
|
|
autocompleteSuggestions = []
|
|
autocompleteRange = nil
|
|
}
|
|
return
|
|
}
|
|
|
|
// Extract the partial nickname
|
|
let partialRange = match.range(at: 1)
|
|
guard let range = Range(partialRange, in: beforeCursor) else {
|
|
if showAutocomplete {
|
|
showAutocomplete = false
|
|
autocompleteSuggestions = []
|
|
autocompleteRange = nil
|
|
}
|
|
return
|
|
}
|
|
|
|
let partial = String(beforeCursor[range]).lowercased()
|
|
|
|
// Update cached nicknames only if peer list changed (check every 1 second max)
|
|
let now = Date()
|
|
if now.timeIntervalSince(lastNicknameUpdate) > 1.0 || cachedNicknames.isEmpty {
|
|
let peerNicknames = meshService.getPeerNicknames()
|
|
cachedNicknames = Array(peerNicknames.values).sorted()
|
|
lastNicknameUpdate = now
|
|
}
|
|
|
|
// Filter suggestions using cached nicknames
|
|
let suggestions = cachedNicknames.filter { nick in
|
|
nick.lowercased().hasPrefix(partial)
|
|
}
|
|
|
|
// Batch UI updates
|
|
if !suggestions.isEmpty {
|
|
// Only update if suggestions changed
|
|
if autocompleteSuggestions != suggestions {
|
|
autocompleteSuggestions = suggestions
|
|
}
|
|
if !showAutocomplete {
|
|
showAutocomplete = true
|
|
}
|
|
if autocompleteRange != match.range(at: 0) {
|
|
autocompleteRange = match.range(at: 0)
|
|
}
|
|
selectedAutocompleteIndex = 0
|
|
} else {
|
|
if showAutocomplete {
|
|
showAutocomplete = false
|
|
autocompleteSuggestions = []
|
|
autocompleteRange = nil
|
|
selectedAutocompleteIndex = 0
|
|
}
|
|
}
|
|
}
|
|
|
|
func completeNickname(_ nickname: String, in text: inout String) -> Int {
|
|
guard let range = autocompleteRange else { return text.count }
|
|
|
|
// Replace the @partial with @nickname
|
|
let nsText = text as NSString
|
|
let newText = nsText.replacingCharacters(in: range, with: "@\(nickname) ")
|
|
text = newText
|
|
|
|
// Hide autocomplete
|
|
showAutocomplete = false
|
|
autocompleteSuggestions = []
|
|
autocompleteRange = nil
|
|
selectedAutocompleteIndex = 0
|
|
|
|
// Return new cursor position (after the space)
|
|
return range.location + nickname.count + 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 = "@([a-zA-Z0-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)
|
|
let secondaryColor = primaryColor.opacity(0.7)
|
|
|
|
// Timestamp
|
|
let timestamp = AttributedString("[\(formatTimestamp(message.timestamp))] ")
|
|
var timestampStyle = AttributeContainer()
|
|
timestampStyle.foregroundColor = message.sender == "system" ? Color.gray : secondaryColor
|
|
timestampStyle.font = .system(size: 12, design: .monospaced)
|
|
result.append(timestamp.mergingAttributes(timestampStyle))
|
|
|
|
if message.sender != "system" {
|
|
// Sender
|
|
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 = "@([a-zA-Z0-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))
|
|
}
|
|
} 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))
|
|
}
|
|
|
|
// 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)
|
|
let secondaryColor = primaryColor.opacity(0.7)
|
|
|
|
let timestamp = AttributedString("[\(formatTimestamp(message.timestamp))] ")
|
|
var timestampStyle = AttributeContainer()
|
|
timestampStyle.foregroundColor = message.sender == "system" ? Color.gray : secondaryColor
|
|
timestampStyle.font = .system(size: 12, design: .monospaced)
|
|
result.append(timestamp.mergingAttributes(timestampStyle))
|
|
|
|
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))
|
|
} 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 = "@([a-zA-Z0-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 = secondaryColor
|
|
relayStyle.font = .system(size: 11, design: .monospaced)
|
|
result.append(relay.mergingAttributes(relayStyle))
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// MARK: - Noise Protocol Support
|
|
|
|
func updateEncryptionStatusForPeers() {
|
|
for peerID in connectedPeers {
|
|
updateEncryptionStatusForPeer(peerID)
|
|
}
|
|
}
|
|
|
|
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)
|
|
|
|
// Schedule UI update
|
|
DispatchQueue.main.async { [weak self] in
|
|
self?.scheduleUIUpdate()
|
|
}
|
|
}
|
|
|
|
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 storedStatus = peerEncryptionStatus[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
|
|
|
|
// Only log occasionally to avoid spam
|
|
if Int.random(in: 0..<100) == 0 {
|
|
SecureLogger.log("getEncryptionStatus for \(peerID): sessionState=\(sessionState), stored=\(String(describing: storedStatus)), hasEverEstablished=\(hasEverEstablishedSession), final=\(status)", category: SecureLogger.security, level: .debug)
|
|
}
|
|
|
|
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 Batching
|
|
|
|
private func trimMessagesIfNeeded() {
|
|
if messages.count > maxMessages {
|
|
let removeCount = messages.count - maxMessages
|
|
messages.removeFirst(removeCount)
|
|
}
|
|
}
|
|
|
|
private func trimPrivateChatMessagesIfNeeded(for peerID: String) {
|
|
if let count = privateChats[peerID]?.count, count > maxMessages {
|
|
let removeCount = count - maxMessages
|
|
privateChats[peerID]?.removeFirst(removeCount)
|
|
}
|
|
}
|
|
|
|
private func addMessageToBatch(_ message: BitchatMessage) {
|
|
pendingMessages.append(message)
|
|
scheduleBatchFlush()
|
|
}
|
|
|
|
private func addPrivateMessageToBatch(_ message: BitchatMessage, for peerID: String) {
|
|
if pendingPrivateMessages[peerID] == nil {
|
|
pendingPrivateMessages[peerID] = []
|
|
}
|
|
pendingPrivateMessages[peerID]?.append(message)
|
|
scheduleBatchFlush()
|
|
}
|
|
|
|
private func scheduleBatchFlush() {
|
|
// Cancel existing timer
|
|
messageBatchTimer?.invalidate()
|
|
|
|
// Schedule new flush
|
|
messageBatchTimer = Timer.scheduledTimer(withTimeInterval: messageBatchInterval, repeats: false) { [weak self] _ in
|
|
self?.flushMessageBatch()
|
|
}
|
|
}
|
|
|
|
private func flushMessageBatch() {
|
|
DispatchQueue.main.async { [weak self] in
|
|
guard let self = self else { return }
|
|
|
|
// Process pending public messages
|
|
if !self.pendingMessages.isEmpty {
|
|
let messagesToAdd = self.pendingMessages
|
|
self.pendingMessages.removeAll()
|
|
|
|
// Add all messages at once
|
|
self.messages.append(contentsOf: messagesToAdd)
|
|
|
|
// Sort once after batch addition
|
|
self.messages.sort { $0.timestamp < $1.timestamp }
|
|
|
|
// Trim once if needed
|
|
self.trimMessagesIfNeeded()
|
|
}
|
|
|
|
// Process pending private messages
|
|
if !self.pendingPrivateMessages.isEmpty {
|
|
let privateMessageBatches = self.pendingPrivateMessages
|
|
self.pendingPrivateMessages.removeAll()
|
|
|
|
for (peerID, messagesToAdd) in privateMessageBatches {
|
|
if self.privateChats[peerID] == nil {
|
|
self.privateChats[peerID] = []
|
|
}
|
|
|
|
// Add all messages for this peer at once
|
|
self.privateChats[peerID]?.append(contentsOf: messagesToAdd)
|
|
|
|
// Sort once after batch addition
|
|
self.privateChats[peerID]?.sort { $0.timestamp < $1.timestamp }
|
|
|
|
// Trim once if needed
|
|
self.trimPrivateChatMessagesIfNeeded(for: peerID)
|
|
}
|
|
}
|
|
|
|
// Single scheduled UI update for all changes
|
|
self.scheduleUIUpdate()
|
|
}
|
|
}
|
|
|
|
// Force immediate flush for high-priority messages
|
|
private func flushMessageBatchImmediately() {
|
|
messageBatchTimer?.invalidate()
|
|
flushMessageBatch()
|
|
}
|
|
|
|
// MARK: - UI Update Debouncing
|
|
|
|
// Schedule a debounced UI update
|
|
private func scheduleUIUpdate() {
|
|
guard !pendingUIUpdate else { return } // Already scheduled
|
|
|
|
pendingUIUpdate = true
|
|
|
|
// Cancel existing timer
|
|
uiUpdateTimer?.invalidate()
|
|
|
|
// Schedule new update
|
|
uiUpdateTimer = Timer.scheduledTimer(withTimeInterval: uiUpdateInterval, repeats: false) { [weak self] _ in
|
|
self?.flushUIUpdate()
|
|
}
|
|
}
|
|
|
|
// Execute the pending UI update
|
|
private func flushUIUpdate() {
|
|
DispatchQueue.main.async { [weak self] in
|
|
guard let self = self else { return }
|
|
|
|
self.pendingUIUpdate = false
|
|
self.objectWillChange.send()
|
|
}
|
|
}
|
|
|
|
// Force immediate UI update for critical user actions
|
|
private func flushUIUpdateImmediately() {
|
|
uiUpdateTimer?.invalidate()
|
|
pendingUIUpdate = false
|
|
objectWillChange.send()
|
|
}
|
|
|
|
// Update encryption status in appropriate places, not during view updates
|
|
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)
|
|
|
|
// Schedule UI update
|
|
DispatchQueue.main.async { [weak self] in
|
|
self?.scheduleUIUpdate()
|
|
}
|
|
}
|
|
|
|
// MARK: - Fingerprint Management
|
|
|
|
func showFingerprint(for peerID: String) {
|
|
showingFingerprintFor = peerID
|
|
}
|
|
|
|
// MARK: - Peer Lookup Helpers
|
|
|
|
func getPeer(byID peerID: String) -> BitchatPeer? {
|
|
return peerIndex[peerID]
|
|
}
|
|
|
|
func getFingerprint(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
|
|
}
|
|
|
|
// Fallback to 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
|
|
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
|
|
}
|
|
}
|
|
|
|
// Fallback to 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
|
|
}
|
|
|
|
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("ChatViewModel: Peer authenticated - \(peerID), fingerprint: \(fingerprint)", category: SecureLogger.security, level: .info)
|
|
|
|
// Update encryption status
|
|
if self.verifiedFingerprints.contains(fingerprint) {
|
|
self.peerEncryptionStatus[peerID] = .noiseVerified
|
|
SecureLogger.log("ChatViewModel: Setting encryption status to noiseVerified for \(peerID)", category: SecureLogger.security, level: .info)
|
|
} else {
|
|
self.peerEncryptionStatus[peerID] = .noiseSecured
|
|
SecureLogger.log("ChatViewModel: Setting encryption status to noiseSecured for \(peerID)", category: SecureLogger.security, level: .info)
|
|
}
|
|
|
|
// Invalidate cache when encryption status changes
|
|
self.invalidateEncryptionCache(for: peerID)
|
|
|
|
// Schedule UI update
|
|
self.scheduleUIUpdate()
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
|
|
// Schedule UI update
|
|
self.scheduleUIUpdate()
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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 parts = command.split(separator: " ")
|
|
guard let cmd = parts.first else { return }
|
|
|
|
switch cmd {
|
|
case "/m", "/msg":
|
|
if parts.count > 1 {
|
|
let targetName = String(parts[1])
|
|
// Remove @ if present
|
|
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
|
|
|
// Find peer ID for this nickname
|
|
if let peerID = getPeerIDForNickname(nickname) {
|
|
startPrivateChat(with: peerID)
|
|
|
|
// If there's a message after the nickname, send it
|
|
if parts.count > 2 {
|
|
let messageContent = parts[2...].joined(separator: " ")
|
|
sendPrivateMessage(messageContent, to: peerID)
|
|
} else {
|
|
let systemMessage = BitchatMessage(
|
|
sender: "system",
|
|
content: "started private chat with \(nickname)",
|
|
timestamp: Date(),
|
|
isRelay: false
|
|
)
|
|
messages.append(systemMessage)
|
|
}
|
|
} else {
|
|
let systemMessage = BitchatMessage(
|
|
sender: "system",
|
|
content: "user '\(nickname)' not found. they may be offline or using a different nickname.",
|
|
timestamp: Date(),
|
|
isRelay: false
|
|
)
|
|
messages.append(systemMessage)
|
|
}
|
|
} else {
|
|
let systemMessage = BitchatMessage(
|
|
sender: "system",
|
|
content: "usage: /m @nickname [message] or /m nickname [message]",
|
|
timestamp: Date(),
|
|
isRelay: false
|
|
)
|
|
messages.append(systemMessage)
|
|
}
|
|
case "/w":
|
|
let peerNicknames = meshService.getPeerNicknames()
|
|
if connectedPeers.isEmpty {
|
|
let systemMessage = BitchatMessage(
|
|
sender: "system",
|
|
content: "no one else is online right now.",
|
|
timestamp: Date(),
|
|
isRelay: false
|
|
)
|
|
messages.append(systemMessage)
|
|
} else {
|
|
let onlineList = connectedPeers.compactMap { peerID in
|
|
peerNicknames[peerID]
|
|
}.sorted().joined(separator: ", ")
|
|
|
|
let systemMessage = BitchatMessage(
|
|
sender: "system",
|
|
content: "online users: \(onlineList)",
|
|
timestamp: Date(),
|
|
isRelay: false
|
|
)
|
|
messages.append(systemMessage)
|
|
}
|
|
case "/clear":
|
|
// Clear messages based on current context
|
|
if let peerID = selectedPrivateChatPeer {
|
|
// Clear private chat
|
|
privateChats[peerID]?.removeAll()
|
|
} else {
|
|
// Clear main messages
|
|
messages.removeAll()
|
|
}
|
|
case "/hug":
|
|
if parts.count > 1 {
|
|
let targetName = String(parts[1])
|
|
// Remove @ if present
|
|
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
|
|
|
// Check if target exists in connected peers
|
|
if let targetPeerID = getPeerIDForNickname(nickname) {
|
|
// Create hug message
|
|
let hugMessage = BitchatMessage(
|
|
sender: "system",
|
|
content: "🫂 \(self.nickname) hugs \(nickname)",
|
|
timestamp: Date(),
|
|
isRelay: false,
|
|
isPrivate: false,
|
|
recipientNickname: nickname,
|
|
senderPeerID: meshService.myPeerID
|
|
)
|
|
|
|
// Send as a regular message but it will be displayed as system message due to content
|
|
let hugContent = "* 🫂 \(self.nickname) hugs \(nickname) *"
|
|
if selectedPrivateChatPeer != nil {
|
|
// In private chat, send as private message
|
|
if let peerNickname = meshService.getPeerNicknames()[targetPeerID] {
|
|
meshService.sendPrivateMessage("* 🫂 \(self.nickname) hugs you *", to: targetPeerID, recipientNickname: peerNickname)
|
|
}
|
|
} else {
|
|
// In public chat
|
|
meshService.sendMessage(hugContent)
|
|
messages.append(hugMessage)
|
|
}
|
|
} else {
|
|
let errorMessage = BitchatMessage(
|
|
sender: "system",
|
|
content: "cannot hug \(nickname): user not found.",
|
|
timestamp: Date(),
|
|
isRelay: false
|
|
)
|
|
messages.append(errorMessage)
|
|
}
|
|
} else {
|
|
let usageMessage = BitchatMessage(
|
|
sender: "system",
|
|
content: "usage: /hug <nickname>",
|
|
timestamp: Date(),
|
|
isRelay: false
|
|
)
|
|
messages.append(usageMessage)
|
|
}
|
|
|
|
case "/slap":
|
|
if parts.count > 1 {
|
|
let targetName = String(parts[1])
|
|
// Remove @ if present
|
|
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
|
|
|
// Check if target exists in connected peers
|
|
if let targetPeerID = getPeerIDForNickname(nickname) {
|
|
// Create slap message
|
|
let slapMessage = BitchatMessage(
|
|
sender: "system",
|
|
content: "🐟 \(self.nickname) slaps \(nickname) around a bit with a large trout",
|
|
timestamp: Date(),
|
|
isRelay: false,
|
|
isPrivate: false,
|
|
recipientNickname: nickname,
|
|
senderPeerID: meshService.myPeerID
|
|
)
|
|
|
|
// Send as a regular message but it will be displayed as system message due to content
|
|
let slapContent = "* 🐟 \(self.nickname) slaps \(nickname) around a bit with a large trout *"
|
|
if selectedPrivateChatPeer != nil {
|
|
// In private chat, send as private message
|
|
if let peerNickname = meshService.getPeerNicknames()[targetPeerID] {
|
|
meshService.sendPrivateMessage("* 🐟 \(self.nickname) slaps you around a bit with a large trout *", to: targetPeerID, recipientNickname: peerNickname)
|
|
}
|
|
} else {
|
|
// In public chat
|
|
meshService.sendMessage(slapContent)
|
|
messages.append(slapMessage)
|
|
}
|
|
} else {
|
|
let errorMessage = BitchatMessage(
|
|
sender: "system",
|
|
content: "cannot slap \(nickname): user not found.",
|
|
timestamp: Date(),
|
|
isRelay: false
|
|
)
|
|
messages.append(errorMessage)
|
|
}
|
|
} else {
|
|
let usageMessage = BitchatMessage(
|
|
sender: "system",
|
|
content: "usage: /slap <nickname>",
|
|
timestamp: Date(),
|
|
isRelay: false
|
|
)
|
|
messages.append(usageMessage)
|
|
}
|
|
|
|
case "/block":
|
|
if parts.count > 1 {
|
|
let targetName = String(parts[1])
|
|
// Remove @ if present
|
|
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
|
|
|
// Find peer ID for this nickname
|
|
if let peerID = getPeerIDForNickname(nickname) {
|
|
// Get fingerprint for persistent blocking
|
|
if let fingerprintStr = meshService.getPeerFingerprint(peerID) {
|
|
|
|
if SecureIdentityStateManager.shared.isBlocked(fingerprint: fingerprintStr) {
|
|
let systemMessage = BitchatMessage(
|
|
sender: "system",
|
|
content: "\(nickname) is already blocked.",
|
|
timestamp: Date(),
|
|
isRelay: false
|
|
)
|
|
messages.append(systemMessage)
|
|
} else {
|
|
// Update or create social identity with blocked status
|
|
if var identity = SecureIdentityStateManager.shared.getSocialIdentity(for: fingerprintStr) {
|
|
identity.isBlocked = true
|
|
identity.isFavorite = false // Remove from favorites if blocked
|
|
SecureIdentityStateManager.shared.updateSocialIdentity(identity)
|
|
} else {
|
|
let blockedIdentity = SocialIdentity(
|
|
fingerprint: fingerprintStr,
|
|
localPetname: nil,
|
|
claimedNickname: nickname,
|
|
trustLevel: .unknown,
|
|
isFavorite: false,
|
|
isBlocked: true,
|
|
notes: nil
|
|
)
|
|
SecureIdentityStateManager.shared.updateSocialIdentity(blockedIdentity)
|
|
}
|
|
|
|
// Update local sets for UI
|
|
blockedUsers.insert(fingerprintStr)
|
|
favoritePeers.remove(fingerprintStr)
|
|
|
|
let systemMessage = BitchatMessage(
|
|
sender: "system",
|
|
content: "blocked \(nickname). you will no longer receive messages from them.",
|
|
timestamp: Date(),
|
|
isRelay: false
|
|
)
|
|
messages.append(systemMessage)
|
|
}
|
|
} else {
|
|
let systemMessage = BitchatMessage(
|
|
sender: "system",
|
|
content: "cannot block \(nickname): unable to verify identity.",
|
|
timestamp: Date(),
|
|
isRelay: false
|
|
)
|
|
messages.append(systemMessage)
|
|
}
|
|
} else {
|
|
let systemMessage = BitchatMessage(
|
|
sender: "system",
|
|
content: "cannot block \(nickname): user not found.",
|
|
timestamp: Date(),
|
|
isRelay: false
|
|
)
|
|
messages.append(systemMessage)
|
|
}
|
|
} else {
|
|
// List blocked users
|
|
if blockedUsers.isEmpty {
|
|
let systemMessage = BitchatMessage(
|
|
sender: "system",
|
|
content: "no blocked peers.",
|
|
timestamp: Date(),
|
|
isRelay: false
|
|
)
|
|
messages.append(systemMessage)
|
|
} else {
|
|
// Find nicknames for blocked users
|
|
var blockedNicknames: [String] = []
|
|
for (peerID, _) in meshService.getPeerNicknames() {
|
|
if let fingerprintStr = meshService.getPeerFingerprint(peerID) {
|
|
if blockedUsers.contains(fingerprintStr) {
|
|
if let nickname = meshService.getPeerNicknames()[peerID] {
|
|
blockedNicknames.append(nickname)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let blockedList = blockedNicknames.isEmpty ? "blocked peers (not currently online)" : blockedNicknames.sorted().joined(separator: ", ")
|
|
let systemMessage = BitchatMessage(
|
|
sender: "system",
|
|
content: "blocked peers: \(blockedList)",
|
|
timestamp: Date(),
|
|
isRelay: false
|
|
)
|
|
messages.append(systemMessage)
|
|
}
|
|
}
|
|
|
|
case "/unblock":
|
|
if parts.count > 1 {
|
|
let targetName = String(parts[1])
|
|
// Remove @ if present
|
|
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
|
|
|
// Find peer ID for this nickname
|
|
if let peerID = getPeerIDForNickname(nickname) {
|
|
// Get fingerprint
|
|
if let fingerprintStr = meshService.getPeerFingerprint(peerID) {
|
|
|
|
if SecureIdentityStateManager.shared.isBlocked(fingerprint: fingerprintStr) {
|
|
// Update social identity to unblock
|
|
SecureIdentityStateManager.shared.setBlocked(fingerprintStr, isBlocked: false)
|
|
|
|
// Update local set for UI
|
|
blockedUsers.remove(fingerprintStr)
|
|
|
|
let systemMessage = BitchatMessage(
|
|
sender: "system",
|
|
content: "unblocked \(nickname).",
|
|
timestamp: Date(),
|
|
isRelay: false
|
|
)
|
|
messages.append(systemMessage)
|
|
} else {
|
|
let systemMessage = BitchatMessage(
|
|
sender: "system",
|
|
content: "\(nickname) is not blocked.",
|
|
timestamp: Date(),
|
|
isRelay: false
|
|
)
|
|
messages.append(systemMessage)
|
|
}
|
|
} else {
|
|
let systemMessage = BitchatMessage(
|
|
sender: "system",
|
|
content: "cannot unblock \(nickname): unable to verify identity.",
|
|
timestamp: Date(),
|
|
isRelay: false
|
|
)
|
|
messages.append(systemMessage)
|
|
}
|
|
} else {
|
|
let systemMessage = BitchatMessage(
|
|
sender: "system",
|
|
content: "cannot unblock \(nickname): user not found.",
|
|
timestamp: Date(),
|
|
isRelay: false
|
|
)
|
|
messages.append(systemMessage)
|
|
}
|
|
} else {
|
|
let systemMessage = BitchatMessage(
|
|
sender: "system",
|
|
content: "usage: /unblock <nickname>",
|
|
timestamp: Date(),
|
|
isRelay: false
|
|
)
|
|
messages.append(systemMessage)
|
|
}
|
|
|
|
case "/fav":
|
|
if parts.count > 1 {
|
|
let targetName = String(parts[1])
|
|
// Remove @ if present
|
|
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
|
|
|
// Find peer ID for this nickname
|
|
if let peerID = getPeerIDForNickname(nickname) {
|
|
// Add to favorites using the Nostr integration
|
|
if let noisePublicKey = Data(hexString: peerID) {
|
|
// Get or set Nostr public key
|
|
let existingFavorite = FavoritesPersistenceService.shared.getFavoriteStatus(for: noisePublicKey)
|
|
FavoritesPersistenceService.shared.addFavorite(
|
|
peerNoisePublicKey: noisePublicKey,
|
|
peerNostrPublicKey: existingFavorite?.peerNostrPublicKey,
|
|
peerNickname: nickname
|
|
)
|
|
|
|
// Toggle favorite in identity manager for UI
|
|
toggleFavorite(peerID: peerID)
|
|
|
|
// Send favorite notification
|
|
Task { [weak self] in
|
|
try? await self?.messageRouter?.sendFavoriteNotification(to: noisePublicKey, isFavorite: true)
|
|
}
|
|
|
|
let systemMessage = BitchatMessage(
|
|
sender: "system",
|
|
content: "added \(nickname) to favorites.",
|
|
timestamp: Date(),
|
|
isRelay: false
|
|
)
|
|
messages.append(systemMessage)
|
|
}
|
|
} else {
|
|
let systemMessage = BitchatMessage(
|
|
sender: "system",
|
|
content: "can't find peer: \(nickname)",
|
|
timestamp: Date(),
|
|
isRelay: false
|
|
)
|
|
messages.append(systemMessage)
|
|
}
|
|
} else {
|
|
let systemMessage = BitchatMessage(
|
|
sender: "system",
|
|
content: "usage: /fav <nickname>",
|
|
timestamp: Date(),
|
|
isRelay: false
|
|
)
|
|
messages.append(systemMessage)
|
|
}
|
|
|
|
case "/unfav":
|
|
if parts.count > 1 {
|
|
let targetName = String(parts[1])
|
|
// Remove @ if present
|
|
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
|
|
|
// Find peer ID for this nickname
|
|
if let peerID = getPeerIDForNickname(nickname) {
|
|
// Remove from favorites
|
|
if let noisePublicKey = Data(hexString: peerID) {
|
|
FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noisePublicKey)
|
|
|
|
// Toggle favorite in identity manager for UI
|
|
toggleFavorite(peerID: peerID)
|
|
|
|
// Send unfavorite notification
|
|
Task { [weak self] in
|
|
try? await self?.messageRouter?.sendFavoriteNotification(to: noisePublicKey, isFavorite: false)
|
|
}
|
|
|
|
let systemMessage = BitchatMessage(
|
|
sender: "system",
|
|
content: "removed \(nickname) from favorites.",
|
|
timestamp: Date(),
|
|
isRelay: false
|
|
)
|
|
messages.append(systemMessage)
|
|
}
|
|
} else {
|
|
let systemMessage = BitchatMessage(
|
|
sender: "system",
|
|
content: "can't find peer: \(nickname)",
|
|
timestamp: Date(),
|
|
isRelay: false
|
|
)
|
|
messages.append(systemMessage)
|
|
}
|
|
} else {
|
|
let systemMessage = BitchatMessage(
|
|
sender: "system",
|
|
content: "usage: /unfav <nickname>",
|
|
timestamp: Date(),
|
|
isRelay: false
|
|
)
|
|
messages.append(systemMessage)
|
|
}
|
|
|
|
case "/testnostr":
|
|
let systemMessage = BitchatMessage(
|
|
sender: "system",
|
|
content: "testing nostr relay connectivity...",
|
|
timestamp: Date(),
|
|
isRelay: false
|
|
)
|
|
messages.append(systemMessage)
|
|
|
|
Task { @MainActor in
|
|
if let relayManager = self.nostrRelayManager {
|
|
// Simple connectivity test
|
|
relayManager.connect()
|
|
|
|
// Wait a moment for connections
|
|
try? await Task.sleep(nanoseconds: 2_000_000_000) // 2 seconds
|
|
|
|
let statusMessage = if relayManager.isConnected {
|
|
"nostr relays connected successfully!"
|
|
} else {
|
|
"failed to connect to nostr relays - check console for details"
|
|
}
|
|
|
|
let completeMessage = BitchatMessage(
|
|
sender: "system",
|
|
content: statusMessage,
|
|
timestamp: Date(),
|
|
isRelay: false
|
|
)
|
|
self.messages.append(completeMessage)
|
|
} else {
|
|
let errorMessage = BitchatMessage(
|
|
sender: "system",
|
|
content: "nostr relay manager not initialized",
|
|
timestamp: Date(),
|
|
isRelay: false
|
|
)
|
|
self.messages.append(errorMessage)
|
|
}
|
|
}
|
|
|
|
default:
|
|
// Unknown command
|
|
let systemMessage = BitchatMessage(
|
|
sender: "system",
|
|
content: "unknown command: \(cmd).",
|
|
timestamp: Date(),
|
|
isRelay: false
|
|
)
|
|
messages.append(systemMessage)
|
|
}
|
|
}
|
|
|
|
// MARK: - Message Reception
|
|
|
|
func handleHandshakeRequest(from peerID: String, nickname: String, pendingCount: UInt8) {
|
|
// Create a notification message
|
|
let notificationMessage = BitchatMessage(
|
|
sender: "system",
|
|
content: "📨 \(nickname) wants to send you \(pendingCount) message\(pendingCount == 1 ? "" : "s"). Open the conversation to receive.",
|
|
timestamp: Date(),
|
|
isRelay: false,
|
|
originalSender: nil,
|
|
isPrivate: false,
|
|
recipientNickname: nil,
|
|
senderPeerID: "system",
|
|
mentions: nil
|
|
)
|
|
|
|
// Add to messages
|
|
messages.append(notificationMessage)
|
|
trimMessagesIfNeeded()
|
|
|
|
// Show system notification
|
|
if let fingerprint = getFingerprint(for: peerID) {
|
|
let isFavorite = favoritePeers.contains(fingerprint)
|
|
if isFavorite {
|
|
// Send favorite notification
|
|
NotificationService.shared.sendPrivateMessageNotification(
|
|
from: nickname,
|
|
message: "\(pendingCount) message\(pendingCount == 1 ? "" : "s") pending",
|
|
peerID: peerID
|
|
)
|
|
} else {
|
|
// Send regular notification
|
|
NotificationService.shared.sendMentionNotification(
|
|
from: nickname,
|
|
message: "\(pendingCount) message\(pendingCount == 1 ? "" : "s") pending. Open conversation to receive."
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
func didReceiveMessage(_ message: BitchatMessage) {
|
|
|
|
|
|
// Check if sender is blocked (for both private and public messages)
|
|
if let senderPeerID = message.senderPeerID {
|
|
if isPeerBlocked(senderPeerID) {
|
|
// Silently ignore messages from blocked users
|
|
return
|
|
}
|
|
} else if let peerID = getPeerIDForNickname(message.sender) {
|
|
if isPeerBlocked(peerID) {
|
|
// Silently ignore messages from blocked users
|
|
return
|
|
}
|
|
}
|
|
|
|
if message.isPrivate {
|
|
// Handle private message
|
|
|
|
// Use the senderPeerID from the message if available
|
|
let senderPeerID = message.senderPeerID ?? getPeerIDForNickname(message.sender)
|
|
|
|
if let peerID = senderPeerID {
|
|
// Message from someone else
|
|
|
|
// First check if we need to migrate existing messages from this sender
|
|
let senderNickname = message.sender
|
|
let currentFingerprint = getFingerprint(for: peerID)
|
|
|
|
if privateChats[peerID] == nil || privateChats[peerID]?.isEmpty == true {
|
|
// Check if we have messages under a different peer ID with same fingerprint
|
|
var migratedMessages: [BitchatMessage] = []
|
|
var oldPeerIDsToRemove: [String] = []
|
|
|
|
for (oldPeerID, messages) in privateChats {
|
|
if oldPeerID != peerID {
|
|
// Check fingerprint match first (most reliable)
|
|
let oldFingerprint = peerIDToPublicKeyFingerprint[oldPeerID]
|
|
|
|
if let currentFp = currentFingerprint,
|
|
let oldFp = oldFingerprint,
|
|
currentFp == oldFp {
|
|
// Same peer with different ID - migrate all messages
|
|
migratedMessages.append(contentsOf: messages)
|
|
oldPeerIDsToRemove.append(oldPeerID)
|
|
|
|
SecureLogger.log("📦 Migrating \(messages.count) messages from old peer ID \(oldPeerID) to \(peerID) for incoming message (fingerprint match)",
|
|
category: SecureLogger.session, level: .info)
|
|
} else if currentFingerprint == nil || oldFingerprint == nil {
|
|
// Fallback: Check if this chat contains messages with this sender by nickname
|
|
let isRelevantChat = messages.contains { msg in
|
|
(msg.sender == senderNickname && msg.sender != nickname) ||
|
|
(msg.sender == nickname && msg.recipientNickname == senderNickname)
|
|
}
|
|
|
|
if isRelevantChat {
|
|
migratedMessages.append(contentsOf: messages)
|
|
oldPeerIDsToRemove.append(oldPeerID)
|
|
|
|
SecureLogger.log("📦 Migrating \(messages.count) messages from old peer ID \(oldPeerID) to \(peerID) for incoming message (nickname match)",
|
|
category: SecureLogger.session, level: .warning)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Remove old peer ID entries
|
|
for oldPeerID in oldPeerIDsToRemove {
|
|
privateChats.removeValue(forKey: oldPeerID)
|
|
unreadPrivateMessages.remove(oldPeerID)
|
|
}
|
|
|
|
// Initialize with migrated messages
|
|
privateChats[peerID] = migratedMessages
|
|
trimPrivateChatMessagesIfNeeded(for: peerID)
|
|
}
|
|
|
|
if privateChats[peerID] == nil {
|
|
privateChats[peerID] = []
|
|
}
|
|
|
|
// Fix delivery status for incoming messages
|
|
var messageToStore = message
|
|
if message.sender != nickname {
|
|
// This is an incoming message - it should NOT have "sending" status
|
|
if messageToStore.deliveryStatus == nil || messageToStore.deliveryStatus == .sending {
|
|
// Mark it as delivered since we received it
|
|
messageToStore.deliveryStatus = .delivered(to: nickname, at: Date())
|
|
}
|
|
}
|
|
|
|
// Check if this is an action that should be converted to system message
|
|
let isActionMessage = messageToStore.content.hasPrefix("* ") && messageToStore.content.hasSuffix(" *") &&
|
|
(messageToStore.content.contains("🫂") || messageToStore.content.contains("🐟") ||
|
|
messageToStore.content.contains("took a screenshot"))
|
|
|
|
if isActionMessage {
|
|
// Convert to system message
|
|
messageToStore = BitchatMessage(
|
|
id: messageToStore.id,
|
|
sender: "system",
|
|
content: String(messageToStore.content.dropFirst(2).dropLast(2)), // Remove * * wrapper
|
|
timestamp: messageToStore.timestamp,
|
|
isRelay: messageToStore.isRelay,
|
|
originalSender: messageToStore.originalSender,
|
|
isPrivate: messageToStore.isPrivate,
|
|
recipientNickname: messageToStore.recipientNickname,
|
|
senderPeerID: messageToStore.senderPeerID,
|
|
mentions: messageToStore.mentions,
|
|
deliveryStatus: messageToStore.deliveryStatus
|
|
)
|
|
}
|
|
|
|
// Use batching for private messages
|
|
addPrivateMessageToBatch(messageToStore, for: peerID)
|
|
|
|
// Debug logging
|
|
|
|
// Check if we're in a private chat with this peer's fingerprint
|
|
// This handles reconnections with new peer IDs
|
|
if let chatFingerprint = selectedPrivateChatFingerprint,
|
|
let senderFingerprint = peerIDToPublicKeyFingerprint[peerID],
|
|
chatFingerprint == senderFingerprint && selectedPrivateChatPeer != peerID {
|
|
// Update our private chat peer to the new ID
|
|
selectedPrivateChatPeer = peerID
|
|
// Schedule UI update when peer ID changes
|
|
scheduleUIUpdate()
|
|
}
|
|
|
|
// Also check if we need to update the private chat peer for any reason
|
|
updatePrivateChatPeerIfNeeded()
|
|
|
|
// If we're currently viewing this chat, force immediate UI update
|
|
if selectedPrivateChatPeer == peerID {
|
|
// Immediately flush the batch for the current chat to ensure UI updates
|
|
DispatchQueue.main.async { [weak self] in
|
|
guard let self = self else { return }
|
|
if let pendingMessages = self.pendingPrivateMessages[peerID], !pendingMessages.isEmpty {
|
|
// Process this peer's messages immediately
|
|
self.pendingPrivateMessages.removeValue(forKey: peerID)
|
|
|
|
if self.privateChats[peerID] == nil {
|
|
self.privateChats[peerID] = []
|
|
}
|
|
|
|
// Add messages and sort
|
|
self.privateChats[peerID]?.append(contentsOf: pendingMessages)
|
|
self.privateChats[peerID]?.sort { $0.timestamp < $1.timestamp }
|
|
|
|
// Remove duplicates
|
|
var seen = Set<String>()
|
|
self.privateChats[peerID] = self.privateChats[peerID]?.filter { msg in
|
|
if seen.contains(msg.id) {
|
|
return false
|
|
}
|
|
seen.insert(msg.id)
|
|
return true
|
|
}
|
|
|
|
// Trim if needed
|
|
self.trimPrivateChatMessagesIfNeeded(for: peerID)
|
|
|
|
// Schedule UI update
|
|
self.scheduleUIUpdate()
|
|
}
|
|
}
|
|
}
|
|
|
|
// Mark as unread if not currently viewing this chat
|
|
if selectedPrivateChatPeer != peerID {
|
|
unreadPrivateMessages.insert(peerID)
|
|
|
|
} else {
|
|
// We're viewing this chat, make sure unread is cleared
|
|
unreadPrivateMessages.remove(peerID)
|
|
|
|
// Send read receipt immediately since we're viewing the chat
|
|
if !sentReadReceipts.contains(message.id) {
|
|
let receipt = ReadReceipt(
|
|
originalMessageID: message.id,
|
|
readerID: meshService.myPeerID,
|
|
readerNickname: nickname
|
|
)
|
|
// Use the message's senderPeerID if available (for Nostr messages)
|
|
let recipientID = message.senderPeerID ?? peerID
|
|
|
|
// For backwards compatibility, check if this might be a Nostr message
|
|
// by checking if the sender has a Nostr key
|
|
let receiptToSend = receipt
|
|
let recipientToSend = recipientID
|
|
Task { @MainActor in
|
|
var originalTransport: String? = nil
|
|
if let noiseKey = Data(hexString: recipientToSend),
|
|
let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
|
|
favoriteStatus.peerNostrPublicKey != nil,
|
|
self.meshService.getPeerNicknames()[recipientToSend] == nil {
|
|
// Peer has Nostr key and is not on mesh, likely received via Nostr
|
|
originalTransport = "nostr"
|
|
}
|
|
|
|
self.sendReadReceipt(receiptToSend, to: recipientToSend, originalTransport: originalTransport)
|
|
}
|
|
sentReadReceipts.insert(message.id)
|
|
}
|
|
|
|
// Also check if there are other unread messages from this peer
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
|
|
self?.markPrivateMessagesAsRead(from: peerID)
|
|
}
|
|
}
|
|
} else if message.sender == nickname {
|
|
// Our own message that was echoed back - ignore it since we already added it locally
|
|
}
|
|
} else {
|
|
// Regular public message (main chat)
|
|
|
|
// Check if this is an action that should be converted to system message
|
|
let isActionMessage = message.content.hasPrefix("* ") && message.content.hasSuffix(" *") &&
|
|
(message.content.contains("🫂") || message.content.contains("🐟") ||
|
|
message.content.contains("took a screenshot"))
|
|
|
|
let finalMessage: BitchatMessage
|
|
if isActionMessage {
|
|
// Convert to system message
|
|
finalMessage = BitchatMessage(
|
|
sender: "system",
|
|
content: String(message.content.dropFirst(2).dropLast(2)), // Remove * * wrapper
|
|
timestamp: message.timestamp,
|
|
isRelay: message.isRelay,
|
|
originalSender: message.originalSender,
|
|
isPrivate: false,
|
|
recipientNickname: message.recipientNickname,
|
|
senderPeerID: message.senderPeerID,
|
|
mentions: message.mentions
|
|
)
|
|
} else {
|
|
finalMessage = message
|
|
}
|
|
|
|
// Check if this is our own message being echoed back
|
|
if finalMessage.sender != nickname && finalMessage.sender != "system" {
|
|
// Skip empty or whitespace-only messages
|
|
if !finalMessage.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
addMessageToBatch(finalMessage)
|
|
}
|
|
} else if finalMessage.sender != "system" {
|
|
// Our own message - check if we already have it (by ID and content)
|
|
let messageExists = messages.contains { existingMsg in
|
|
// Check by ID first
|
|
if existingMsg.id == finalMessage.id {
|
|
return true
|
|
}
|
|
// Check by content and sender with time window (within 1 second)
|
|
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 {
|
|
// This is a message we sent from another device or it's missing locally
|
|
// Skip empty or whitespace-only messages
|
|
if !finalMessage.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
addMessageToBatch(finalMessage)
|
|
}
|
|
}
|
|
} else {
|
|
// System message - check for empty content before adding
|
|
if !finalMessage.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
addMessageToBatch(finalMessage)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check if we're mentioned
|
|
let isMentioned = message.mentions?.contains(nickname) ?? false
|
|
|
|
// Send notifications for mentions and private messages when app is in background
|
|
if isMentioned && message.sender != nickname {
|
|
NotificationService.shared.sendMentionNotification(from: message.sender, message: message.content)
|
|
} else if message.isPrivate && message.sender != nickname {
|
|
// Only send notification if the private chat is not currently open
|
|
if selectedPrivateChatPeer != message.senderPeerID {
|
|
NotificationService.shared.sendPrivateMessageNotification(from: message.sender, message: message.content, peerID: message.senderPeerID ?? "")
|
|
}
|
|
}
|
|
|
|
#if os(iOS)
|
|
// Haptic feedback for iOS only
|
|
guard UIApplication.shared.applicationState == .active else {
|
|
return
|
|
}
|
|
// Check if this is a hug message directed at the user
|
|
let isHugForMe = message.content.contains("🫂") &&
|
|
(message.content.contains("hugs \(nickname)") ||
|
|
message.content.contains("hugs you"))
|
|
|
|
// Check if this is a slap message directed at the user
|
|
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 - continuous gentle vibration
|
|
let impactFeedback = UIImpactFeedbackGenerator(style: .medium)
|
|
impactFeedback.prepare()
|
|
|
|
// Create a warm, sustained haptic pattern
|
|
for i in 0..<8 {
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + Double(i) * 0.15) {
|
|
impactFeedback.impactOccurred()
|
|
}
|
|
}
|
|
|
|
// Add a final stronger pulse
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) {
|
|
let strongFeedback = UIImpactFeedbackGenerator(style: .heavy)
|
|
strongFeedback.prepare()
|
|
strongFeedback.impactOccurred()
|
|
}
|
|
} else if isSlapForMe && message.sender != nickname {
|
|
// Very harsh, fast, strong haptic for slaps - multiple sharp impacts
|
|
let impactFeedback = UIImpactFeedbackGenerator(style: .heavy)
|
|
impactFeedback.prepare()
|
|
|
|
// Rapid-fire heavy impacts to simulate a hard slap
|
|
impactFeedback.impactOccurred()
|
|
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.03) {
|
|
impactFeedback.impactOccurred()
|
|
}
|
|
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.06) {
|
|
impactFeedback.impactOccurred()
|
|
}
|
|
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.09) {
|
|
impactFeedback.impactOccurred()
|
|
}
|
|
|
|
// Final extra heavy impact
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
|
|
let finalImpact = UIImpactFeedbackGenerator(style: .heavy)
|
|
finalImpact.prepare()
|
|
finalImpact.impactOccurred()
|
|
}
|
|
} else if isMentioned && message.sender != nickname {
|
|
// Very prominent haptic for @mentions - triple tap with heavy impact
|
|
let impactFeedback = UIImpactFeedbackGenerator(style: .heavy)
|
|
impactFeedback.prepare()
|
|
impactFeedback.impactOccurred()
|
|
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
|
impactFeedback.impactOccurred()
|
|
}
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
|
|
impactFeedback.impactOccurred()
|
|
}
|
|
} else if message.isPrivate && message.sender != nickname {
|
|
// Heavy haptic for private messages - more pronounced
|
|
let impactFeedback = UIImpactFeedbackGenerator(style: .heavy)
|
|
impactFeedback.prepare()
|
|
impactFeedback.impactOccurred()
|
|
|
|
// Double tap for extra emphasis
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
|
impactFeedback.impactOccurred()
|
|
}
|
|
} else if message.sender != nickname {
|
|
// Light haptic for public messages from others
|
|
let impactFeedback = UIImpactFeedbackGenerator(style: .light)
|
|
impactFeedback.impactOccurred()
|
|
}
|
|
#endif
|
|
}
|
|
|
|
// MARK: - Peer Connection Events
|
|
|
|
func didConnectToPeer(_ peerID: String) {
|
|
isConnected = true
|
|
|
|
// Register ephemeral session with identity manager
|
|
SecureIdentityStateManager.shared.registerEphemeralSession(peerID: peerID)
|
|
|
|
// Resolve nickname using helper
|
|
let displayName = resolveNickname(for: peerID)
|
|
|
|
// Ensure we have a valid display name
|
|
let finalDisplayName = displayName.isEmpty ? "peer" : displayName
|
|
|
|
let systemMessage = BitchatMessage(
|
|
sender: "system",
|
|
content: "\(finalDisplayName) connected",
|
|
timestamp: Date(),
|
|
isRelay: false,
|
|
originalSender: nil
|
|
)
|
|
// Batch system messages
|
|
addMessageToBatch(systemMessage)
|
|
}
|
|
|
|
func didDisconnectFromPeer(_ peerID: String) {
|
|
// Remove ephemeral session from identity manager
|
|
SecureIdentityStateManager.shared.removeEphemeralSession(peerID: peerID)
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Resolve nickname using helper
|
|
let displayName = resolveNickname(for: peerID)
|
|
|
|
// Ensure we have a valid display name
|
|
let finalDisplayName = displayName.isEmpty ? "peer" : displayName
|
|
|
|
let systemMessage = BitchatMessage(
|
|
sender: "system",
|
|
content: "\(finalDisplayName) disconnected",
|
|
timestamp: Date(),
|
|
isRelay: false,
|
|
originalSender: nil
|
|
)
|
|
// Batch system messages
|
|
addMessageToBatch(systemMessage)
|
|
}
|
|
|
|
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 {
|
|
self.connectedPeers = peers
|
|
self.isConnected = !peers.isEmpty
|
|
|
|
self.peerManager?.updatePeers()
|
|
|
|
// 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
|
|
self.scheduleUIUpdate()
|
|
|
|
// Update encryption status for all peers
|
|
self.updateEncryptionStatusForPeers()
|
|
|
|
// Schedule UI update for peer list change
|
|
self.scheduleUIUpdate()
|
|
|
|
// 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
|
|
|
|
private func parseMentions(from content: String) -> [String] {
|
|
let pattern = "@([a-zA-Z0-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
|
|
peerManager?.updatePeers()
|
|
}
|
|
|
|
func isFavorite(fingerprint: String) -> Bool {
|
|
return SecureIdentityStateManager.shared.isFavorite(fingerprint: fingerprint)
|
|
}
|
|
|
|
// MARK: - Delivery Tracking
|
|
|
|
func didReceiveDeliveryAck(_ ack: DeliveryAck) {
|
|
// Find the message and update its delivery status
|
|
updateMessageDeliveryStatus(ack.originalMessageID, status: .delivered(to: ack.recipientNickname, at: ack.timestamp))
|
|
}
|
|
|
|
func didReceiveReadReceipt(_ receipt: ReadReceipt) {
|
|
// Find the message and update its read status
|
|
updateMessageDeliveryStatus(receipt.originalMessageID, status: .read(by: receipt.readerNickname, at: receipt.timestamp))
|
|
|
|
// Clear delivery tracking since the message has been read
|
|
// This prevents the timeout from marking it as failed
|
|
DeliveryTracker.shared.clearDeliveryStatus(for: receipt.originalMessageID)
|
|
}
|
|
|
|
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
|
|
var updatedPrivateChats = privateChats
|
|
for (peerID, chatMessages) in updatedPrivateChats {
|
|
if let index = chatMessages.firstIndex(where: { $0.id == messageID }) {
|
|
let currentStatus = chatMessages[index].deliveryStatus
|
|
if !shouldSkipUpdate(currentStatus: currentStatus, newStatus: status) {
|
|
chatMessages[index].deliveryStatus = status
|
|
updatedPrivateChats[peerID] = chatMessages
|
|
}
|
|
}
|
|
}
|
|
|
|
// Force complete reassignment to trigger SwiftUI update
|
|
DispatchQueue.main.async { [weak self] in
|
|
guard let self = self else { return }
|
|
self.privateChats = updatedPrivateChats
|
|
self.scheduleUIUpdate()
|
|
}
|
|
|
|
}
|
|
|
|
} // End of ChatViewModel class
|