Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 125 additions & 0 deletions Meshtastic/Helpers/MentionParser.swift
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})")

Copy link
Copy Markdown
Member

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. @!deadbeef00 is treated as a mention of @!deadbeef, even though the documented wire format above says exactly 8 lowercase hex digits. The same prefix problem exists in containsMention via text.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 the tooLongHex_matchesFirst8 test to reject this case.


/// 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))"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

displayName comes from mesh data, so it can contain Markdown control characters such as ], [, (, ), or \. Interpolating it directly into Markdown link text can make AttributedString(markdown:) fail or create unintended Markdown/link structure for every message that mentions that node. Please escape the link text before interpolation, or build the attributed link without round-tripping through Markdown.

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))))"
}
}
5 changes: 4 additions & 1 deletion Meshtastic/Helpers/MeshPackets.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1382,7 +1382,10 @@ actor MeshPackets {
// to ~1/sec — the badge tolerates brief lag and resyncs on app-active and on read.
let recountUnread = shouldRecomputeChannelUnread()
let connectedNodeNum = connectedNode
let shouldNotify = UserDefaults.channelMessageNotifications && newMessage.isEmoji == false && myInfo.channels.contains(where: { $0.index == newMessage.channel && !$0.mute })
// Notify if channel notifications are on and the channel isn't muted,
// OR if this message @mentions the local node (self-mention always notifies).
let isSelfMentioned = MentionParser.containsMention(of: connectedNode, in: messageText ?? "")
let shouldNotify = newMessage.isEmoji == false && (isSelfMentioned || (UserDefaults.channelMessageNotifications && myInfo.channels.contains(where: { $0.index == newMessage.channel && !$0.mute })))
var channelNotification: Notification?
if shouldNotify {
var chNotification = Notification(
Expand Down
11 changes: 11 additions & 0 deletions Meshtastic/Resources/docs/user/messages.html
Original file line number Diff line number Diff line change
Expand Up @@ -292,5 +292,16 @@ <h3>Mac Catalyst</h3>
<p>On Mac Catalyst, pressing <strong>Enter</strong> sends the message. Press <strong>Shift+Enter</strong> to insert a line break. The character palette button remains available alongside the formatting buttons.</p>
<div class="tips-callout"><strong>Tip — Message Limit</strong>
Messages are limited to 200 bytes. Markdown delimiters count toward this limit (e.g., <code>**bold**</code> uses 4 extra bytes for the <code>**</code> pairs). The byte counter in the toolbar shows remaining space.</div>
<hr />
<h2>@Mentions</h2>
<p>You can mention a specific node in any message by typing <code>@</code> followed by a name. An autocomplete list of known nodes appears, filterable by long name, short name, or user ID. Tap a row to insert the mention.</p>
<h3>How mentions work</h3>
<p>When you select a node from the autocomplete list, the app inserts a mention token (<code>@!&lt;hex-id&gt;</code>) into the message. The hex ID is the node's numeric identifier, which stays stable even if the node's display name changes later. This format is shared with Android and Web clients so mentions resolve correctly on all platforms.</p>
<h3>Reading mentions</h3>
<p>In received messages, mention tokens are automatically resolved to the node's current display name and rendered as tappable links. Tapping a mention navigates to that node's detail screen. The name updates live — if a node renames itself, existing messages show the new name immediately.</p>
<h3>Mention notifications</h3>
<p>If a channel message mentions your own node (even on a muted channel), the app delivers a notification for that message. This ensures you are notified when someone addresses you directly in a group conversation.</p>
<div class="tips-callout"><strong>Tip — Mention format</strong>
The mention token sent on the wire is <code>@!&lt;hex-id&gt;</code> (e.g. <code>@!deadbeef</code>). You can also type this token directly if you know the node's hex ID.</div>
</body>
</html>
15 changes: 14 additions & 1 deletion Meshtastic/Views/Messages/MessageText.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import Translation
struct MessageText: View {
@Environment(\.modelContext) private var context
@EnvironmentObject var accessoryManager: AccessoryManager
@EnvironmentObject var appState: AppState

let message: MessageEntity
let tapBackDestination: MessageDestination
Expand Down Expand Up @@ -101,7 +102,10 @@ struct MessageText: View {
}

private var baseMessageContent: some View {
let payload = message.displayedMarkdownPayload
let payload = MentionParser.resolveMentions(
in: message.displayedMarkdownPayload,
context: context
)
return Group {
if let attributed = try? AttributedString(markdown: payload, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)) {
Text(underlineLinks(in: attributed))
Expand Down Expand Up @@ -193,6 +197,15 @@ struct MessageText: View {

private func handleURL(_ url: URL) -> OpenURLAction.Result {
saveChannelLink = nil
// Handle meshtastic:/// deep links — node navigation from @mention taps
if url.scheme == "meshtastic",
let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
components.path == "/nodes",
let nodeNumStr = components.queryItems?.first(where: { $0.name == "nodenum" })?.value,
let nodeNum = Int64(nodeNumStr) {
appState.router.navigateToNodeDetail(nodeNum: nodeNum)
return .handled
}
var addChannels = false
if url.absoluteString.lowercased().contains("meshtastic.org/v/#") {
// Handle contact URL
Expand Down
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())
}
}
24 changes: 24 additions & 0 deletions Meshtastic/Views/Messages/TextMessageField/TextMessageField.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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, *) {
Expand All @@ -33,6 +34,14 @@ struct TextMessageField: View {
)
} else {
VStack(spacing: 0) {
if let query = mentionQuery {
MentionAutocomplete(query: query) { user in

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR currently does not compile. Running xcodebuild test -project Meshtastic.xcodeproj -scheme Meshtastic -destination 'platform=iOS Simulator,name=iPhone 17' -only-testing:MeshtasticTests/MentionParserTests fails with Cannot find 'MentionAutocomplete' in scope and Cannot find 'MentionParser' in scope. I also checked Meshtastic.xcodeproj/project.pbxproj and it has no references to MentionAutocomplete, MentionParser, or MentionParserTests, so the new source/test files are never added to the Xcode targets. Please add these files to the Meshtastic and MeshtasticTests targets; otherwise the app target fails before the mention tests can run.

withAnimation(.easeInOut(duration: 0.15)) {
typingMessage = MentionParser.insertMentionToken(into: typingMessage, user: user)
mentionQuery = nil
}
}
}
HStack(alignment: .top) {
if replyMessageId != 0 || isFocused {
Button {
Expand Down Expand Up @@ -65,6 +74,7 @@ struct TextMessageField: View {
typingMessage = String(typingMessage.dropLast())
totalBytes = typingMessage.utf8.count
}
mentionQuery = MentionParser.activeMentionQuery(in: value)
}
.keyboardType(.default)
.focused($isFocused)
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -228,6 +248,7 @@ private struct FormattingComposeArea: View {
totalBytes = typingMessage.utf8.count
textSelection = nil
}
mentionQuery = MentionParser.activeMentionQuery(in: value)
}
.keyboardType(.default)
.focused($isFocused)
Expand Down Expand Up @@ -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()
}

Expand Down
Loading