From 0beee2fb2706c326f96049b09292cd73624ca1c3 Mon Sep 17 00:00:00 2001 From: jack Date: Thu, 24 Jul 2025 11:47:20 +0200 Subject: [PATCH 01/21] Improve peer list UI polish - Make entire peer list row tappable for starting private chats - Fix separator bar visibility when private chat is fully open (offset by -1px) - Keep favorite star as separate tap target - Preserve double-tap gesture for showing fingerprints --- bitchat/Views/ContentView.swift | 44 ++++++++++++++++----------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index ae51a1a5..4c9dfb66 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -50,7 +50,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() @@ -437,7 +437,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 +467,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) @@ -534,25 +534,9 @@ struct ContentView: View { 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(displayName) + .font(.system(size: 14, design: .monospaced)) + .foregroundColor(peerNicknames[peerID] != nil ? textColor : secondaryTextColor) // Encryption status icon (after peer name) let encryptionStatus = viewModel.getEncryptionStatus(for: peerID) @@ -580,6 +564,22 @@ struct ContentView: View { } .padding(.horizontal) .padding(.vertical, 8) + .contentShape(Rectangle()) + .onTapGesture { + if !isMe && peerNicknames[peerID] != nil { + viewModel.startPrivateChat(with: peerID) + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + showSidebar = false + sidebarDragOffset = 0 + } + } + } + .onTapGesture(count: 2) { + if !isMe { + // Show fingerprint on double tap + viewModel.showFingerprint(for: peerID) + } + } } } } From b0204afa7517506bb64a4c7279fbc160662bfb88 Mon Sep 17 00:00:00 2001 From: jack Date: Thu, 24 Jul 2025 11:54:09 +0200 Subject: [PATCH 02/21] Add message action sheet for user interactions - Tap any message to show action sheet with user options - Actions include: private message, hug, slap, and block - Only appears for messages from other users (not system or self) - Integrates with existing chat commands (/hug, /slap, /block) --- bitchat/Views/ContentView.swift | 47 +++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index 4c9dfb66..4f261d7c 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -22,6 +22,9 @@ 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? private var backgroundColor: Color { colorScheme == .dark ? Color.black : Color.white @@ -120,6 +123,41 @@ 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(.spring(response: 0.3, dampingFraction: 0.8)) { + 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) {} + } } private func messagesView(privatePeer: String?) -> some View { @@ -188,6 +226,15 @@ struct ContentView: View { .padding(.horizontal, 12) .padding(.vertical, 2) .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(.vertical, 8) From c4ca38654dc9634a5c1671fc8df0446da3e2f66b Mon Sep 17 00:00:00 2001 From: jack Date: Thu, 24 Jul 2025 12:00:01 +0200 Subject: [PATCH 03/21] Improve private message notification behavior - Don't show PM notifications when the private chat is already open - Store peer ID in notification userInfo for reliable chat opening - Tap notification to open the correct private chat using peer ID - Check both in ChatViewModel (when receiving) and NotificationDelegate (when presenting) --- bitchat/BitchatApp.swift | 30 +++++++++++++++------- bitchat/Services/NotificationService.swift | 10 +++++--- bitchat/ViewModels/ChatViewModel.swift | 5 +++- 3 files changed, 32 insertions(+), 13 deletions(-) diff --git a/bitchat/BitchatApp.swift b/bitchat/BitchatApp.swift index c2694842..8322ff45 100644 --- a/bitchat/BitchatApp.swift +++ b/bitchat/BitchatApp.swift @@ -147,17 +147,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 +163,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/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..d9f539cf 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -1968,7 +1968,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) From cc3a09420558f1c2de7e95408f8ddc1eccf5cced Mon Sep 17 00:00:00 2001 From: jack Date: Thu, 24 Jul 2025 12:16:23 +0200 Subject: [PATCH 04/21] Add nickname validation to prevent empty names - Validate nickname only when field loses focus or on submit - Replace empty nicknames with anon+random 4 digits (e.g. anon3847) - Remove auto-save timer to prevent validation while typing - Add FocusState to detect when user exits the field --- bitchat/ViewModels/ChatViewModel.swift | 18 +++++++++--------- bitchat/Views/ContentView.swift | 11 ++++++++--- 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index d9f539cf..a8d84e67 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -18,14 +18,7 @@ import UIKit class ChatViewModel: ObservableObject { @Published var messages: [BitchatMessage] = [] @Published var connectedPeers: [String] = [] - @Published var nickname: String = "" { - didSet { - nicknameSaveTimer?.invalidate() - nicknameSaveTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { _ in - self.saveNickname() - } - } - } + @Published var nickname: String = "" @Published var isConnected = false @Published var privateChats: [String: [BitchatMessage]] = [:] // peerID -> messages @Published var selectedPrivateChatPeer: String? = nil @@ -42,7 +35,6 @@ class ChatViewModel: ObservableObject { var meshService = BluetoothMeshService() private let userDefaults = UserDefaults.standard private let nicknameKey = "bitchat.nickname" - private var nicknameSaveTimer: Timer? @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 @@ -172,6 +164,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() diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index 4f261d7c..6366f015 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -25,6 +25,7 @@ struct ContentView: View { @State private var showMessageActions = false @State private var selectedMessageSender: String? @State private var selectedMessageSenderID: String? + @FocusState private var isNicknameFieldFocused: Bool private var backgroundColor: Color { colorScheme == .dark ? Color.black : Color.white @@ -727,11 +728,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() } } From a3380085116ccfeb06b75a1d01730923ffbb8811 Mon Sep 17 00:00:00 2001 From: jack Date: Thu, 24 Jul 2025 12:22:50 +0200 Subject: [PATCH 05/21] Remove nickname label from chat input box - Remove <@nickname> label to simplify UI - Add placeholder text "type a message..." for clarity - Maintain consistent padding for input field --- bitchat/Views/ContentView.swift | 19 ++----------------- 1 file changed, 2 insertions(+), 17 deletions(-) diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index 6366f015..ab0d72ff 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -377,27 +377,12 @@ 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 From f45c52e9d3433fc2705362f8a94709739a1893eb Mon Sep 17 00:00:00 2001 From: jack Date: Thu, 24 Jul 2025 12:38:31 +0200 Subject: [PATCH 06/21] Optimize UI performance with message caching and List view - Convert BitchatMessage from struct to class for efficient caching - Cache formatted AttributedStrings to avoid expensive regex on every render - Replace ScrollView+LazyVStack with native List for better cell reuse - Implement message windowing (show last 100 messages for performance) - Add LazyLinkPreviewView that defers loading for 0.5s to improve scroll performance These optimizations significantly improve scroll performance, especially with large message lists. --- bitchat/Protocols/BitchatProtocol.swift | 36 +++++++++- bitchat/ViewModels/ChatViewModel.swift | 21 +++--- bitchat/Views/ContentView.swift | 87 ++++++++++++++++++------- 3 files changed, 111 insertions(+), 33 deletions(-) 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/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index a8d84e67..c866240c 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -938,9 +938,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) @@ -1075,6 +1081,9 @@ class ChatViewModel: ObservableObject { result.append(content.mergingAttributes(contentStyle)) } + // Cache the formatted text + message.setCachedFormattedText(result, isDark: isDark) + return result } @@ -2223,21 +2232,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 ab0d72ff..1b394e18 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -163,18 +163,20 @@ struct ContentView: View { private func messagesView(privatePeer: String?) -> some View { ScrollViewReader { proxy in - ScrollView { - LazyVStack(alignment: .leading, spacing: 2) { - let messages: [BitchatMessage] = { - if let privatePeer = privatePeer { - let msgs = viewModel.getPrivateChatMessages(for: privatePeer) - return msgs - } else { - return viewModel.messages - } - }() - - ForEach(messages, id: \.id) { message in + List { + let messages: [BitchatMessage] = { + if let privatePeer = privatePeer { + let msgs = viewModel.getPrivateChatMessages(for: privatePeer) + return msgs + } else { + return viewModel.messages + } + }() + + // Implement windowing - show last 100 messages for performance + let windowedMessages = messages.suffix(100) + + ForEach(Array(windowedMessages), id: \.id) { message in VStack(alignment: .leading, spacing: 4) { // Check if current user is mentioned let _ = message.mentions?.contains(viewModel.nickname) ?? false @@ -208,24 +210,26 @@ struct ContentView: View { // 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)") + LazyLinkPreviewView( + url: markdownLink.url, + title: markdownLink.title, + 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) - .id("\(message.id)-\(urlInfo.url.absoluteString)") + LazyLinkPreviewView( + url: urlInfo.url, + title: nil, + id: "\(message.id)-\(urlInfo.url.absoluteString)" + ) } } } } } - .padding(.horizontal, 12) - .padding(.vertical, 2) .id(message.id) .contentShape(Rectangle()) .onTapGesture { @@ -236,10 +240,13 @@ struct ContentView: View { showMessageActions = true } } + .listRowInsets(EdgeInsets(top: 2, leading: 12, bottom: 2, trailing: 12)) + .listRowSeparator(.hidden) + .listRowBackground(backgroundColor) } - } - .padding(.vertical, 8) } + .listStyle(.plain) + .scrollContentBackground(.hidden) .background(backgroundColor) .onTapGesture(count: 3) { // Triple-tap to clear current chat @@ -248,7 +255,9 @@ struct ContentView: View { .onChange(of: viewModel.messages.count) { _ in if privatePeer == nil && !viewModel.messages.isEmpty { withAnimation { - proxy.scrollTo(viewModel.messages.last?.id, anchor: .bottom) + // Scroll to last message in windowed view + let lastId = viewModel.messages.suffix(100).last?.id + proxy.scrollTo(lastId, anchor: .bottom) } } } @@ -257,7 +266,9 @@ struct ContentView: View { let messages = viewModel.privateChats[peerID], !messages.isEmpty { withAnimation { - proxy.scrollTo(messages.last?.id, anchor: .bottom) + // Scroll to last message in windowed view + let lastId = messages.suffix(100).last?.id + proxy.scrollTo(lastId, anchor: .bottom) } } } @@ -940,6 +951,34 @@ struct MessageContentView: View { } } +// Lazy loading wrapper for LinkPreviewView +struct LazyLinkPreviewView: View { + let url: URL + let title: String? + let id: String + + @State private var shouldLoad = false + + var body: some View { + Group { + if shouldLoad { + LinkPreviewView(url: url, title: title) + .padding(.top, 2) + .id(id) + } + } + .onAppear { + // Delay loading to improve scroll performance + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + shouldLoad = true + } + } + .onDisappear { + shouldLoad = false + } + } +} + // Delivery status indicator view struct DeliveryStatusView: View { let status: DeliveryStatus From b480a4836c0f5b850ae92d939596dc81889e18ce Mon Sep 17 00:00:00 2001 From: jack Date: Thu, 24 Jul 2025 12:47:49 +0200 Subject: [PATCH 07/21] Fix message spacing and remove markdown link support - Reduce VStack spacing from 4 to 2 and nested VStack from 2 to 0 - Set list row insets top/bottom to 0 to minimize spacing on iPhone - Remove all markdown link parsing and formatting - Share plain URLs instead of markdown when using share extension - Keep only plain text URL detection and preview functionality --- bitchat/BitchatApp.swift | 10 +++----- bitchat/ViewModels/ChatViewModel.swift | 29 ++------------------- bitchat/Views/ContentView.swift | 35 ++++++++------------------ bitchat/Views/LinkPreviewView.swift | 34 ++----------------------- 4 files changed, 19 insertions(+), 89 deletions(-) diff --git a/bitchat/BitchatApp.swift b/bitchat/BitchatApp.swift index 8322ff45..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) diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index c866240c..fa805960 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -979,33 +979,8 @@ class ChatViewModel: ObservableObject { senderStyle.font = .system(size: 14, weight: .medium, 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[.. [(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 { From c08fe38c721cbb55cab838bcc64e0aacdc9bffc6 Mon Sep 17 00:00:00 2001 From: jack Date: Thu, 24 Jul 2025 12:53:55 +0200 Subject: [PATCH 08/21] Fix URL detection and message spacing - Add URL detection to message formatting with blue color and underline - Replace List with ScrollView for precise spacing control - Use minimal vertical padding (1px) between messages - Remove LazyLinkPreviewView, use LinkPreviewView directly - URLs now appear highlighted in message text AND show preview below --- bitchat/ViewModels/ChatViewModel.swift | 10 ++++ bitchat/Views/ContentView.swift | 80 +++++++++----------------- 2 files changed, 36 insertions(+), 54 deletions(-) diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index fa805960..d0033f11 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -988,8 +988,12 @@ class ChatViewModel: ObservableObject { 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)] = [] @@ -999,6 +1003,9 @@ class ChatViewModel: ObservableObject { 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 @@ -1029,6 +1036,9 @@ class ChatViewModel: ObservableObject { 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)) diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index 204eb6d4..e8bbe0fa 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -163,21 +163,22 @@ struct ContentView: View { private func messagesView(privatePeer: String?) -> some View { ScrollViewReader { proxy in - List { - let messages: [BitchatMessage] = { - if let privatePeer = privatePeer { - let msgs = viewModel.getPrivateChatMessages(for: privatePeer) - return msgs - } else { - return viewModel.messages - } - }() - - // Implement windowing - show last 100 messages for performance - let windowedMessages = messages.suffix(100) - - ForEach(Array(windowedMessages), id: \.id) { message in - VStack(alignment: .leading, spacing: 2) { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + let messages: [BitchatMessage] = { + if let privatePeer = privatePeer { + let msgs = viewModel.getPrivateChatMessages(for: privatePeer) + return msgs + } else { + return viewModel.messages + } + }() + + // Implement windowing - show last 100 messages for performance + let windowedMessages = messages.suffix(100) + + ForEach(Array(windowedMessages), id: \.id) { message in + VStack(alignment: .leading, spacing: 0) { // Check if current user is mentioned let _ = message.mentions?.contains(viewModel.nickname) ?? false @@ -207,12 +208,12 @@ struct ContentView: View { // Check for plain URLs let urls = message.content.extractURLs() - ForEach(Array(urls.prefix(3).enumerated()), id: \.offset) { index, urlInfo in - LazyLinkPreviewView( - url: urlInfo.url, - title: nil, - id: "\(message.id)-\(urlInfo.url.absoluteString)" - ) + if !urls.isEmpty { + ForEach(Array(urls.prefix(3).enumerated()), id: \.offset) { index, urlInfo in + LinkPreviewView(url: urlInfo.url, title: nil) + .padding(.top, 2) + .id("\(message.id)-\(urlInfo.url.absoluteString)") + } } } } @@ -227,13 +228,12 @@ struct ContentView: View { showMessageActions = true } } - .listRowInsets(EdgeInsets(top: 0, leading: 12, bottom: 0, trailing: 12)) - .listRowSeparator(.hidden) - .listRowBackground(backgroundColor) + .padding(.horizontal, 12) + .padding(.vertical, 1) } + } + .padding(.vertical, 4) } - .listStyle(.plain) - .scrollContentBackground(.hidden) .background(backgroundColor) .onTapGesture(count: 3) { // Triple-tap to clear current chat @@ -938,34 +938,6 @@ struct MessageContentView: View { } } -// Lazy loading wrapper for LinkPreviewView -struct LazyLinkPreviewView: View { - let url: URL - let title: String? - let id: String - - @State private var shouldLoad = false - - var body: some View { - Group { - if shouldLoad { - LinkPreviewView(url: url, title: title) - .padding(.top, 2) - .id(id) - } - } - .onAppear { - // Delay loading to improve scroll performance - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - shouldLoad = true - } - } - .onDisappear { - shouldLoad = false - } - } -} - // Delivery status indicator view struct DeliveryStatusView: View { let status: DeliveryStatus From 5a0c8f0a26c0776cdb2c53ac81afd6476f336135 Mon Sep 17 00:00:00 2001 From: jack Date: Thu, 24 Jul 2025 12:59:46 +0200 Subject: [PATCH 09/21] Add 1pt more padding around link preview box - Increase top padding from 2pt to 3pt - Add 1pt horizontal padding for better visual balance --- bitchat/Views/ContentView.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index e8bbe0fa..7450e917 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -211,7 +211,8 @@ struct ContentView: View { if !urls.isEmpty { ForEach(Array(urls.prefix(3).enumerated()), id: \.offset) { index, urlInfo in LinkPreviewView(url: urlInfo.url, title: nil) - .padding(.top, 2) + .padding(.top, 3) + .padding(.horizontal, 1) .id("\(message.id)-\(urlInfo.url.absoluteString)") } } From 5d3512dfbc4f78c1cb950deefffdcdc62d91998a Mon Sep 17 00:00:00 2001 From: jack Date: Thu, 24 Jul 2025 13:04:43 +0200 Subject: [PATCH 10/21] Add 1pt more padding around link preview box - Increased padding from 9pt to 10pt for better visual spacing - Improves readability and touch target size --- bitchat/ViewModels/ChatViewModel.swift | 74 +++++++++++++++++++------- 1 file changed, 56 insertions(+), 18 deletions(-) diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index d0033f11..8077f0e9 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -36,6 +36,10 @@ class ChatViewModel: ObservableObject { private let userDefaults = UserDefaults.standard private let nicknameKey = "bitchat.nickname" + // 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 private var blockedUsers: Set = [] // Stores public key fingerprints of blocked users @@ -765,25 +769,37 @@ 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) { @@ -1203,6 +1219,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 @@ -1210,34 +1231,51 @@ 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() } // Update encryption status in appropriate places, not during view updates From f9d5a07542f187f0bdc0d27871c940fdb522fd1e Mon Sep 17 00:00:00 2001 From: jack Date: Thu, 24 Jul 2025 13:09:18 +0200 Subject: [PATCH 11/21] Optimization 3: Add caching for expensive view computations - Cache getRSSIColor results with key format: "\(rssi)_\(isDark)" - Cache getEncryptionStatus results with peerID as key - Add cache invalidation methods for both caches - Invalidate encryption cache when: - Encryption status is updated - Fingerprints are verified - Peer authentication callbacks trigger - Invalidate RSSI cache when peer list updates - Clear all caches in panicClearAllData This reduces redundant calculations during view updates and improves performance --- bitchat/ViewModels/ChatViewModel.swift | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 8077f0e9..2a66ff57 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -747,6 +747,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() @@ -1212,6 +1216,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() @@ -1299,6 +1306,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() @@ -1418,6 +1428,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() } @@ -1426,7 +1439,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() } } } @@ -2168,6 +2188,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() From e13380fe278fcd74c552832d25db9cc296d4aea4 Mon Sep 17 00:00:00 2001 From: jack Date: Thu, 24 Jul 2025 13:14:37 +0200 Subject: [PATCH 12/21] Set message limit to 1337 and auto-trim oldest messages - Added maxMessages constant set to 1337 - Added trimMessagesIfNeeded() to remove oldest messages when limit exceeded - Added trimPrivateChatMessagesIfNeeded() for private chat limits - Applied trimming after all message append operations: - Regular chat messages (sent and received) - Private messages (sent and received) - System messages (connect/disconnect/screenshot) - Message migrations between peer IDs - Ensures chat history doesn't grow unbounded --- bitchat/ViewModels/ChatViewModel.swift | 30 ++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 2a66ff57..8c657eda 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -17,6 +17,7 @@ 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 = "" @Published var isConnected = false @@ -321,6 +322,7 @@ class ChatViewModel: ObservableObject { privateChats[currentPeerID] = [] } privateChats[currentPeerID]?.append(contentsOf: oldMessages) + trimPrivateChatMessagesIfNeeded(for: currentPeerID) privateChats.removeValue(forKey: oldPeerID) } @@ -378,6 +380,7 @@ class ChatViewModel: ObservableObject { // Add to main messages messages.append(message) + trimMessagesIfNeeded() // Send via mesh with mentions meshService.sendMessage(content, mentions: mentions) @@ -424,6 +427,7 @@ class ChatViewModel: ObservableObject { privateChats[peerID] = [] } privateChats[peerID]?.append(message) + trimPrivateChatMessagesIfNeeded(for: peerID) // Track the message for delivery confirmation let isFavorite = isFavorite(peerID: peerID) @@ -505,6 +509,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] = [] } @@ -565,6 +570,7 @@ class ChatViewModel: ObservableObject { privateChats[peerID] = [] } privateChats[peerID]?.append(localNotification) + trimPrivateChatMessagesIfNeeded(for: peerID) } else { // In public chat - send to everyone @@ -578,6 +584,7 @@ class ChatViewModel: ObservableObject { isRelay: false ) messages.append(localNotification) + trimMessagesIfNeeded() } } @@ -1285,6 +1292,22 @@ class ChatViewModel: ObservableObject { 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) + } + } + // Update encryption status in appropriate places, not during view updates private func updateEncryptionStatus(for peerID: String) { let noiseService = meshService.getNoiseService() @@ -1863,6 +1886,7 @@ extension ChatViewModel: BitchatDelegate { // Initialize with migrated messages privateChats[peerID] = migratedMessages + trimPrivateChatMessagesIfNeeded(for: peerID) } if privateChats[peerID] == nil { @@ -1904,6 +1928,7 @@ extension ChatViewModel: BitchatDelegate { privateChats[peerID]?.append(messageToStore) // Sort messages by timestamp to ensure proper ordering privateChats[peerID]?.sort { $0.timestamp < $1.timestamp } + trimPrivateChatMessagesIfNeeded(for: peerID) // Debug logging @@ -1980,6 +2005,7 @@ extension ChatViewModel: BitchatDelegate { messages.append(finalMessage) // Sort messages by timestamp to ensure proper ordering messages.sort { $0.timestamp < $1.timestamp } + trimMessagesIfNeeded() } } else if finalMessage.sender != "system" { // Our own message - check if we already have it (by ID and content) @@ -2002,6 +2028,7 @@ extension ChatViewModel: BitchatDelegate { if !finalMessage.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { messages.append(finalMessage) messages.sort { $0.timestamp < $1.timestamp } + trimMessagesIfNeeded() } } } else { @@ -2009,6 +2036,7 @@ extension ChatViewModel: BitchatDelegate { if !finalMessage.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { messages.append(finalMessage) messages.sort { $0.timestamp < $1.timestamp } + trimMessagesIfNeeded() } } } @@ -2135,6 +2163,7 @@ extension ChatViewModel: BitchatDelegate { originalSender: nil ) messages.append(systemMessage) + trimMessagesIfNeeded() // Force UI update objectWillChange.send() @@ -2169,6 +2198,7 @@ extension ChatViewModel: BitchatDelegate { originalSender: nil ) messages.append(systemMessage) + trimMessagesIfNeeded() // Force UI update objectWillChange.send() From b51000939bec11f92eecb4460f000b27c8958969 Mon Sep 17 00:00:00 2001 From: jack Date: Thu, 24 Jul 2025 13:16:47 +0200 Subject: [PATCH 13/21] Remove RSSI-based sender name coloring in chat messages - Simplified getSenderColor to always return primary color - Removed RSSI color calculation logic from formatMessageAsText - Removed RSSI color calculation logic from formatMessage - All sender names now use consistent green color - RSSI information remains visible only in peer list --- bitchat/ViewModels/ChatViewModel.swift | 44 ++++---------------------- 1 file changed, 6 insertions(+), 38 deletions(-) diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 8c657eda..aa1e6611 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -882,16 +882,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 } @@ -989,20 +981,8 @@ 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 + // Use consistent color for all senders + senderStyle.foregroundColor = primaryColor senderStyle.font = .system(size: 14, weight: .medium, design: .monospaced) result.append(sender.mergingAttributes(senderStyle)) @@ -1122,20 +1102,8 @@ class ChatViewModel: ObservableObject { let sender = AttributedString("<\(message.sender)> ") 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 + // Use consistent color for all senders + senderStyle.foregroundColor = primaryColor senderStyle.font = .system(size: 12, weight: .medium, design: .monospaced) result.append(sender.mergingAttributes(senderStyle)) From 58876704c33d4e139f06571db9c2baee849a56ac Mon Sep 17 00:00:00 2001 From: jack Date: Thu, 24 Jul 2025 13:18:12 +0200 Subject: [PATCH 14/21] Bold user's own nickname in chat messages - Added font weight logic to formatMessageAsText and formatMessage - User's own @nickname appears bold (weight: .bold) - Other nicknames remain medium weight - Helps visually distinguish own messages from others --- bitchat/ViewModels/ChatViewModel.swift | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index aa1e6611..68f5ab0c 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -983,7 +983,9 @@ class ChatViewModel: ObservableObject { // Use consistent color for all senders senderStyle.foregroundColor = primaryColor - senderStyle.font = .system(size: 14, weight: .medium, design: .monospaced) + // 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 @@ -1104,7 +1106,9 @@ class ChatViewModel: ObservableObject { // Use consistent color for all senders senderStyle.foregroundColor = primaryColor - senderStyle.font = .system(size: 12, weight: .medium, design: .monospaced) + // 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)) From 8d64920f8ebaa47a0a0dcdd0decb83584772fcbd Mon Sep 17 00:00:00 2001 From: jack Date: Thu, 24 Jul 2025 13:23:31 +0200 Subject: [PATCH 15/21] Optimization 4: Batch message updates for better performance - Added message batching system with 100ms window - Created pendingMessages array for public messages - Created pendingPrivateMessages dictionary for private messages - Added scheduleBatchFlush() to consolidate updates - Batch all incoming messages from didReceiveMessage - Batch system messages (connect/disconnect/screenshot) - Keep immediate updates for user's own sent messages - Single UI update per batch instead of per message - Flush pending messages on app background/terminate - Clear timer on deinit This reduces UI updates when multiple messages arrive quickly --- bitchat/ViewModels/ChatViewModel.swift | 143 +++++++++++++++++++------ 1 file changed, 112 insertions(+), 31 deletions(-) diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 68f5ab0c..c7a39e74 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -19,6 +19,12 @@ class ChatViewModel: ObservableObject { @Published var messages: [BitchatMessage] = [] private let maxMessages = 1337 // Maximum messages before oldest are removed @Published var connectedPeers: [String] = [] + + // 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 @@ -148,6 +154,9 @@ class ChatViewModel: ObservableObject { } deinit { + // Clean up timer + messageBatchTimer?.invalidate() + // Force immediate save userDefaults.synchronize() } @@ -378,10 +387,13 @@ 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) } @@ -433,7 +445,7 @@ class ChatViewModel: ObservableObject { 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 @@ -583,16 +595,21 @@ class ChatViewModel: ObservableObject { timestamp: Date(), isRelay: false ) - messages.append(localNotification) - trimMessagesIfNeeded() + // 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() @@ -604,6 +621,9 @@ class ChatViewModel: ObservableObject { } @objc private func appWillTerminate() { + // Flush any pending messages immediately + flushMessageBatchImmediately() + userDefaults.synchronize() } @@ -707,6 +727,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() @@ -1280,6 +1303,82 @@ class ChatViewModel: ObservableObject { } } + // 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 private func updateEncryptionStatus(for peerID: String) { let noiseService = meshService.getNoiseService() @@ -1897,16 +1996,11 @@ extension ChatViewModel: BitchatDelegate { ) } - privateChats[peerID]?.append(messageToStore) - // Sort messages by timestamp to ensure proper ordering - privateChats[peerID]?.sort { $0.timestamp < $1.timestamp } - trimPrivateChatMessagesIfNeeded(for: peerID) + // 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, @@ -1974,10 +2068,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 } - trimMessagesIfNeeded() + addMessageToBatch(finalMessage) } } else if finalMessage.sender != "system" { // Our own message - check if we already have it (by ID and content) @@ -1998,17 +2089,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 } - trimMessagesIfNeeded() + 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 } - trimMessagesIfNeeded() + addMessageToBatch(finalMessage) } } } @@ -2134,11 +2221,8 @@ extension ChatViewModel: BitchatDelegate { isRelay: false, originalSender: nil ) - messages.append(systemMessage) - trimMessagesIfNeeded() - - // Force UI update - objectWillChange.send() + // Batch system messages + addMessageToBatch(systemMessage) } func didDisconnectFromPeer(_ peerID: String) { @@ -2169,11 +2253,8 @@ extension ChatViewModel: BitchatDelegate { isRelay: false, originalSender: nil ) - messages.append(systemMessage) - trimMessagesIfNeeded() - - // Force UI update - objectWillChange.send() + // Batch system messages + addMessageToBatch(systemMessage) } func didUpdatePeerList(_ peers: [String]) { From 354d3ff6ced44303145dcf1bd77cdbb1c5df28b9 Mon Sep 17 00:00:00 2001 From: jack Date: Thu, 24 Jul 2025 13:30:56 +0200 Subject: [PATCH 16/21] Optimization 5: Fix animation overhead - Removed redundant animation modifiers (multiple animations on same view) - Replaced spring animations with simpler easeInOut/easeOut curves - Spring animations are computationally expensive, now using 0.2s easeInOut - Added scroll throttling to prevent excessive animations on message updates - Scroll animations limited to once per 0.5 seconds - Removed withAnimation wrapper from scrollTo calls - Simplified drag gesture animations from spring to easeOut This reduces CPU usage during UI transitions and message updates --- bitchat/Views/ContentView.swift | 56 +++++++++++++++++++++------------ 1 file changed, 36 insertions(+), 20 deletions(-) diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index 7450e917..c47b2ca0 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -26,6 +26,8 @@ struct ContentView: View { @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? private var backgroundColor: Color { colorScheme == .dark ? Color.black : Color.white @@ -65,19 +67,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 @@ -86,7 +87,7 @@ 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 } @@ -101,15 +102,14 @@ struct ContentView: View { .transition(.move(edge: .trailing)) } .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 } } @@ -132,7 +132,7 @@ struct ContentView: View { Button("private message") { if let peerID = selectedMessageSenderID { viewModel.startPrivateChat(with: peerID) - withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + withAnimation(.easeInOut(duration: 0.2)) { showSidebar = false sidebarDragOffset = 0 } @@ -242,10 +242,19 @@ struct ContentView: View { } .onChange(of: viewModel.messages.count) { _ in if privatePeer == nil && !viewModel.messages.isEmpty { - withAnimation { - // Scroll to last message in windowed view - let lastId = viewModel.messages.suffix(100).last?.id - proxy.scrollTo(lastId, 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) + } } } } @@ -253,10 +262,17 @@ struct ContentView: View { if let peerID = privatePeer, let messages = viewModel.privateChats[peerID], !messages.isEmpty { - withAnimation { - // Scroll to last message in windowed view - let lastId = messages.suffix(100).last?.id - proxy.scrollTo(lastId, 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) + } } } } @@ -600,7 +616,7 @@ struct ContentView: View { .onTapGesture { if !isMe && peerNicknames[peerID] != nil { viewModel.startPrivateChat(with: peerID) - withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + withAnimation(.easeInOut(duration: 0.2)) { showSidebar = false sidebarDragOffset = 0 } @@ -647,7 +663,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 @@ -749,7 +765,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 } @@ -766,7 +782,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() } From bb6552f4a6bac7f1bd6ec4511315e9271659680b Mon Sep 17 00:00:00 2001 From: jack Date: Thu, 24 Jul 2025 13:34:21 +0200 Subject: [PATCH 17/21] Add more spacing between chat messages - Increased vertical padding from 1px to 2px per message - Total spacing between messages now 4px (was 2px) - Improves readability and visual separation --- bitchat/Views/ContentView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index c47b2ca0..ff3ad8d3 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -230,7 +230,7 @@ struct ContentView: View { } } .padding(.horizontal, 12) - .padding(.vertical, 1) + .padding(.vertical, 2) } } .padding(.vertical, 4) From 7120b116924c3f2804d5c9c4a655f0d523a2ba5e Mon Sep 17 00:00:00 2001 From: jack Date: Thu, 24 Jul 2025 13:40:34 +0200 Subject: [PATCH 18/21] Optimize autocomplete with debouncing - Add 150ms debounce to text field onChange handler - Prevents excessive autocomplete calculations during rapid typing - Add timer cleanup in onDisappear - Works for both main chat and private chat inputs --- bitchat/ViewModels/ChatViewModel.swift | 76 ++++++++++++++++++-------- bitchat/Views/ContentView.swift | 20 +++++-- 2 files changed, 70 insertions(+), 26 deletions(-) diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index c7a39e74..5da2a287 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -36,6 +36,11 @@ 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 @@ -837,49 +842,76 @@ class ChatViewModel: ObservableObject { } 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 + } } } diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index ff3ad8d3..d8ff3b1c 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -28,6 +28,7 @@ struct ContentView: View { @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 @@ -159,6 +160,11 @@ struct ContentView: View { Button("cancel", role: .cancel) {} } + .onDisappear { + // Clean up timers + scrollThrottleTimer?.invalidate() + autocompleteDebounceTimer?.invalidate() + } } private func messagesView(privatePeer: String?) -> some View { @@ -399,11 +405,17 @@ struct ContentView: View { .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 = [ From 2350c48e13648b71c6c3a79a1c371fbad035f97b Mon Sep 17 00:00:00 2001 From: jack Date: Thu, 24 Jul 2025 13:43:02 +0200 Subject: [PATCH 19/21] Reduce ForEach overhead with pre-computed data - Remove unnecessary Array() conversions in messages ForEach - Optimize URL ForEach to use indices instead of enumerated - Simplify autocomplete ForEach by removing unneeded enumeration - Pre-compute all peer display data outside ForEach for sidebar - Create PeerDisplayData struct to bundle peer properties - Move sorting and data preparation outside the render loop --- bitchat/Views/ContentView.swift | 94 ++++++++++++++++++--------------- 1 file changed, 52 insertions(+), 42 deletions(-) diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index d8ff3b1c..52989123 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -183,7 +183,7 @@ struct ContentView: View { // Implement windowing - show last 100 messages for performance let windowedMessages = messages.suffix(100) - ForEach(Array(windowedMessages), id: \.id) { message in + ForEach(windowedMessages, id: \.id) { message in VStack(alignment: .leading, spacing: 0) { // Check if current user is mentioned let _ = message.mentions?.contains(viewModel.nickname) ?? false @@ -215,7 +215,8 @@ struct ContentView: View { // Check for plain URLs let urls = message.content.extractURLs() if !urls.isEmpty { - ForEach(Array(urls.prefix(3).enumerated()), id: \.offset) { index, urlInfo in + ForEach(urls.prefix(3).indices, id: \.self) { index in + let urlInfo = urls[index] LinkPreviewView(url: urlInfo.url, title: nil) .padding(.top, 3) .padding(.horizontal, 1) @@ -305,7 +306,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) }) { @@ -539,39 +540,49 @@ 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 + struct PeerDisplayData: Identifiable { + let id: String + let displayName: String + let rssi: Int? + let isFavorite: Bool + let isMe: Bool + let hasUnreadMessages: Bool + let encryptionStatus: EncryptionStatus } - let name1 = peerNicknames[peer1] ?? "anon\(peer1.prefix(4))" - let name2 = peerNicknames[peer2] ?? "anon\(peer2.prefix(4))" - return name1 < name2 - } + 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 + } - 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)) @@ -585,49 +596,48 @@ 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 { - Text(displayName) + Text(peer.displayName) .font(.system(size: 14, design: .monospaced)) - .foregroundColor(peerNicknames[peerID] != nil ? textColor : secondaryTextColor) + .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 !isMe && peerNicknames[peerID] != nil { - viewModel.startPrivateChat(with: peerID) + if !peer.isMe && peerNicknames[peer.id] != nil { + viewModel.startPrivateChat(with: peer.id) withAnimation(.easeInOut(duration: 0.2)) { showSidebar = false sidebarDragOffset = 0 @@ -635,9 +645,9 @@ struct ContentView: View { } } .onTapGesture(count: 2) { - if !isMe { + if !peer.isMe { // Show fingerprint on double tap - viewModel.showFingerprint(for: peerID) + viewModel.showFingerprint(for: peer.id) } } } From c7f0a5ea02e637d8dffe910db0b8e5027755f74c Mon Sep 17 00:00:00 2001 From: jack Date: Thu, 24 Jul 2025 13:46:23 +0200 Subject: [PATCH 20/21] Implement lazy loading for heavy components - Add LazyLinkPreviewView wrapper to defer link preview loading - Only render sidebar content when visible or animating - Defer expensive computations until components are shown - Reduces initial render cost and memory usage --- bitchat/Views/ContentView.swift | 52 ++++++++++++++++++++++++++++----- 1 file changed, 44 insertions(+), 8 deletions(-) diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index 52989123..828bdd10 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -8,6 +8,31 @@ import SwiftUI +// 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 = "" @@ -94,13 +119,24 @@ struct ContentView: View { } } - 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(.easeInOut(duration: 0.25), value: showSidebar) @@ -217,7 +253,7 @@ struct ContentView: View { if !urls.isEmpty { ForEach(urls.prefix(3).indices, id: \.self) { index in let urlInfo = urls[index] - LinkPreviewView(url: urlInfo.url, title: nil) + LazyLinkPreviewView(url: urlInfo.url, title: nil) .padding(.top, 3) .padding(.horizontal, 1) .id("\(message.id)-\(urlInfo.url.absoluteString)") From cea0579bce549c2c5dd8726ebf2b36dc1e57aaec Mon Sep 17 00:00:00 2001 From: jack Date: Thu, 24 Jul 2025 13:47:44 +0200 Subject: [PATCH 21/21] Fix build error: move PeerDisplayData struct outside ViewBuilder - Move struct declaration to top level to avoid ViewBuilder error - Struct declarations cannot be inside ViewBuilder closures --- bitchat/Views/ContentView.swift | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index 828bdd10..7892e29d 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -8,6 +8,17 @@ 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 @@ -577,16 +588,6 @@ struct ContentView: View { let peersToShow: [String] = viewModel.connectedPeers // Pre-compute peer data outside ForEach to reduce overhead - struct PeerDisplayData: Identifiable { - let id: String - let displayName: String - let rssi: Int? - let isFavorite: Bool - let isMe: Bool - let hasUnreadMessages: Bool - let encryptionStatus: EncryptionStatus - } - let peerData = peersToShow.map { peerID in PeerDisplayData( id: peerID,