From d142fde11d3a07dd380dd06d12208f2f2e2721cd Mon Sep 17 00:00:00 2001 From: jack Date: Thu, 3 Jul 2025 01:08:11 +0200 Subject: [PATCH] Add @ autocomplete functionality - Added autocomplete state tracking in ChatViewModel - Implemented updateAutocomplete() to parse @ mentions and show suggestions - Added completeNickname() to handle nickname completion - Created autocomplete UI overlay above input field - Shows list of matching nicknames when typing @ - Highlights selected suggestion - Clicking suggestion completes the mention --- bitchat/Protocols/BinaryProtocol.swift | 38 ++++- bitchat/Protocols/BitchatProtocol.swift | 4 +- bitchat/Services/BluetoothMeshService.swift | 5 +- bitchat/ViewModels/ChatViewModel.swift | 169 ++++++++++++++++++-- bitchat/Views/ContentView.swift | 52 +++++- 5 files changed, 251 insertions(+), 17 deletions(-) diff --git a/bitchat/Protocols/BinaryProtocol.swift b/bitchat/Protocols/BinaryProtocol.swift index 1f9f843b..2971ed6d 100644 --- a/bitchat/Protocols/BinaryProtocol.swift +++ b/bitchat/Protocols/BinaryProtocol.swift @@ -191,6 +191,7 @@ extension BitchatMessage { if originalSender != nil { flags |= 0x04 } if recipientNickname != nil { flags |= 0x08 } if senderPeerID != nil { flags |= 0x10 } + if mentions != nil && !mentions!.isEmpty { flags |= 0x20 } data.append(flags) @@ -244,6 +245,19 @@ extension BitchatMessage { data.append(peerData.prefix(255)) } + // Mentions array + if let mentions = mentions { + data.append(UInt8(min(mentions.count, 255))) // Number of mentions + for mention in mentions.prefix(255) { + if let mentionData = mention.data(using: .utf8) { + data.append(UInt8(min(mentionData.count, 255))) + data.append(mentionData.prefix(255)) + } else { + data.append(0) + } + } + } + return data } @@ -268,6 +282,7 @@ extension BitchatMessage { let hasOriginalSender = (flags & 0x04) != 0 let hasRecipientNickname = (flags & 0x08) != 0 let hasSenderPeerID = (flags & 0x10) != 0 + let hasMentions = (flags & 0x20) != 0 // Timestamp guard offset + 8 <= dataCopy.count else { @@ -345,6 +360,26 @@ extension BitchatMessage { } } + // Mentions array + var mentions: [String]? + if hasMentions && offset < dataCopy.count { + let mentionCount = Int(dataCopy[offset]); offset += 1 + if mentionCount > 0 { + mentions = [] + for _ in 0.. messages @Published var selectedPrivateChatPeer: String? = nil @Published var unreadPrivateMessages: Set = [] + @Published var autocompleteSuggestions: [String] = [] + @Published var showAutocomplete: Bool = false + @Published var autocompleteRange: NSRange? = nil + @Published var selectedAutocompleteIndex: Int = 0 @Published var privateMessageNotification: (sender: String, message: String)? = nil let meshService = BluetoothMeshService() @@ -57,18 +61,22 @@ class ChatViewModel: ObservableObject { // Send as private message sendPrivateMessage(content, to: selectedPeer) } else { + // Parse mentions from the content + let mentions = parseMentions(from: content) + // Add message to local display let message = BitchatMessage( sender: nickname, content: content, timestamp: Date(), isRelay: false, - originalSender: nil + originalSender: nil, + mentions: mentions.isEmpty ? nil : mentions ) messages.append(message) - // Send via mesh - meshService.sendMessage(content) + // Send via mesh with mentions + meshService.sendMessage(content, mentions: mentions) } } @@ -165,6 +173,72 @@ class ChatViewModel: ObservableObject { } } + func updateAutocomplete(for text: String, cursorPosition: Int) { + // 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: []), + let match = regex.firstMatch(in: beforeCursor, options: [], range: NSRange(location: 0, length: beforeCursor.count)) else { + 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 + return + } + + let partial = String(beforeCursor[range]).lowercased() + + // Get all available nicknames + let peerNicknames = meshService.getPeerNicknames() + var allNicknames = Array(peerNicknames.values) + allNicknames.append(nickname) // Include self + + // Filter suggestions + let suggestions = allNicknames.filter { nick in + nick.lowercased().hasPrefix(partial) + }.sorted() + + if !suggestions.isEmpty { + autocompleteSuggestions = suggestions + showAutocomplete = true + autocompleteRange = match.range(at: 0) // Store full @mention range + selectedAutocompleteIndex = 0 + } else { + showAutocomplete = false + autocompleteSuggestions = [] + autocompleteRange = nil + selectedAutocompleteIndex = 0 + } + } + + func completeNickname(_ nickname: String, in text: inout String) -> Int { + guard let range = autocompleteRange else { return text.count } + + // Replace the @partial with @nickname + let nsText = text as NSString + let newText = nsText.replacingCharacters(in: range, with: "@\(nickname) ") + text = newText + + // Hide autocomplete + showAutocomplete = false + autocompleteSuggestions = [] + autocompleteRange = nil + selectedAutocompleteIndex = 0 + + // Return new cursor position (after the space) + return range.location + nickname.count + 2 + } + func formatMessage(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString { var result = AttributedString() @@ -203,11 +277,49 @@ class ChatViewModel: ObservableObject { senderStyle.font = .system(size: 12, weight: .medium, design: .monospaced) result.append(sender.mergingAttributes(senderStyle)) - let content = AttributedString(message.content) - var contentStyle = AttributeContainer() - contentStyle.font = .system(size: 14, design: .monospaced) - contentStyle.foregroundColor = isDark ? Color.white : Color.black - result.append(content.mergingAttributes(contentStyle)) + // Process content to highlight mentions + let contentText = message.content + var processedContent = AttributedString() + + // Regular expression to find @mentions + let pattern = "@([a-zA-Z0-9_]+)" + let regex = try? NSRegularExpression(pattern: pattern, options: []) + let matches = regex?.matches(in: contentText, options: [], range: NSRange(location: 0, length: contentText.count)) ?? [] + + var lastEndIndex = contentText.startIndex + + for match in matches { + // Add text before the mention + if let range = Range(match.range(at: 0), in: contentText) { + let beforeText = String(contentText[lastEndIndex.. [String] { + let pattern = "@([a-zA-Z0-9_]+)" + let regex = try? NSRegularExpression(pattern: pattern, options: []) + let matches = regex?.matches(in: content, options: [], range: NSRange(location: 0, length: content.count)) ?? [] + + var mentions: [String] = [] + let peerNicknames = meshService.getPeerNicknames() + let allNicknames = Set(peerNicknames.values).union([nickname]) // Include self + + for match in matches { + if let range = Range(match.range(at: 1), in: content) { + let mentionedName = String(content[range]) + // Only include if it's a valid nickname + if allNicknames.contains(mentionedName) { + mentions.append(mentionedName) + } + } + } + + return Array(Set(mentions)) // Remove duplicates + } } \ No newline at end of file diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index 8a1f9d6e..2c1ab0ef 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -3,6 +3,7 @@ import SwiftUI struct ContentView: View { @EnvironmentObject var viewModel: ChatViewModel @State private var messageText = "" + @State private var textFieldSelection: NSRange? = nil @FocusState private var isTextFieldFocused: Bool @Environment(\.colorScheme) var colorScheme @State private var showPeerList = false @@ -224,8 +225,12 @@ struct ContentView: View { ForEach(Array(messages.enumerated()), id: \.offset) { index, message in VStack(alignment: .leading, spacing: 4) { + // Check if current user is mentioned + let isMentioned = message.mentions?.contains(viewModel.nickname) ?? false + Text(viewModel.formatMessage(message, colorScheme: colorScheme)) .font(.system(size: 14, design: .monospaced)) + .fontWeight(isMentioned ? .bold : .regular) .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: .infinity, alignment: .leading) @@ -258,7 +263,42 @@ struct ContentView: View { } private var inputView: some View { - HStack(spacing: 4) { + ZStack(alignment: .bottom) { + VStack(spacing: 0) { + // Autocomplete suggestions overlay + if viewModel.showAutocomplete && !viewModel.autocompleteSuggestions.isEmpty { + VStack(alignment: .leading, spacing: 0) { + ForEach(Array(viewModel.autocompleteSuggestions.enumerated()), id: \.element) { index, suggestion in + Button(action: { + _ = viewModel.completeNickname(suggestion, in: &messageText) + }) { + HStack { + Text("@\(suggestion)") + .font(.system(size: 12, design: .monospaced)) + .foregroundColor(index == viewModel.selectedAutocompleteIndex ? backgroundColor : textColor) + Spacer() + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(index == viewModel.selectedAutocompleteIndex ? textColor : Color.clear) + } + .buttonStyle(.plain) + } + } + .background(backgroundColor) + .overlay( + RoundedRectangle(cornerRadius: 4) + .stroke(secondaryTextColor.opacity(0.5), lineWidth: 1) + ) + .frame(maxWidth: 200, alignment: .leading) + .padding(.leading, 100) // Align with input field + .padding(.bottom, 4) + } + + Spacer() + } + + HStack(spacing: 4) { Text("[\(viewModel.formatTimestamp(Date()))]") .font(.system(size: 12, design: .monospaced)) .foregroundColor(secondaryTextColor) @@ -285,6 +325,11 @@ struct ContentView: View { .font(.system(size: 14, design: .monospaced)) .foregroundColor(textColor) .focused($isTextFieldFocused) + .onChange(of: messageText) { newValue in + // Get cursor position (approximate - end of text for now) + let cursorPosition = newValue.count + viewModel.updateAutocomplete(for: newValue, cursorPosition: cursorPosition) + } .onSubmit { sendMessage() } @@ -296,9 +341,10 @@ struct ContentView: View { } .buttonStyle(.plain) .padding(.trailing, 12) + } + .padding(.vertical, 10) + .background(backgroundColor.opacity(0.95)) } - .padding(.vertical, 10) - .background(backgroundColor.opacity(0.95)) .onAppear { isTextFieldFocused = true }