mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 06:45:18 +00:00
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:
@@ -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..<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(
|
||||
sender: sender,
|
||||
content: content,
|
||||
@@ -353,7 +388,8 @@ extension BitchatMessage {
|
||||
originalSender: originalSender,
|
||||
isPrivate: isPrivate,
|
||||
recipientNickname: recipientNickname,
|
||||
senderPeerID: senderPeerID
|
||||
senderPeerID: senderPeerID,
|
||||
mentions: mentions
|
||||
)
|
||||
return message
|
||||
}
|
||||
|
||||
@@ -68,8 +68,9 @@ struct BitchatMessage: Codable, Equatable {
|
||||
let isPrivate: Bool
|
||||
let recipientNickname: 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.sender = sender
|
||||
self.content = content
|
||||
@@ -79,6 +80,7 @@ struct BitchatMessage: Codable, Equatable {
|
||||
self.isPrivate = isPrivate
|
||||
self.recipientNickname = recipientNickname
|
||||
self.senderPeerID = senderPeerID
|
||||
self.mentions = mentions
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -150,7 +150,7 @@ class BluetoothMeshService: NSObject {
|
||||
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
|
||||
guard let self = self else { return }
|
||||
|
||||
@@ -162,7 +162,8 @@ class BluetoothMeshService: NSObject {
|
||||
content: content,
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
originalSender: nil
|
||||
originalSender: nil,
|
||||
mentions: mentions.isEmpty ? nil : mentions
|
||||
)
|
||||
|
||||
if let messageData = message.toBinaryPayload() {
|
||||
|
||||
@@ -20,6 +20,10 @@ class ChatViewModel: ObservableObject {
|
||||
@Published var privateChats: [String: [BitchatMessage]] = [:] // peerID -> messages
|
||||
@Published var selectedPrivateChatPeer: String? = nil
|
||||
@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
|
||||
|
||||
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..<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 {
|
||||
let relay = AttributedString(" (via \(originalSender))")
|
||||
@@ -259,8 +371,23 @@ extension ChatViewModel: BitchatDelegate {
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
// Different haptic feedback for private vs public messages
|
||||
if message.isPrivate && message.sender != nickname {
|
||||
// Check if we're mentioned
|
||||
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
|
||||
let impactFeedback = UIImpactFeedbackGenerator(style: .heavy)
|
||||
impactFeedback.prepare()
|
||||
@@ -322,4 +449,26 @@ extension ChatViewModel: BitchatDelegate {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user