-
-
Notifications
You must be signed in to change notification settings - Fork 261
Add @mention support to messaging (compose autocomplete, tappable render, self-mention notification) #2044
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Add @mention support to messaging (compose autocomplete, tappable render, self-mention notification) #2044
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,125 @@ | ||
| // MentionParser.swift | ||
| // Meshtastic | ||
|
|
||
| import Foundation | ||
| import SwiftData | ||
|
|
||
| /// Parses and resolves `@!<hex-id>` mention tokens embedded in message text. | ||
| /// | ||
| /// ## Wire format | ||
| /// A mention is stored as `@!<hex-id>` where `<hex-id>` is the node's 32-bit numeric ID | ||
| /// encoded as exactly 8 lowercase hex digits (e.g. `@!deadbeef`). This matches the | ||
| /// cross-platform contract shared with Android and Web clients. | ||
| /// | ||
| /// ## Example | ||
| /// ``` | ||
| /// // Raw payload stored on the wire | ||
| /// "Hello @!deadbeef, how are you?" | ||
| /// | ||
| /// // After resolveMentions(in:context:): | ||
| /// "Hello [@Alice](meshtastic:///nodes?nodenum=3735928559), how are you?" | ||
| /// ``` | ||
| enum MentionParser { | ||
|
|
||
| /// Pattern matching a mention token: `@!` followed by exactly 8 lowercase hex digits. | ||
| private static let mentionRegex = try! NSRegularExpression(pattern: "@!([0-9a-f]{8})") | ||
|
|
||
| /// Returns all mention ranges found in `text`, paired with the 8-char hex node ID. | ||
| static func mentionRanges(in text: String) -> [(range: Range<String.Index>, hexId: String)] { | ||
| let nsRange = NSRange(text.startIndex..., in: text) | ||
| let matches = mentionRegex.matches(in: text, range: nsRange) | ||
| return matches.compactMap { match -> (Range<String.Index>, String)? in | ||
| guard | ||
| let fullRange = Range(match.range, in: text), | ||
| let captureRange = Range(match.range(at: 1), in: text) | ||
| else { return nil } | ||
| return (fullRange, String(text[captureRange])) | ||
| } | ||
| } | ||
|
|
||
| /// Converts an 8-char lowercase hex string to the corresponding Int64 node number, or `nil` | ||
| /// if the string is not valid hex. | ||
| static func nodeNum(from hexId: String) -> Int64? { | ||
| guard let value = UInt32(hexId, radix: 16) else { return nil } | ||
| return Int64(value) | ||
| } | ||
|
|
||
| /// Detects the in-progress mention being composed at the end of `text`. | ||
| /// | ||
| /// Returns the partial query string (the characters typed after `@`) if the caret | ||
| /// appears to be inside an unresolved mention trigger (i.e., `@` not followed by `!` | ||
| /// and not interrupted by whitespace). Returns `nil` when no active trigger is found. | ||
| /// | ||
| /// Examples: | ||
| /// - `"Hello @"` → `""` | ||
| /// - `"Hello @Al"` → `"Al"` | ||
| /// - `"Hello @!deadbeef"` → `nil` (already resolved) | ||
| /// - `"Hello @Alice world"` → `nil` (space ended the trigger) | ||
| static func activeMentionQuery(in text: String) -> String? { | ||
| guard let atRange = text.range(of: "@", options: .backwards) else { return nil } | ||
| let afterAt = text[atRange.upperBound...] | ||
| // Already resolved — starts with "!" | ||
| guard !afterAt.hasPrefix("!") else { return nil } | ||
| // Mention ended by whitespace | ||
| guard !afterAt.contains(where: { $0.isWhitespace }) else { return nil } | ||
| return String(afterAt) | ||
| } | ||
|
|
||
| /// Replaces the active in-progress mention trigger at the end of `text` with the | ||
| /// resolved token `@!<hexId>` (e.g. `@!deadbeef`) for the chosen node. | ||
| /// | ||
| /// If no active trigger is found (see `activeMentionQuery`) the text is returned unchanged. | ||
| static func insertMentionToken(into text: String, user: UserEntity) -> String { | ||
| guard let atRange = text.range(of: "@", options: .backwards) else { return text } | ||
| let afterAt = text[atRange.upperBound...] | ||
| guard !afterAt.hasPrefix("!"), !afterAt.contains(where: { $0.isWhitespace }) else { | ||
| return text | ||
| } | ||
| let token = "@" + user.num.toHex() // e.g. "@!deadbeef" | ||
| return String(text[..<atRange.lowerBound]) + token | ||
| } | ||
|
|
||
| /// Returns `true` when `text` contains a mention of the node identified by `nodeNum`. | ||
| /// | ||
| /// Used by the notification layer to detect self-mentions in channel broadcasts. | ||
| static func containsMention(of nodeNum: Int64, in text: String) -> Bool { | ||
| // "@" + toHex() gives "@!deadbeef" — the exact wire format | ||
| return text.contains("@" + nodeNum.toHex()) | ||
| } | ||
|
|
||
| /// Replaces all `@!<hex>` mention tokens in `text` with markdown links of the form | ||
| /// `[@DisplayName](meshtastic:///nodes?nodenum=<num>)`. | ||
| /// | ||
| /// The display name is resolved live from the SwiftData `context` so name changes are | ||
| /// reflected immediately without re-sending the message. Tokens for unknown nodes fall | ||
| /// back to `@!<hexId>` unchanged. | ||
| /// | ||
| /// Processes tokens in reverse order to preserve string indices after replacement. | ||
| static func resolveMentions(in text: String, context: ModelContext) -> String { | ||
| let ranges = mentionRanges(in: text) | ||
| guard !ranges.isEmpty else { return text } | ||
|
|
||
| var result = text | ||
| for (range, hexId) in ranges.reversed() { | ||
| guard let num = nodeNum(from: hexId) else { continue } | ||
| let displayName = resolveDisplayName(nodeNum: num, context: context) | ||
| let link = "[@\(displayName)](meshtastic:///nodes?nodenum=\(num))" | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| result.replaceSubrange(range, with: link) | ||
| } | ||
| return result | ||
| } | ||
|
|
||
| // MARK: - Private helpers | ||
|
|
||
| private static func resolveDisplayName(nodeNum: Int64, context: ModelContext) -> String { | ||
| var descriptor = FetchDescriptor<UserEntity>( | ||
| predicate: #Predicate<UserEntity> { $0.num == nodeNum } | ||
| ) | ||
| descriptor.fetchLimit = 1 | ||
| if let user = (try? context.fetch(descriptor))?.first { | ||
| if let name = user.longName, !name.isEmpty { return name } | ||
| if let name = user.shortName, !name.isEmpty { return name } | ||
| } | ||
| return "!\(String(format: "%08x", UInt32(bitPattern: Int32(truncatingIfNeeded: nodeNum))))" | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,91 @@ | ||
| // MentionAutocomplete.swift | ||
| // Meshtastic | ||
|
|
||
| import SwiftData | ||
| import SwiftUI | ||
|
|
||
| /// A compact autocomplete panel shown when the user types `@` in the compose field. | ||
| /// | ||
| /// Displays up to 10 known nodes filtered by the current query (matched against long name, | ||
| /// short name, and user ID). Tapping a row calls `onSelect` with the chosen `UserEntity`, | ||
| /// which the caller inserts as `@!<hex-id>` into the message text. | ||
| struct MentionAutocomplete: View { | ||
|
|
||
| @Query(sort: \NodeInfoEntity.num) private var nodes: [NodeInfoEntity] | ||
|
|
||
| let query: String | ||
| let onSelect: (UserEntity) -> Void | ||
|
|
||
| private var filteredNodes: [NodeInfoEntity] { | ||
| let trimmed = query.lowercased() | ||
| let candidates = trimmed.isEmpty | ||
| ? nodes | ||
| : nodes.filter { node in | ||
| let user = node.user | ||
| let longName = (user?.longName ?? "").lowercased() | ||
| let shortName = (user?.shortName ?? "").lowercased() | ||
| let userId = (user?.userId ?? "").lowercased() | ||
| return longName.contains(trimmed) | ||
| || shortName.contains(trimmed) | ||
| || userId.contains(trimmed) | ||
| } | ||
| return Array(candidates.prefix(10)) | ||
| } | ||
|
|
||
| var body: some View { | ||
| if !filteredNodes.isEmpty { | ||
| VStack(spacing: 0) { | ||
| Divider() | ||
| ScrollView(.vertical, showsIndicators: false) { | ||
| LazyVStack(spacing: 0) { | ||
| ForEach(filteredNodes, id: \.num) { node in | ||
| if let user = node.user { | ||
| Button { | ||
| onSelect(user) | ||
| } label: { | ||
| MentionAutocompleteRow(node: node) | ||
| } | ||
| .buttonStyle(.plain) | ||
| Divider() | ||
| .padding(.leading, 56) | ||
| } | ||
| } | ||
| } | ||
| } | ||
| .frame(maxHeight: 200) | ||
| .background(Color(.systemBackground)) | ||
| } | ||
| .transition(.opacity.combined(with: .move(edge: .bottom))) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // MARK: - MentionAutocompleteRow | ||
|
|
||
| private struct MentionAutocompleteRow: View { | ||
| let node: NodeInfoEntity | ||
|
|
||
| var body: some View { | ||
| HStack(spacing: 12) { | ||
| CircleText( | ||
| text: node.user?.shortName ?? "?", | ||
| color: Color(UIColor(hex: UInt32(node.num))), | ||
| circleSize: 36 | ||
| ) | ||
| VStack(alignment: .leading, spacing: 2) { | ||
| Text(node.user?.longName ?? "Unknown") | ||
| .font(.body) | ||
| .foregroundStyle(.primary) | ||
| if let userId = node.user?.userId { | ||
| Text(userId) | ||
| .font(.caption) | ||
| .foregroundStyle(.secondary) | ||
| } | ||
| } | ||
| Spacer() | ||
| } | ||
| .padding(.horizontal, 12) | ||
| .padding(.vertical, 8) | ||
| .contentShape(Rectangle()) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -18,6 +18,7 @@ struct TextMessageField: View { | |
| @State private var typingMessage: String = "" | ||
| @State private var totalBytes = 0 | ||
| @State private var sendPositionWithMessage = false | ||
| @State private var mentionQuery: String? = nil | ||
|
|
||
| var body: some View { | ||
| if #available(iOS 18.0, macOS 15.0, *) { | ||
|
|
@@ -33,6 +34,14 @@ struct TextMessageField: View { | |
| ) | ||
| } else { | ||
| VStack(spacing: 0) { | ||
| if let query = mentionQuery { | ||
| MentionAutocomplete(query: query) { user in | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This PR currently does not compile. Running |
||
| withAnimation(.easeInOut(duration: 0.15)) { | ||
| typingMessage = MentionParser.insertMentionToken(into: typingMessage, user: user) | ||
| mentionQuery = nil | ||
| } | ||
| } | ||
| } | ||
| HStack(alignment: .top) { | ||
| if replyMessageId != 0 || isFocused { | ||
| Button { | ||
|
|
@@ -65,6 +74,7 @@ struct TextMessageField: View { | |
| typingMessage = String(typingMessage.dropLast()) | ||
| totalBytes = typingMessage.utf8.count | ||
| } | ||
| mentionQuery = MentionParser.activeMentionQuery(in: value) | ||
| } | ||
| .keyboardType(.default) | ||
| .focused($isFocused) | ||
|
|
@@ -181,10 +191,20 @@ private struct FormattingComposeArea: View { | |
| @State private var textSelection: TextSelection? | ||
| @State private var showToolbar = false | ||
| @State private var showLinkSheet = false | ||
| @State private var mentionQuery: String? = nil | ||
|
|
||
| var body: some View { | ||
| VStack(spacing: 0) { | ||
| MessagePreview(text: typingMessage) | ||
| if let query = mentionQuery { | ||
| MentionAutocomplete(query: query) { user in | ||
| withAnimation(.easeInOut(duration: 0.15)) { | ||
| textSelection = nil | ||
| typingMessage = MentionParser.insertMentionToken(into: typingMessage, user: user) | ||
| mentionQuery = nil | ||
| } | ||
| } | ||
| } | ||
| HStack(alignment: .top) { | ||
| if replyMessageId != 0 || isFocused { | ||
| Button { | ||
|
|
@@ -228,6 +248,7 @@ private struct FormattingComposeArea: View { | |
| totalBytes = typingMessage.utf8.count | ||
| textSelection = nil | ||
| } | ||
| mentionQuery = MentionParser.activeMentionQuery(in: value) | ||
| } | ||
| .keyboardType(.default) | ||
| .focused($isFocused) | ||
|
|
@@ -287,16 +308,19 @@ private struct FormattingComposeArea: View { | |
| // text/selection pair where the selection's UTF-16 range is out of bounds. (#1976) | ||
| private func send() { | ||
| textSelection = nil | ||
| mentionQuery = nil | ||
| onSend() | ||
| } | ||
|
|
||
| private func alert() { | ||
| textSelection = nil | ||
| mentionQuery = nil | ||
| onAlert() | ||
| } | ||
|
|
||
| private func requestPosition() { | ||
| textSelection = nil | ||
| mentionQuery = nil | ||
| onRequestPosition() | ||
| } | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This regex still matches the prefix of a longer token, e.g.
@!deadbeef00is treated as a mention of@!deadbeef, even though the documented wire format above says exactly 8 lowercase hex digits. The same prefix problem exists incontainsMentionviatext.contains, so a malformed longer token can trigger a self-mention notification for the wrong node. Please require a non-hex/token boundary after the eighth digit, or reuse parsed bounded ranges for notification detection, and update thetooLongHex_matchesFirst8test to reject this case.