From 7120b116924c3f2804d5c9c4a655f0d523a2ba5e Mon Sep 17 00:00:00 2001 From: jack Date: Thu, 24 Jul 2025 13:40:34 +0200 Subject: [PATCH] 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 = [