diff --git a/bitchat/BitchatApp.swift b/bitchat/BitchatApp.swift index c2694842..e611a2c1 100644 --- a/bitchat/BitchatApp.swift +++ b/bitchat/BitchatApp.swift @@ -98,14 +98,12 @@ struct BitchatApp: App { // Try to parse as JSON first if let data = sharedContent.data(using: .utf8), let urlData = try? JSONSerialization.jsonObject(with: data) as? [String: String], - let url = urlData["url"], - let title = urlData["title"] { - // Send just emoji with hidden markdown link - let markdownLink = "👇 [\(title)](\(url))" - self.chatViewModel.sendMessage(markdownLink) + let url = urlData["url"] { + // Send plain URL + self.chatViewModel.sendMessage(url) } else { // Fallback to simple URL - self.chatViewModel.sendMessage("Shared link: \(sharedContent)") + self.chatViewModel.sendMessage(sharedContent) } } else { self.chatViewModel.sendMessage(sharedContent) @@ -147,17 +145,14 @@ class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate { func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { let identifier = response.notification.request.identifier + let userInfo = response.notification.request.content.userInfo // Check if this is a private message notification if identifier.hasPrefix("private-") { - // Extract sender from notification title - let title = response.notification.request.content.title - if let senderName = title.replacingOccurrences(of: "Private message from ", with: "").nilIfEmpty { - // Find peer ID and open chat - if let peerID = chatViewModel?.getPeerIDForNickname(senderName) { - DispatchQueue.main.async { - self.chatViewModel?.startPrivateChat(with: peerID) - } + // Get peer ID from userInfo + if let peerID = userInfo["peerID"] as? String { + DispatchQueue.main.async { + self.chatViewModel?.startPrivateChat(with: peerID) } } } @@ -166,7 +161,22 @@ class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate { } func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { - // Show notification even when app is in foreground (for testing) + let identifier = notification.request.identifier + let userInfo = notification.request.content.userInfo + + // Check if this is a private message notification + if identifier.hasPrefix("private-") { + // Get peer ID from userInfo + if let peerID = userInfo["peerID"] as? String { + // Don't show notification if the private chat is already open + if chatViewModel?.selectedPrivateChatPeer == peerID { + completionHandler([]) + return + } + } + } + + // Show notification in all other cases completionHandler([.banner, .sound]) } } diff --git a/bitchat/Protocols/BitchatProtocol.swift b/bitchat/Protocols/BitchatProtocol.swift index 3c213928..2b902bb9 100644 --- a/bitchat/Protocols/BitchatProtocol.swift +++ b/bitchat/Protocols/BitchatProtocol.swift @@ -929,7 +929,7 @@ enum DeliveryStatus: Codable, Equatable { } } -struct BitchatMessage: Codable, Equatable { +class BitchatMessage: Codable { let id: String let sender: String let content: String @@ -942,6 +942,23 @@ struct BitchatMessage: Codable, Equatable { let mentions: [String]? // Array of mentioned nicknames var deliveryStatus: DeliveryStatus? // Delivery tracking + // Cached formatted text (not included in Codable) + private var _cachedFormattedText: [String: AttributedString] = [:] + + func getCachedFormattedText(isDark: Bool) -> AttributedString? { + return _cachedFormattedText["\(isDark)"] + } + + func setCachedFormattedText(_ text: AttributedString, isDark: Bool) { + _cachedFormattedText["\(isDark)"] = text + } + + // Codable implementation + enum CodingKeys: String, CodingKey { + case id, sender, content, timestamp, isRelay, originalSender + case isPrivate, recipientNickname, senderPeerID, mentions, deliveryStatus + } + init(id: String? = nil, sender: String, content: String, timestamp: Date, isRelay: Bool, originalSender: String? = nil, isPrivate: Bool = false, recipientNickname: String? = nil, senderPeerID: String? = nil, mentions: [String]? = nil, deliveryStatus: DeliveryStatus? = nil) { self.id = id ?? UUID().uuidString self.sender = sender @@ -957,6 +974,23 @@ struct BitchatMessage: Codable, Equatable { } } +// Equatable conformance for BitchatMessage +extension BitchatMessage: Equatable { + static func == (lhs: BitchatMessage, rhs: BitchatMessage) -> Bool { + return lhs.id == rhs.id && + lhs.sender == rhs.sender && + lhs.content == rhs.content && + lhs.timestamp == rhs.timestamp && + lhs.isRelay == rhs.isRelay && + lhs.originalSender == rhs.originalSender && + lhs.isPrivate == rhs.isPrivate && + lhs.recipientNickname == rhs.recipientNickname && + lhs.senderPeerID == rhs.senderPeerID && + lhs.mentions == rhs.mentions && + lhs.deliveryStatus == rhs.deliveryStatus + } +} + protocol BitchatDelegate: AnyObject { func didReceiveMessage(_ message: BitchatMessage) func didConnectToPeer(_ peerID: String) diff --git a/bitchat/Services/NotificationService.swift b/bitchat/Services/NotificationService.swift index 4d2cbbfc..dfde4d48 100644 --- a/bitchat/Services/NotificationService.swift +++ b/bitchat/Services/NotificationService.swift @@ -29,7 +29,7 @@ class NotificationService { } } - func sendLocalNotification(title: String, body: String, identifier: String) { + func sendLocalNotification(title: String, body: String, identifier: String, userInfo: [String: Any]? = nil) { // For now, skip app state check entirely to avoid thread issues // The NotificationDelegate will handle foreground presentation DispatchQueue.main.async { @@ -37,6 +37,9 @@ class NotificationService { content.title = title content.body = body content.sound = .default + if let userInfo = userInfo { + content.userInfo = userInfo + } let request = UNNotificationRequest( identifier: identifier, @@ -58,12 +61,13 @@ class NotificationService { sendLocalNotification(title: title, body: body, identifier: identifier) } - func sendPrivateMessageNotification(from sender: String, message: String) { + func sendPrivateMessageNotification(from sender: String, message: String, peerID: String) { let title = "🔒 private message from \(sender)" let body = message let identifier = "private-\(UUID().uuidString)" + let userInfo = ["peerID": peerID, "senderName": sender] - sendLocalNotification(title: title, body: body, identifier: identifier) + sendLocalNotification(title: title, body: body, identifier: identifier, userInfo: userInfo) } func sendFavoriteOnlineNotification(nickname: String) { diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 0afbc21c..5da2a287 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -17,15 +17,15 @@ import UIKit class ChatViewModel: ObservableObject { @Published var messages: [BitchatMessage] = [] + private let maxMessages = 1337 // Maximum messages before oldest are removed @Published var connectedPeers: [String] = [] - @Published var nickname: String = "" { - didSet { - nicknameSaveTimer?.invalidate() - nicknameSaveTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { _ in - self.saveNickname() - } - } - } + + // 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 + @Published var nickname: String = "" @Published var isConnected = false @Published var privateChats: [String: [BitchatMessage]] = [:] // peerID -> messages @Published var selectedPrivateChatPeer: String? = nil @@ -36,13 +36,21 @@ class ChatViewModel: ObservableObject { @Published var autocompleteRange: NSRange? = nil @Published var selectedAutocompleteIndex: Int = 0 + // 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 var meshService = BluetoothMeshService() private let userDefaults = UserDefaults.standard private let nicknameKey = "bitchat.nickname" - private var nicknameSaveTimer: Timer? + + // Caches for expensive computations + private var rssiColorCache: [String: Color] = [:] // key: "\(rssi)_\(isDark)" + private var encryptionStatusCache: [String: EncryptionStatus] = [:] // key: peerID @Published var favoritePeers: Set = [] // Now stores public key fingerprints instead of peer IDs private var peerIDToPublicKeyFingerprint: [String: String] = [:] // Maps ephemeral peer IDs to persistent fingerprints @@ -151,6 +159,9 @@ class ChatViewModel: ObservableObject { } deinit { + // Clean up timer + messageBatchTimer?.invalidate() + // Force immediate save userDefaults.synchronize() } @@ -172,6 +183,14 @@ class ChatViewModel: ObservableObject { meshService.sendBroadcastAnnounce() } + func validateAndSaveNickname() { + // Check if nickname is empty or just whitespace + if nickname.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + nickname = "anon\(Int.random(in: 1000...9999))" + } + saveNickname() + } + private func loadFavorites() { // Load favorites from secure storage favoritePeers = SecureIdentityStateManager.shared.getFavorites() @@ -317,6 +336,7 @@ class ChatViewModel: ObservableObject { privateChats[currentPeerID] = [] } privateChats[currentPeerID]?.append(contentsOf: oldMessages) + trimPrivateChatMessagesIfNeeded(for: currentPeerID) privateChats.removeValue(forKey: oldPeerID) } @@ -372,8 +392,12 @@ class ChatViewModel: ObservableObject { mentions: mentions.isEmpty ? nil : mentions, ) - // Add to main messages + // 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) @@ -420,12 +444,13 @@ class ChatViewModel: ObservableObject { 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) - // Trigger UI update + // Immediate UI update for user's own messages objectWillChange.send() // Send via mesh with the same message ID @@ -501,6 +526,7 @@ class ChatViewModel: ObservableObject { // 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] = [] } @@ -561,6 +587,7 @@ class ChatViewModel: ObservableObject { privateChats[peerID] = [] } privateChats[peerID]?.append(localNotification) + trimPrivateChatMessagesIfNeeded(for: peerID) } else { // In public chat - send to everyone @@ -573,15 +600,21 @@ class ChatViewModel: ObservableObject { timestamp: Date(), isRelay: false ) - messages.append(localNotification) + // System messages can be batched + addMessageToBatch(localNotification) } } @objc private func appWillResignActive() { + // Flush any pending messages when app goes to background + flushMessageBatchImmediately() + userDefaults.synchronize() } @objc func applicationWillTerminate() { + // Flush any pending messages immediately + flushMessageBatchImmediately() // Verify identity key is still there _ = KeychainManager.shared.verifyIdentityKeyExists() @@ -593,6 +626,9 @@ class ChatViewModel: ObservableObject { } @objc private func appWillTerminate() { + // Flush any pending messages immediately + flushMessageBatchImmediately() + userDefaults.synchronize() } @@ -696,6 +732,9 @@ class ChatViewModel: ObservableObject { // PANIC: Emergency data clearing for activist safety func panicClearAllData() { + // Flush any pending messages immediately before clearing + flushMessageBatchImmediately() + // Clear all messages messages.removeAll() privateChats.removeAll() @@ -743,6 +782,10 @@ class ChatViewModel: ObservableObject { // Clear read receipt tracking sentReadReceipts.removeAll() + // Clear all caches + invalidateEncryptionCache() + invalidateRSSIColorCache() + // Disconnect from all peers and clear persistent identity // This will force creation of a new identity (new fingerprint) on next launch meshService.emergencyDisconnectAll() @@ -765,71 +808,110 @@ class ChatViewModel: ObservableObject { func getRSSIColor(rssi: Int, colorScheme: ColorScheme) -> Color { let isDark = colorScheme == .dark + let cacheKey = "\(rssi)_\(isDark)" + + // Check cache first + if let cachedColor = rssiColorCache[cacheKey] { + return cachedColor + } + // RSSI typically ranges from -30 (excellent) to -90 (poor) // We'll map this to colors from green (strong) to red (weak) + let color: Color if rssi >= -50 { // Excellent signal: bright green - return isDark ? Color(red: 0.0, green: 1.0, blue: 0.0) : Color(red: 0.0, green: 0.7, blue: 0.0) + color = isDark ? Color(red: 0.0, green: 1.0, blue: 0.0) : Color(red: 0.0, green: 0.7, blue: 0.0) } else if rssi >= -60 { // Good signal: green-yellow - return isDark ? Color(red: 0.5, green: 1.0, blue: 0.0) : Color(red: 0.3, green: 0.7, blue: 0.0) + color = isDark ? Color(red: 0.5, green: 1.0, blue: 0.0) : Color(red: 0.3, green: 0.7, blue: 0.0) } else if rssi >= -70 { // Fair signal: yellow - return isDark ? Color(red: 1.0, green: 1.0, blue: 0.0) : Color(red: 0.7, green: 0.7, blue: 0.0) + color = isDark ? Color(red: 1.0, green: 1.0, blue: 0.0) : Color(red: 0.7, green: 0.7, blue: 0.0) } else if rssi >= -80 { // Weak signal: orange - return isDark ? Color(red: 1.0, green: 0.6, blue: 0.0) : Color(red: 0.8, green: 0.4, blue: 0.0) + color = isDark ? Color(red: 1.0, green: 0.6, blue: 0.0) : Color(red: 0.8, green: 0.4, blue: 0.0) } else { // Poor signal: red - return isDark ? Color(red: 1.0, green: 0.2, blue: 0.2) : Color(red: 0.8, green: 0.0, blue: 0.0) + color = isDark ? Color(red: 1.0, green: 0.2, blue: 0.2) : Color(red: 0.8, green: 0.0, blue: 0.0) } + + // Cache the result + rssiColorCache[cacheKey] = color + return color } 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)) - // Look for @ pattern - let pattern = "@([a-zA-Z0-9_]*)$" - guard let regex = try? NSRegularExpression(pattern: pattern, options: []), + // Use cached regex + guard let regex = mentionRegex, let match = regex.firstMatch(in: beforeCursor, options: [], range: NSRange(location: 0, length: beforeCursor.count)) else { - showAutocomplete = false - autocompleteSuggestions = [] - autocompleteRange = nil + 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 { - showAutocomplete = false - autocompleteSuggestions = [] - autocompleteRange = nil + if showAutocomplete { + showAutocomplete = false + autocompleteSuggestions = [] + autocompleteRange = nil + } return } let partial = String(beforeCursor[range]).lowercased() - // Get all available nicknames (excluding self) - let peerNicknames = meshService.getPeerNicknames() - let allNicknames = Array(peerNicknames.values) + // 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 - let suggestions = allNicknames.filter { nick in + // Filter suggestions using cached nicknames + let suggestions = cachedNicknames.filter { nick in nick.lowercased().hasPrefix(partial) - }.sorted() + } + // Batch UI updates if !suggestions.isEmpty { - autocompleteSuggestions = suggestions - showAutocomplete = true - autocompleteRange = match.range(at: 0) // Store full @mention range + // 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 { - showAutocomplete = false - autocompleteSuggestions = [] - autocompleteRange = nil - selectedAutocompleteIndex = 0 + if showAutocomplete { + showAutocomplete = false + autocompleteSuggestions = [] + autocompleteRange = nil + selectedAutocompleteIndex = 0 + } } } @@ -855,16 +937,8 @@ class ChatViewModel: ObservableObject { let isDark = colorScheme == .dark let primaryColor = isDark ? Color.green : Color(red: 0, green: 0.5, blue: 0) - if message.sender == nickname { - return primaryColor - } else if let peerID = message.senderPeerID ?? getPeerIDForNickname(message.sender), - let rssi = meshService.getPeerRSSI()[peerID] { - // Use actual RSSI value - return getRSSIColor(rssi: rssi.intValue, colorScheme: colorScheme) - } else { - // No RSSI data available - use a neutral color - return primaryColor.opacity(0.7) - } + // Always use the same color for all senders - no RSSI-based coloring + return primaryColor } @@ -938,9 +1012,15 @@ class ChatViewModel: ObservableObject { } 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 isDark = colorScheme == .dark let primaryColor = isDark ? Color.green : Color(red: 0, green: 0.5, blue: 0) let secondaryColor = primaryColor.opacity(0.7) @@ -956,50 +1036,15 @@ class ChatViewModel: ObservableObject { let sender = AttributedString("<@\(message.sender)> ") var senderStyle = AttributeContainer() - // Get sender color - let senderColor: Color - if message.sender == nickname { - senderColor = primaryColor - } else if let peerID = message.senderPeerID ?? getPeerIDForNickname(message.sender), - let rssi = meshService.getPeerRSSI()[peerID] { - // Use actual RSSI value - senderColor = getRSSIColor(rssi: rssi.intValue, colorScheme: colorScheme) - } else { - // No RSSI data available - use a neutral color - senderColor = primaryColor.opacity(0.7) - } - - senderStyle.foregroundColor = senderColor - senderStyle.font = .system(size: 14, weight: .medium, design: .monospaced) + // 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, mentions, and markdown links - var content = message.content - - // First, check if content starts with 👇 followed by markdown link - if content.hasPrefix("👇 [") { - // This is a URL share - remove everything after the emoji - if let linkStart = content.firstIndex(of: "[") { - let indexBeforeLink = content.index(before: linkStart) - content = String(content[.. ") var senderStyle = AttributeContainer() - // Get RSSI-based color - let senderColor: Color - if message.sender == nickname { - senderColor = primaryColor - } else if let peerID = message.senderPeerID ?? getPeerIDForNickname(message.sender), - let rssi = meshService.getPeerRSSI()[peerID] { - // Use actual RSSI value - senderColor = getRSSIColor(rssi: rssi.intValue, colorScheme: colorScheme) - } else { - // No RSSI data available - use a neutral color - senderColor = primaryColor.opacity(0.7) - } - - senderStyle.foregroundColor = senderColor - senderStyle.font = .system(size: 12, weight: .medium, design: .monospaced) + // 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)) @@ -1202,6 +1250,9 @@ class ChatViewModel: ObservableObject { peerEncryptionStatus[peerID] = Optional.none } + // Invalidate cache when encryption status changes + invalidateEncryptionCache(for: peerID) + // Force UI update DispatchQueue.main.async { [weak self] in self?.objectWillChange.send() @@ -1209,6 +1260,11 @@ class ChatViewModel: ObservableObject { } 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 @@ -1216,34 +1272,143 @@ class ChatViewModel: ObservableObject { let hasSession = meshService.getNoiseService().hasSession(with: peerID) let storedStatus = peerEncryptionStatus[peerID] + let status: EncryptionStatus + // First check if we have an established session if hasEstablished { // We have encryption, now check if it's verified if let fingerprint = getFingerprint(for: peerID) { if verifiedFingerprints.contains(fingerprint) { - return .noiseVerified + status = .noiseVerified } else { - return .noiseSecured + status = .noiseSecured } + } else { + // We have a session but no fingerprint yet - still secured + status = .noiseSecured } - // We have a session but no fingerprint yet - still secured - return .noiseSecured + } else if hasSession { + // Check if handshaking + status = .noiseHandshaking + } else { + // Fall back to stored status + status = storedStatus ?? .none } - // Check if handshaking - if hasSession { - return .noiseHandshaking - } - - // Fall back to stored status - let finalStatus = storedStatus ?? .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): hasEstablished=\(hasEstablished), hasSession=\(hasSession), stored=\(String(describing: storedStatus)), final=\(finalStatus)", category: SecureLogger.security, level: .debug) + SecureLogger.log("getEncryptionStatus for \(peerID): hasEstablished=\(hasEstablished), hasSession=\(hasSession), stored=\(String(describing: storedStatus)), final=\(status)", category: SecureLogger.security, level: .debug) } - return finalStatus + 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() + } + } + + private func invalidateRSSIColorCache() { + rssiColorCache.removeAll() + } + + // Trim messages to keep only the most recent maxMessages + private func trimMessagesIfNeeded() { + if messages.count > maxMessages { + let removeCount = messages.count - maxMessages + messages.removeFirst(removeCount) + } + } + + // Trim private chat messages to keep only the most recent maxMessages + private func trimPrivateChatMessagesIfNeeded(for peerID: String) { + if let count = privateChats[peerID]?.count, count > maxMessages { + let removeCount = count - maxMessages + privateChats[peerID]?.removeFirst(removeCount) + } + } + + // MARK: - Message Batching + + 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 UI update for all changes + self.objectWillChange.send() + } + } + + // Force immediate flush for high-priority messages + private func flushMessageBatchImmediately() { + messageBatchTimer?.invalidate() + flushMessageBatch() } // Update encryption status in appropriate places, not during view updates @@ -1267,6 +1432,9 @@ class ChatViewModel: ObservableObject { peerEncryptionStatus[peerID] = Optional.none } + // Invalidate cache when encryption status changes + invalidateEncryptionCache(for: peerID) + // Trigger UI update DispatchQueue.main.async { [weak self] in self?.objectWillChange.send() @@ -1386,6 +1554,9 @@ class ChatViewModel: ObservableObject { 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) + // Force UI update self.objectWillChange.send() } @@ -1394,7 +1565,14 @@ class ChatViewModel: ObservableObject { // Set up handshake required callback noiseService.onHandshakeRequired = { [weak self] peerID in DispatchQueue.main.async { - self?.peerEncryptionStatus[peerID] = .noiseHandshaking + guard let self = self else { return } + self.peerEncryptionStatus[peerID] = .noiseHandshaking + + // Invalidate cache when encryption status changes + self.invalidateEncryptionCache(for: peerID) + + // Force UI update + self.objectWillChange.send() } } } @@ -1811,6 +1989,7 @@ extension ChatViewModel: BitchatDelegate { // Initialize with migrated messages privateChats[peerID] = migratedMessages + trimPrivateChatMessagesIfNeeded(for: peerID) } if privateChats[peerID] == nil { @@ -1849,15 +2028,11 @@ extension ChatViewModel: BitchatDelegate { ) } - privateChats[peerID]?.append(messageToStore) - // Sort messages by timestamp to ensure proper ordering - privateChats[peerID]?.sort { $0.timestamp < $1.timestamp } + // Use batching for private messages + addPrivateMessageToBatch(messageToStore, for: peerID) // Debug logging - // Trigger UI update for private chats - objectWillChange.send() - // Check if we're in a private chat with this peer's fingerprint // This handles reconnections with new peer IDs if let chatFingerprint = selectedPrivateChatFingerprint, @@ -1925,9 +2100,7 @@ extension ChatViewModel: BitchatDelegate { if finalMessage.sender != nickname && finalMessage.sender != "system" { // Skip empty or whitespace-only messages if !finalMessage.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - messages.append(finalMessage) - // Sort messages by timestamp to ensure proper ordering - messages.sort { $0.timestamp < $1.timestamp } + addMessageToBatch(finalMessage) } } else if finalMessage.sender != "system" { // Our own message - check if we already have it (by ID and content) @@ -1948,15 +2121,13 @@ extension ChatViewModel: BitchatDelegate { // 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 { - messages.append(finalMessage) - messages.sort { $0.timestamp < $1.timestamp } + addMessageToBatch(finalMessage) } } } else { // System message - check for empty content before adding if !finalMessage.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - messages.append(finalMessage) - messages.sort { $0.timestamp < $1.timestamp } + addMessageToBatch(finalMessage) } } } @@ -1968,7 +2139,10 @@ extension ChatViewModel: BitchatDelegate { if isMentioned && message.sender != nickname { NotificationService.shared.sendMentionNotification(from: message.sender, message: message.content) } else if message.isPrivate && message.sender != nickname { - NotificationService.shared.sendPrivateMessageNotification(from: message.sender, message: message.content) + // 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) @@ -2079,10 +2253,8 @@ extension ChatViewModel: BitchatDelegate { isRelay: false, originalSender: nil ) - messages.append(systemMessage) - - // Force UI update - objectWillChange.send() + // Batch system messages + addMessageToBatch(systemMessage) } func didDisconnectFromPeer(_ peerID: String) { @@ -2113,10 +2285,8 @@ extension ChatViewModel: BitchatDelegate { isRelay: false, originalSender: nil ) - messages.append(systemMessage) - - // Force UI update - objectWillChange.send() + // Batch system messages + addMessageToBatch(systemMessage) } func didUpdatePeerList(_ peers: [String]) { @@ -2133,6 +2303,9 @@ extension ChatViewModel: BitchatDelegate { // Update encryption status for all peers self.updateEncryptionStatusForPeers() + + // Invalidate RSSI cache since peer data may have changed + self.invalidateRSSIColorCache() // Explicitly notify SwiftUI that the object has changed. self.objectWillChange.send() @@ -2220,21 +2393,17 @@ extension ChatViewModel: BitchatDelegate { if let index = messages.firstIndex(where: { $0.id == messageID }) { let currentStatus = messages[index].deliveryStatus if !shouldSkipUpdate(currentStatus: currentStatus, newStatus: status) { - var updatedMessage = messages[index] - updatedMessage.deliveryStatus = status - messages[index] = updatedMessage + messages[index].deliveryStatus = status } } // Update in private chats var updatedPrivateChats = privateChats - for (peerID, var chatMessages) in updatedPrivateChats { + 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) { - var updatedMessage = chatMessages[index] - updatedMessage.deliveryStatus = status - chatMessages[index] = updatedMessage + chatMessages[index].deliveryStatus = status updatedPrivateChats[peerID] = chatMessages } } diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index ae51a1a5..7892e29d 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -8,6 +8,42 @@ import SwiftUI +// Pre-computed peer data for performance +struct PeerDisplayData: Identifiable { + let id: String + let displayName: String + let rssi: Int? + let isFavorite: Bool + let isMe: Bool + let hasUnreadMessages: Bool + let encryptionStatus: EncryptionStatus +} + +// Lazy loading wrapper for link previews +struct LazyLinkPreviewView: View { + let url: URL + let title: String? + @State private var isVisible = false + + var body: some View { + GeometryReader { geometry in + if isVisible { + LinkPreviewView(url: url, title: title) + } else { + // Placeholder while not visible + RoundedRectangle(cornerRadius: 10) + .fill(Color.gray.opacity(0.1)) + .frame(height: 80) + .onAppear { + // Only load when view appears on screen + isVisible = true + } + } + } + .frame(height: 80) + } +} + struct ContentView: View { @EnvironmentObject var viewModel: ChatViewModel @State private var messageText = "" @@ -22,6 +58,13 @@ struct ContentView: View { @State private var commandSuggestions: [String] = [] @State private var backSwipeOffset: CGFloat = 0 @State private var showPrivateChat = false + @State private var showMessageActions = false + @State private var selectedMessageSender: String? + @State private var selectedMessageSenderID: String? + @FocusState private var isNicknameFieldFocused: Bool + @State private var lastScrollTime: Date = .distantPast + @State private var scrollThrottleTimer: Timer? + @State private var autocompleteDebounceTimer: Timer? private var backgroundColor: Color { colorScheme == .dark ? Color.black : Color.white @@ -50,7 +93,7 @@ struct ContentView: View { insertion: .move(edge: .trailing), removal: .move(edge: .trailing) )) - .offset(x: showPrivateChat ? 0 : geometry.size.width) + .offset(x: showPrivateChat ? -1 : geometry.size.width) .offset(x: backSwipeOffset) .gesture( DragGesture() @@ -61,19 +104,18 @@ struct ContentView: View { } .onEnded { value in if value.translation.width > 50 || (value.translation.width > 30 && value.velocity.width > 300) { - withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + withAnimation(.easeOut(duration: 0.2)) { showPrivateChat = false backSwipeOffset = 0 viewModel.endPrivateChat() } } else { - withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + withAnimation(.easeOut(duration: 0.15)) { backSwipeOffset = 0 } } } ) - .animation(.spring(response: 0.3, dampingFraction: 0.8), value: showPrivateChat) } // Sidebar overlay @@ -82,30 +124,40 @@ struct ContentView: View { Color.clear .contentShape(Rectangle()) .onTapGesture { - withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + withAnimation(.easeInOut(duration: 0.2)) { showSidebar = false sidebarDragOffset = 0 } } - sidebarView - #if os(macOS) - .frame(width: min(300, geometry.size.width * 0.4)) - #else - .frame(width: geometry.size.width * 0.7) - #endif - .transition(.move(edge: .trailing)) + // Only render sidebar content when it's visible or animating + if showSidebar || sidebarDragOffset != 0 { + sidebarView + #if os(macOS) + .frame(width: min(300, geometry.size.width * 0.4)) + #else + .frame(width: geometry.size.width * 0.7) + #endif + .transition(.move(edge: .trailing)) + } else { + // Empty placeholder when hidden + Color.clear + #if os(macOS) + .frame(width: min(300, geometry.size.width * 0.4)) + #else + .frame(width: geometry.size.width * 0.7) + #endif + } } .offset(x: showSidebar ? -sidebarDragOffset : geometry.size.width - sidebarDragOffset) - .animation(.spring(response: 0.3, dampingFraction: 0.8), value: showSidebar) - .animation(.spring(response: 0.3, dampingFraction: 0.8), value: sidebarDragOffset) + .animation(.easeInOut(duration: 0.25), value: showSidebar) } } #if os(macOS) .frame(minWidth: 600, minHeight: 400) #endif .onChange(of: viewModel.selectedPrivateChatPeer) { newValue in - withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + withAnimation(.easeInOut(duration: 0.2)) { showPrivateChat = newValue != nil } } @@ -120,12 +172,52 @@ struct ContentView: View { FingerprintView(viewModel: viewModel, peerID: peerID) } } + .confirmationDialog( + selectedMessageSender.map { "@\($0)" } ?? "Actions", + isPresented: $showMessageActions, + titleVisibility: .visible + ) { + Button("private message") { + if let peerID = selectedMessageSenderID { + viewModel.startPrivateChat(with: peerID) + withAnimation(.easeInOut(duration: 0.2)) { + showSidebar = false + sidebarDragOffset = 0 + } + } + } + + Button("hug") { + if let sender = selectedMessageSender { + viewModel.sendMessage("/hug @\(sender)") + } + } + + Button("slap") { + if let sender = selectedMessageSender { + viewModel.sendMessage("/slap @\(sender)") + } + } + + Button("BLOCK", role: .destructive) { + if let sender = selectedMessageSender { + viewModel.sendMessage("/block \(sender)") + } + } + + Button("cancel", role: .cancel) {} + } + .onDisappear { + // Clean up timers + scrollThrottleTimer?.invalidate() + autocompleteDebounceTimer?.invalidate() + } } private func messagesView(privatePeer: String?) -> some View { ScrollViewReader { proxy in ScrollView { - LazyVStack(alignment: .leading, spacing: 2) { + VStack(alignment: .leading, spacing: 0) { let messages: [BitchatMessage] = { if let privatePeer = privatePeer { let msgs = viewModel.getPrivateChatMessages(for: privatePeer) @@ -135,8 +227,11 @@ struct ContentView: View { } }() - ForEach(messages, id: \.id) { message in - VStack(alignment: .leading, spacing: 4) { + // Implement windowing - show last 100 messages for performance + let windowedMessages = messages.suffix(100) + + ForEach(windowedMessages, id: \.id) { message in + VStack(alignment: .leading, spacing: 0) { // Check if current user is mentioned let _ = message.mentions?.contains(viewModel.nickname) ?? false @@ -148,7 +243,7 @@ struct ContentView: View { .frame(maxWidth: .infinity, alignment: .leading) } else { // Regular messages with natural text wrapping - VStack(alignment: .leading, spacing: 2) { + VStack(alignment: .leading, spacing: 0) { HStack(alignment: .top, spacing: 0) { // Single text view for natural wrapping Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme)) @@ -164,33 +259,35 @@ struct ContentView: View { } } - // Check for links and show preview - if let markdownLink = message.content.extractMarkdownLink() { - // Don't show link preview if the message is just the emoji - let cleanContent = message.content.trimmingCharacters(in: .whitespacesAndNewlines) - if cleanContent.hasPrefix("👇") { - LinkPreviewView(url: markdownLink.url, title: markdownLink.title) - .padding(.top, 2) - .id("\(message.id)-\(markdownLink.url.absoluteString)") - } - } else { - // Check for plain URLs - let urls = message.content.extractURLs() - ForEach(Array(urls.prefix(3).enumerated()), id: \.offset) { index, urlInfo in - LinkPreviewView(url: urlInfo.url, title: nil) - .padding(.top, 2) + // Check for plain URLs + let urls = message.content.extractURLs() + if !urls.isEmpty { + ForEach(urls.prefix(3).indices, id: \.self) { index in + let urlInfo = urls[index] + LazyLinkPreviewView(url: urlInfo.url, title: nil) + .padding(.top, 3) + .padding(.horizontal, 1) .id("\(message.id)-\(urlInfo.url.absoluteString)") } } } } } + .id(message.id) + .contentShape(Rectangle()) + .onTapGesture { + // Only show actions for messages from other users (not system or self) + if message.sender != "system" && message.sender != viewModel.nickname { + selectedMessageSender = message.sender + selectedMessageSenderID = message.senderPeerID + showMessageActions = true + } + } .padding(.horizontal, 12) .padding(.vertical, 2) - .id(message.id) } } - .padding(.vertical, 8) + .padding(.vertical, 4) } .background(backgroundColor) .onTapGesture(count: 3) { @@ -199,8 +296,19 @@ struct ContentView: View { } .onChange(of: viewModel.messages.count) { _ in if privatePeer == nil && !viewModel.messages.isEmpty { - withAnimation { - proxy.scrollTo(viewModel.messages.last?.id, anchor: .bottom) + // Throttle scroll animations to prevent excessive UI updates + let now = Date() + if now.timeIntervalSince(lastScrollTime) > 0.5 { + // Immediate scroll if enough time has passed + lastScrollTime = now + proxy.scrollTo(viewModel.messages.suffix(100).last?.id, anchor: .bottom) + } else { + // Schedule a delayed scroll + scrollThrottleTimer?.invalidate() + scrollThrottleTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { _ in + lastScrollTime = Date() + proxy.scrollTo(viewModel.messages.suffix(100).last?.id, anchor: .bottom) + } } } } @@ -208,8 +316,17 @@ struct ContentView: View { if let peerID = privatePeer, let messages = viewModel.privateChats[peerID], !messages.isEmpty { - withAnimation { - proxy.scrollTo(messages.last?.id, anchor: .bottom) + // Same throttling for private chats + let now = Date() + if now.timeIntervalSince(lastScrollTime) > 0.5 { + lastScrollTime = now + proxy.scrollTo(messages.suffix(100).last?.id, anchor: .bottom) + } else { + scrollThrottleTimer?.invalidate() + scrollThrottleTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { _ in + lastScrollTime = Date() + proxy.scrollTo(messages.suffix(100).last?.id, anchor: .bottom) + } } } } @@ -236,7 +353,7 @@ struct ContentView: View { // @mentions autocomplete if viewModel.showAutocomplete && !viewModel.autocompleteSuggestions.isEmpty { VStack(alignment: .leading, spacing: 0) { - ForEach(Array(viewModel.autocompleteSuggestions.enumerated()), id: \.element) { index, suggestion in + ForEach(viewModel.autocompleteSuggestions, id: \.self) { suggestion in Button(action: { _ = viewModel.completeNickname(suggestion, in: &messageText) }) { @@ -329,33 +446,24 @@ struct ContentView: View { } HStack(alignment: .center, spacing: 4) { - if viewModel.selectedPrivateChatPeer != nil { - Text("<@\(viewModel.nickname)> →") - .font(.system(size: 12, weight: .medium, design: .monospaced)) - .foregroundColor(Color.orange) - .lineLimit(1) - .fixedSize() - .padding(.leading, 12) - } else { - Text("<@\(viewModel.nickname)>") - .font(.system(size: 12, weight: .medium, design: .monospaced)) - .foregroundColor(textColor) - .lineLimit(1) - .fixedSize() - .padding(.leading, 12) - } - - TextField("", text: $messageText) + TextField("type a message...", text: $messageText) .textFieldStyle(.plain) .font(.system(size: 14, design: .monospaced)) .foregroundColor(textColor) .focused($isTextFieldFocused) + .padding(.leading, 12) .onChange(of: messageText) { newValue in - // Get cursor position (approximate - end of text for now) - let cursorPosition = newValue.count - viewModel.updateAutocomplete(for: newValue, cursorPosition: cursorPosition) + // Cancel previous debounce timer + autocompleteDebounceTimer?.invalidate() - // Check for command autocomplete + // Debounce autocomplete updates to reduce calls during rapid typing + autocompleteDebounceTimer = Timer.scheduledTimer(withTimeInterval: 0.15, repeats: false) { _ in + // Get cursor position (approximate - end of text for now) + let cursorPosition = newValue.count + viewModel.updateAutocomplete(for: newValue, cursorPosition: cursorPosition) + } + + // Check for command autocomplete (instant, no debounce needed) if newValue.hasPrefix("/") && newValue.count >= 1 { // Build context-aware command list let commandDescriptions = [ @@ -437,7 +545,7 @@ struct ContentView: View { VStack(alignment: .leading, spacing: 0) { // Header - match main toolbar height HStack { - Text("YOUR NETWORK") + Text("NETWORK") .font(.system(size: 16, weight: .bold, design: .monospaced)) .foregroundColor(textColor) Spacer() @@ -467,7 +575,7 @@ struct ContentView: View { } if viewModel.connectedPeers.isEmpty { - Text("no one connected...") + Text("nobody around...") .font(.system(size: 14, design: .monospaced)) .foregroundColor(secondaryTextColor) .padding(.horizontal) @@ -479,39 +587,39 @@ struct ContentView: View { // Show all connected peers let peersToShow: [String] = viewModel.connectedPeers - // Sort peers: favorites first, then alphabetically by nickname - let sortedPeers = peersToShow.sorted { peer1, peer2 in - let isFav1 = viewModel.isFavorite(peerID: peer1) - let isFav2 = viewModel.isFavorite(peerID: peer2) - - if isFav1 != isFav2 { - return isFav1 // Favorites come first + // Pre-compute peer data outside ForEach to reduce overhead + let peerData = peersToShow.map { peerID in + PeerDisplayData( + id: peerID, + displayName: peerID == myPeerID ? viewModel.nickname : (peerNicknames[peerID] ?? "anon\(peerID.prefix(4))"), + rssi: peerRSSI[peerID]?.intValue, + isFavorite: viewModel.isFavorite(peerID: peerID), + isMe: peerID == myPeerID, + hasUnreadMessages: viewModel.unreadPrivateMessages.contains(peerID), + encryptionStatus: viewModel.getEncryptionStatus(for: peerID) + ) + }.sorted { peer1, peer2 in + // Sort: favorites first, then alphabetically by nickname + if peer1.isFavorite != peer2.isFavorite { + return peer1.isFavorite + } + return peer1.displayName < peer2.displayName } - - let name1 = peerNicknames[peer1] ?? "anon\(peer1.prefix(4))" - let name2 = peerNicknames[peer2] ?? "anon\(peer2.prefix(4))" - return name1 < name2 - } - ForEach(sortedPeers, id: \.self) { peerID in - let displayName = peerID == myPeerID ? viewModel.nickname : (peerNicknames[peerID] ?? "anon\(peerID.prefix(4))") - let rssi = peerRSSI[peerID]?.intValue - let isFavorite = viewModel.isFavorite(peerID: peerID) - let isMe = peerID == myPeerID - + ForEach(peerData) { peer in HStack(spacing: 8) { // Signal strength indicator or unread message icon - if isMe { + if peer.isMe { Image(systemName: "person.fill") .font(.system(size: 10)) .foregroundColor(textColor) .accessibilityLabel("You") - } else if viewModel.unreadPrivateMessages.contains(peerID) { + } else if peer.hasUnreadMessages { Image(systemName: "envelope.fill") .font(.system(size: 12)) .foregroundColor(Color.orange) - .accessibilityLabel("Unread message from \(displayName)") - } else if let rssi = rssi { + .accessibilityLabel("Unread message from \(peer.displayName)") + } else if let rssi = peer.rssi { Image(systemName: "circle.fill") .font(.system(size: 8)) .foregroundColor(viewModel.getRSSIColor(rssi: rssi, colorScheme: colorScheme)) @@ -525,61 +633,60 @@ struct ContentView: View { } // Peer name - if isMe { + if peer.isMe { HStack { - Text(displayName + " (you)") + Text(peer.displayName + " (you)") .font(.system(size: 14, design: .monospaced)) .foregroundColor(textColor) Spacer() } } else { - Button(action: { - if peerNicknames[peerID] != nil { - viewModel.startPrivateChat(with: peerID) - withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { - showSidebar = false - sidebarDragOffset = 0 - } - } - }) { - Text(displayName) - .font(.system(size: 14, design: .monospaced)) - .foregroundColor(peerNicknames[peerID] != nil ? textColor : secondaryTextColor) - } - .buttonStyle(.plain) - .disabled(peerNicknames[peerID] == nil) - .onTapGesture(count: 2) { - // Show fingerprint on double tap - viewModel.showFingerprint(for: peerID) - } + Text(peer.displayName) + .font(.system(size: 14, design: .monospaced)) + .foregroundColor(peerNicknames[peer.id] != nil ? textColor : secondaryTextColor) // Encryption status icon (after peer name) - let encryptionStatus = viewModel.getEncryptionStatus(for: peerID) - Image(systemName: encryptionStatus.icon) + Image(systemName: peer.encryptionStatus.icon) .font(.system(size: 10)) - .foregroundColor(encryptionStatus == .noiseVerified ? Color.green : - encryptionStatus == .noiseSecured ? textColor : - encryptionStatus == .noiseHandshaking ? Color.orange : + .foregroundColor(peer.encryptionStatus == .noiseVerified ? Color.green : + peer.encryptionStatus == .noiseSecured ? textColor : + peer.encryptionStatus == .noiseHandshaking ? Color.orange : Color.red) - .accessibilityLabel("Encryption: \(encryptionStatus == .noiseVerified ? "verified" : encryptionStatus == .noiseSecured ? "secured" : encryptionStatus == .noiseHandshaking ? "establishing" : "none")") + .accessibilityLabel("Encryption: \(peer.encryptionStatus == .noiseVerified ? "verified" : peer.encryptionStatus == .noiseSecured ? "secured" : peer.encryptionStatus == .noiseHandshaking ? "establishing" : "none")") Spacer() // Favorite star Button(action: { - viewModel.toggleFavorite(peerID: peerID) + viewModel.toggleFavorite(peerID: peer.id) }) { - Image(systemName: isFavorite ? "star.fill" : "star") + Image(systemName: peer.isFavorite ? "star.fill" : "star") .font(.system(size: 12)) - .foregroundColor(isFavorite ? Color.yellow : secondaryTextColor) + .foregroundColor(peer.isFavorite ? Color.yellow : secondaryTextColor) } .buttonStyle(.plain) - .accessibilityLabel(isFavorite ? "Remove \(displayName) from favorites" : "Add \(displayName) to favorites") + .accessibilityLabel(peer.isFavorite ? "Remove \(peer.displayName) from favorites" : "Add \(peer.displayName) to favorites") } } .padding(.horizontal) .padding(.vertical, 8) + .contentShape(Rectangle()) + .onTapGesture { + if !peer.isMe && peerNicknames[peer.id] != nil { + viewModel.startPrivateChat(with: peer.id) + withAnimation(.easeInOut(duration: 0.2)) { + showSidebar = false + sidebarDragOffset = 0 + } + } + } + .onTapGesture(count: 2) { + if !peer.isMe { + // Show fingerprint on double tap + viewModel.showFingerprint(for: peer.id) + } + } } } } @@ -615,7 +722,7 @@ struct ContentView: View { } } .onEnded { value in - withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + withAnimation(.easeOut(duration: 0.2)) { if !showSidebar { if value.translation.width < -100 || (value.translation.width < -50 && value.velocity.width < -500) { showSidebar = true @@ -680,11 +787,15 @@ struct ContentView: View { .font(.system(size: 14, design: .monospaced)) .frame(maxWidth: 100) .foregroundColor(textColor) - .onChange(of: viewModel.nickname) { _ in - viewModel.saveNickname() + .focused($isNicknameFieldFocused) + .onChange(of: isNicknameFieldFocused) { isFocused in + if !isFocused { + // Only validate when losing focus + viewModel.validateAndSaveNickname() + } } .onSubmit { - viewModel.saveNickname() + viewModel.validateAndSaveNickname() } } @@ -713,7 +824,7 @@ struct ContentView: View { .foregroundColor(viewModel.isConnected ? textColor : Color.red) } .onTapGesture { - withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + withAnimation(.easeInOut(duration: 0.2)) { showSidebar.toggle() sidebarDragOffset = 0 } @@ -730,7 +841,7 @@ struct ContentView: View { let privatePeerNick = viewModel.meshService.getPeerNicknames()[privatePeerID] { HStack { Button(action: { - withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + withAnimation(.easeInOut(duration: 0.2)) { showPrivateChat = false viewModel.endPrivateChat() } diff --git a/bitchat/Views/LinkPreviewView.swift b/bitchat/Views/LinkPreviewView.swift index 74d30e1b..6f3875ba 100644 --- a/bitchat/Views/LinkPreviewView.swift +++ b/bitchat/Views/LinkPreviewView.swift @@ -410,50 +410,20 @@ extension String { func extractURLs() -> [(url: URL, range: Range)] { var urls: [(URL, Range)] = [] - // Check for markdown-style links [title](url) - let markdownPattern = #"\[([^\]]+)\]\(([^)]+)\)"# - if let regex = try? NSRegularExpression(pattern: markdownPattern) { - let matches = regex.matches(in: self, range: NSRange(location: 0, length: self.utf16.count)) - for match in matches { - if let urlRange = Range(match.range(at: 2), in: self), - let url = URL(string: String(self[urlRange])), - let fullRange = Range(match.range, in: self) { - urls.append((url, fullRange)) - } - } - } - - // Also check for plain URLs + // Check for plain URLs let types: NSTextCheckingResult.CheckingType = .link if let detector = try? NSDataDetector(types: types.rawValue) { let matches = detector.matches(in: self, range: NSRange(location: 0, length: self.utf16.count)) for match in matches { if let range = Range(match.range, in: self), let url = match.url { - // Don't add if this URL is already part of a markdown link - let isPartOfMarkdown = urls.contains { $0.1.overlaps(range) } - if !isPartOfMarkdown { - urls.append((url, range)) - } + urls.append((url, range)) } } } return urls } - - func extractMarkdownLink() -> (title: String, url: URL)? { - let pattern = #"\[([^\]]+)\]\(([^)]+)\)"# - if let regex = try? NSRegularExpression(pattern: pattern), - let match = regex.firstMatch(in: self, range: NSRange(location: 0, length: self.utf16.count)) { - if let titleRange = Range(match.range(at: 1), in: self), - let urlRange = Range(match.range(at: 2), in: self), - let url = URL(string: String(self[urlRange])) { - return (String(self[titleRange]), url) - } - } - return nil - } } #Preview {