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
This commit is contained in:
jack
2025-07-03 01:08:11 +02:00
parent 4b98aa7918
commit d142fde11d
5 changed files with 251 additions and 17 deletions
+37 -1
View File
@@ -191,6 +191,7 @@ extension BitchatMessage {
if originalSender != nil { flags |= 0x04 } if originalSender != nil { flags |= 0x04 }
if recipientNickname != nil { flags |= 0x08 } if recipientNickname != nil { flags |= 0x08 }
if senderPeerID != nil { flags |= 0x10 } if senderPeerID != nil { flags |= 0x10 }
if mentions != nil && !mentions!.isEmpty { flags |= 0x20 }
data.append(flags) data.append(flags)
@@ -244,6 +245,19 @@ extension BitchatMessage {
data.append(peerData.prefix(255)) 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 return data
} }
@@ -268,6 +282,7 @@ extension BitchatMessage {
let hasOriginalSender = (flags & 0x04) != 0 let hasOriginalSender = (flags & 0x04) != 0
let hasRecipientNickname = (flags & 0x08) != 0 let hasRecipientNickname = (flags & 0x08) != 0
let hasSenderPeerID = (flags & 0x10) != 0 let hasSenderPeerID = (flags & 0x10) != 0
let hasMentions = (flags & 0x20) != 0
// Timestamp // Timestamp
guard offset + 8 <= dataCopy.count else { 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..<mentionCount {
if offset < dataCopy.count {
let length = Int(dataCopy[offset]); offset += 1
if offset + length <= dataCopy.count {
if let mention = String(data: dataCopy[offset..<offset+length], encoding: .utf8) {
mentions?.append(mention)
}
offset += length
}
}
}
}
}
let message = BitchatMessage( let message = BitchatMessage(
sender: sender, sender: sender,
content: content, content: content,
@@ -353,7 +388,8 @@ extension BitchatMessage {
originalSender: originalSender, originalSender: originalSender,
isPrivate: isPrivate, isPrivate: isPrivate,
recipientNickname: recipientNickname, recipientNickname: recipientNickname,
senderPeerID: senderPeerID senderPeerID: senderPeerID,
mentions: mentions
) )
return message return message
} }
+3 -1
View File
@@ -68,8 +68,9 @@ struct BitchatMessage: Codable, Equatable {
let isPrivate: Bool let isPrivate: Bool
let recipientNickname: String? let recipientNickname: String?
let senderPeerID: String? let senderPeerID: String?
let mentions: [String]? // Array of mentioned nicknames
init(sender: String, content: String, timestamp: Date, isRelay: Bool, originalSender: String? = nil, isPrivate: Bool = false, recipientNickname: String? = nil, senderPeerID: String? = nil) { init(sender: String, content: String, timestamp: Date, isRelay: Bool, originalSender: String? = nil, isPrivate: Bool = false, recipientNickname: String? = nil, senderPeerID: String? = nil, mentions: [String]? = nil) {
self.id = UUID().uuidString self.id = UUID().uuidString
self.sender = sender self.sender = sender
self.content = content self.content = content
@@ -79,6 +80,7 @@ struct BitchatMessage: Codable, Equatable {
self.isPrivate = isPrivate self.isPrivate = isPrivate
self.recipientNickname = recipientNickname self.recipientNickname = recipientNickname
self.senderPeerID = senderPeerID self.senderPeerID = senderPeerID
self.mentions = mentions
} }
} }
+3 -2
View File
@@ -150,7 +150,7 @@ class BluetoothMeshService: NSObject {
self.characteristic = characteristic self.characteristic = characteristic
} }
func sendMessage(_ content: String, to recipientID: String? = nil) { func sendMessage(_ content: String, mentions: [String] = [], to recipientID: String? = nil) {
messageQueue.async { [weak self] in messageQueue.async { [weak self] in
guard let self = self else { return } guard let self = self else { return }
@@ -162,7 +162,8 @@ class BluetoothMeshService: NSObject {
content: content, content: content,
timestamp: Date(), timestamp: Date(),
isRelay: false, isRelay: false,
originalSender: nil originalSender: nil,
mentions: mentions.isEmpty ? nil : mentions
) )
if let messageData = message.toBinaryPayload() { if let messageData = message.toBinaryPayload() {
+159 -10
View File
@@ -20,6 +20,10 @@ class ChatViewModel: ObservableObject {
@Published var privateChats: [String: [BitchatMessage]] = [:] // peerID -> messages @Published var privateChats: [String: [BitchatMessage]] = [:] // peerID -> messages
@Published var selectedPrivateChatPeer: String? = nil @Published var selectedPrivateChatPeer: String? = nil
@Published var unreadPrivateMessages: Set<String> = [] @Published var unreadPrivateMessages: Set<String> = []
@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 @Published var privateMessageNotification: (sender: String, message: String)? = nil
let meshService = BluetoothMeshService() let meshService = BluetoothMeshService()
@@ -57,18 +61,22 @@ class ChatViewModel: ObservableObject {
// Send as private message // Send as private message
sendPrivateMessage(content, to: selectedPeer) sendPrivateMessage(content, to: selectedPeer)
} else { } else {
// Parse mentions from the content
let mentions = parseMentions(from: content)
// Add message to local display // Add message to local display
let message = BitchatMessage( let message = BitchatMessage(
sender: nickname, sender: nickname,
content: content, content: content,
timestamp: Date(), timestamp: Date(),
isRelay: false, isRelay: false,
originalSender: nil originalSender: nil,
mentions: mentions.isEmpty ? nil : mentions
) )
messages.append(message) messages.append(message)
// Send via mesh // Send via mesh with mentions
meshService.sendMessage(content) 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 { func formatMessage(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString {
var result = AttributedString() var result = AttributedString()
@@ -203,11 +277,49 @@ class ChatViewModel: ObservableObject {
senderStyle.font = .system(size: 12, weight: .medium, design: .monospaced) senderStyle.font = .system(size: 12, weight: .medium, design: .monospaced)
result.append(sender.mergingAttributes(senderStyle)) result.append(sender.mergingAttributes(senderStyle))
let content = AttributedString(message.content) // Process content to highlight mentions
var contentStyle = AttributeContainer() let contentText = message.content
contentStyle.font = .system(size: 14, design: .monospaced) var processedContent = AttributedString()
contentStyle.foregroundColor = isDark ? Color.white : Color.black
result.append(content.mergingAttributes(contentStyle)) // Regular expression to find @mentions
let pattern = "@([a-zA-Z0-9_]+)"
let regex = try? NSRegularExpression(pattern: pattern, options: [])
let matches = regex?.matches(in: contentText, options: [], range: NSRange(location: 0, length: contentText.count)) ?? []
var lastEndIndex = contentText.startIndex
for match in matches {
// Add text before the mention
if let range = Range(match.range(at: 0), in: contentText) {
let beforeText = String(contentText[lastEndIndex..<range.lowerBound])
if !beforeText.isEmpty {
var normalStyle = AttributeContainer()
normalStyle.font = .system(size: 14, design: .monospaced)
normalStyle.foregroundColor = isDark ? Color.white : Color.black
processedContent.append(AttributedString(beforeText).mergingAttributes(normalStyle))
}
// Add the mention with highlight
let mentionText = String(contentText[range])
var mentionStyle = AttributeContainer()
mentionStyle.font = .system(size: 14, weight: .semibold, design: .monospaced)
mentionStyle.foregroundColor = Color.orange
processedContent.append(AttributedString(mentionText).mergingAttributes(mentionStyle))
lastEndIndex = range.upperBound
}
}
// Add any remaining text
if lastEndIndex < contentText.endIndex {
let remainingText = String(contentText[lastEndIndex...])
var normalStyle = AttributeContainer()
normalStyle.font = .system(size: 14, design: .monospaced)
normalStyle.foregroundColor = isDark ? Color.white : Color.black
processedContent.append(AttributedString(remainingText).mergingAttributes(normalStyle))
}
result.append(processedContent)
if message.isRelay, let originalSender = message.originalSender { if message.isRelay, let originalSender = message.originalSender {
let relay = AttributedString(" (via \(originalSender))") let relay = AttributedString(" (via \(originalSender))")
@@ -259,8 +371,23 @@ extension ChatViewModel: BitchatDelegate {
} }
#if os(iOS) #if os(iOS)
// Different haptic feedback for private vs public messages // Check if we're mentioned
if message.isPrivate && message.sender != nickname { let isMentioned = message.mentions?.contains(nickname) ?? false
// Different haptic feedback for mentions, private messages, and regular messages
if isMentioned && message.sender != nickname {
// Very prominent haptic for @mentions - triple tap with heavy impact
let impactFeedback = UIImpactFeedbackGenerator(style: .heavy)
impactFeedback.prepare()
impactFeedback.impactOccurred()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
impactFeedback.impactOccurred()
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
impactFeedback.impactOccurred()
}
} else if message.isPrivate && message.sender != nickname {
// Heavy haptic for private messages - more pronounced // Heavy haptic for private messages - more pronounced
let impactFeedback = UIImpactFeedbackGenerator(style: .heavy) let impactFeedback = UIImpactFeedbackGenerator(style: .heavy)
impactFeedback.prepare() impactFeedback.prepare()
@@ -322,4 +449,26 @@ extension ChatViewModel: BitchatDelegate {
endPrivateChat() endPrivateChat()
} }
} }
private func parseMentions(from content: String) -> [String] {
let pattern = "@([a-zA-Z0-9_]+)"
let regex = try? NSRegularExpression(pattern: pattern, options: [])
let matches = regex?.matches(in: content, options: [], range: NSRange(location: 0, length: content.count)) ?? []
var mentions: [String] = []
let peerNicknames = meshService.getPeerNicknames()
let allNicknames = Set(peerNicknames.values).union([nickname]) // Include self
for match in matches {
if let range = Range(match.range(at: 1), in: content) {
let mentionedName = String(content[range])
// Only include if it's a valid nickname
if allNicknames.contains(mentionedName) {
mentions.append(mentionedName)
}
}
}
return Array(Set(mentions)) // Remove duplicates
}
} }
+49 -3
View File
@@ -3,6 +3,7 @@ import SwiftUI
struct ContentView: View { struct ContentView: View {
@EnvironmentObject var viewModel: ChatViewModel @EnvironmentObject var viewModel: ChatViewModel
@State private var messageText = "" @State private var messageText = ""
@State private var textFieldSelection: NSRange? = nil
@FocusState private var isTextFieldFocused: Bool @FocusState private var isTextFieldFocused: Bool
@Environment(\.colorScheme) var colorScheme @Environment(\.colorScheme) var colorScheme
@State private var showPeerList = false @State private var showPeerList = false
@@ -224,8 +225,12 @@ struct ContentView: View {
ForEach(Array(messages.enumerated()), id: \.offset) { index, message in ForEach(Array(messages.enumerated()), id: \.offset) { index, message in
VStack(alignment: .leading, spacing: 4) { 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)) Text(viewModel.formatMessage(message, colorScheme: colorScheme))
.font(.system(size: 14, design: .monospaced)) .font(.system(size: 14, design: .monospaced))
.fontWeight(isMentioned ? .bold : .regular)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
@@ -258,7 +263,42 @@ struct ContentView: View {
} }
private var inputView: some 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()))]") Text("[\(viewModel.formatTimestamp(Date()))]")
.font(.system(size: 12, design: .monospaced)) .font(.system(size: 12, design: .monospaced))
.foregroundColor(secondaryTextColor) .foregroundColor(secondaryTextColor)
@@ -285,6 +325,11 @@ struct ContentView: View {
.font(.system(size: 14, design: .monospaced)) .font(.system(size: 14, design: .monospaced))
.foregroundColor(textColor) .foregroundColor(textColor)
.focused($isTextFieldFocused) .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 { .onSubmit {
sendMessage() sendMessage()
} }
@@ -296,9 +341,10 @@ struct ContentView: View {
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.padding(.trailing, 12) .padding(.trailing, 12)
}
.padding(.vertical, 10)
.background(backgroundColor.opacity(0.95))
} }
.padding(.vertical, 10)
.background(backgroundColor.opacity(0.95))
.onAppear { .onAppear {
isTextFieldFocused = true isTextFieldFocused = true
} }