diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt
index 5037b00de..553127a64 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt
@@ -38,6 +38,7 @@ import org.meshtastic.core.model.destination
import org.meshtastic.core.model.isBroadcast
import org.meshtastic.core.model.isFromLocal
import org.meshtastic.core.model.source
+import org.meshtastic.core.model.textMentionsNode
import org.meshtastic.core.model.util.MeshDataMapper
import org.meshtastic.core.model.util.decodeOrNull
import org.meshtastic.core.model.util.toOneLiner
@@ -414,7 +415,11 @@ class MeshDataHandlerImpl(
) {
val conversationMuted = packetRepository.value.getContactSettings(contactKey).isMuted
val nodeMuted = nodeManager.getNodeById(dataPacket.from.orEmpty())?.isMuted == true
- val isSilent = conversationMuted || nodeMuted
+ // A mention of our own id is a targeted ping, so it should still notify even in a muted channel/contact.
+ val mentionsMe =
+ dataPacket.dataType == PortNum.TEXT_MESSAGE_APP.value &&
+ textMentionsNode(dataPacket.text, nodeManager.getMyId())
+ val isSilent = (conversationMuted || nodeMuted) && !mentionsMe
if (dataPacket.dataType == PortNum.ALERT_APP.value && !isSilent) {
scope.launch {
notificationManager.dispatch(
diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Mention.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Mention.kt
new file mode 100644
index 000000000..3029f0f3b
--- /dev/null
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Mention.kt
@@ -0,0 +1,35 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.model
+
+/**
+ * The @mention wire token: `@` followed by a node's hex user id (`@!ffccee11`). This is the single source of truth for
+ * the cross-platform wire format (see meshtastic/design#21) so the compose input, message rendering, and notification
+ * paths all detect it identically. The 8-hex run plus the negative lookahead reject a partial match against a longer
+ * id.
+ */
+val MENTION_TOKEN_REGEX = Regex("""@(![0-9a-fA-F]{8})(?![0-9a-fA-F])""")
+
+/**
+ * True if [text] @mentions the node whose hex user id is [userId] (e.g. `!ffccee11`), honoring the token boundary — a
+ * trailing hex digit (`@!ffccee11a`) is a different, longer id and is not a match.
+ */
+fun textMentionsNode(text: String?, userId: String): Boolean {
+ if (text.isNullOrEmpty() || userId.length <= 1) return false
+ // Derive from the shared token regex so the format/boundary rule lives in exactly one place.
+ return MENTION_TOKEN_REGEX.findAll(text).any { it.groupValues[1].equals(userId, ignoreCase = true) }
+}
diff --git a/core/model/src/commonTest/kotlin/org/meshtastic/core/model/MentionTest.kt b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/MentionTest.kt
new file mode 100644
index 000000000..361ba7589
--- /dev/null
+++ b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/MentionTest.kt
@@ -0,0 +1,72 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.model
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+class MentionTest {
+
+ private val myId = "!ffccee11"
+
+ @Test
+ fun `textMentionsNode matches an exact mention token`() {
+ assertTrue(textMentionsNode("hey @!ffccee11 you there", myId))
+ }
+
+ @Test
+ fun `textMentionsNode matches a mention at the end of text`() {
+ assertTrue(textMentionsNode("ping @!ffccee11", myId))
+ }
+
+ @Test
+ fun `textMentionsNode does not match when trailing hex extends the id`() {
+ // Boundary rule: @!ffccee11a is a different (longer) hex run, not a mention of !ffccee11.
+ assertFalse(textMentionsNode("look at @!ffccee11a here", myId))
+ }
+
+ @Test
+ fun `textMentionsNode does not match a different node`() {
+ assertFalse(textMentionsNode("hi @!12345678", myId))
+ }
+
+ @Test
+ fun `textMentionsNode matches case-insensitively`() {
+ assertTrue(textMentionsNode("hi @!FFCCEE11", "!ffccee11"))
+ assertTrue(textMentionsNode("hi @!ffccee11", "!FFCCEE11"))
+ }
+
+ @Test
+ fun `textMentionsNode does not match plain text without the token`() {
+ assertFalse(textMentionsNode("no mentions here", myId))
+ }
+
+ @Test
+ fun `textMentionsNode empty or unknown local id never matches`() {
+ assertFalse(textMentionsNode("@!ffccee11", ""))
+ assertFalse(textMentionsNode("@!ffccee11", "!"))
+ assertFalse(textMentionsNode(null, myId))
+ }
+
+ @Test
+ fun `MENTION_TOKEN_REGEX captures the hex id and rejects trailing hex`() {
+ assertEquals("!ffccee11", MENTION_TOKEN_REGEX.find("yo @!ffccee11!")?.groupValues?.get(1))
+ assertEquals(null, MENTION_TOKEN_REGEX.find("@!ffccee11a"))
+ }
+}
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/AutoLinkText.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/AutoLinkText.kt
index af4909e26..f24898009 100644
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/AutoLinkText.kt
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/AutoLinkText.kt
@@ -19,7 +19,9 @@ package org.meshtastic.core.ui.component
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
@@ -32,6 +34,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withStyle
+import org.meshtastic.core.model.MENTION_TOKEN_REGEX
import org.meshtastic.core.ui.theme.HyperlinkBlue
private val DefaultTextLinkStyles =
@@ -52,7 +55,15 @@ private val EMAIL_REGEX =
private val PHONE_REGEX = Regex("""(?:\+?\d{1,3}[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}""")
-/** A [Text] component that automatically detects and linkifies URLs, email addresses, and phone numbers. */
+private val MentionSpanStyle = SpanStyle(color = HyperlinkBlue, fontWeight = FontWeight.Bold)
+
+/**
+ * A [Text] component that automatically detects and linkifies URLs, email addresses, and phone numbers.
+ *
+ * When [mentionName] is supplied, `@!` mention tokens are additionally rendered as tappable text showing the
+ * *current* display name resolved live (the hex id is the only thing stored on the wire), invoking [onMentionClick]
+ * with the `!` id on tap.
+ */
@Composable
fun AutoLinkText(
text: String,
@@ -61,31 +72,77 @@ fun AutoLinkText(
linkStyles: TextLinkStyles = DefaultTextLinkStyles,
color: Color = Color.Unspecified,
textAlign: TextAlign? = null,
+ mentionName: ((String) -> String?)? = null,
+ onMentionClick: ((String) -> Unit)? = null,
) {
- val annotatedString = remember(text, linkStyles) { buildAnnotatedStringWithLinks(text, linkStyles) }
+ // Keep the click handler out of the annotated-string cache key so a fresh lambda per recomposition
+ // doesn't force a rebuild; only text/name changes should.
+ val currentOnMentionClick by rememberUpdatedState(onMentionClick)
+ val annotatedString =
+ remember(text, linkStyles, mentionName) {
+ buildAnnotatedStringWithLinks(text, linkStyles, mentionName) { id -> currentOnMentionClick?.invoke(id) }
+ }
Text(text = annotatedString, modifier = modifier, style = style.copy(color = color), textAlign = textAlign)
}
-private fun buildAnnotatedStringWithLinks(text: String, linkStyles: TextLinkStyles): AnnotatedString =
- buildAnnotatedString {
- append(text)
+private fun buildAnnotatedStringWithLinks(
+ text: String,
+ linkStyles: TextLinkStyles,
+ mentionName: ((String) -> String?)?,
+ onMentionClick: (String) -> Unit,
+): AnnotatedString {
+ // Substitute each mention token with its live display name up front, tracking display-space ranges.
+ // URL/email/phone detection then runs over the substituted text so every offset lines up.
+ val mentions = mutableListOf>() // display range -> "!hex" id
+ val display =
+ if (mentionName == null) {
+ text
+ } else {
+ buildString {
+ var cursor = 0
+ for (match in MENTION_TOKEN_REGEX.findAll(text)) {
+ append(text, cursor, match.range.first)
+ val id = match.groupValues[1]
+ val start = length
+ append("@").append(mentionName(id) ?: id)
+ mentions.add((start until length) to id)
+ cursor = match.range.last + 1
+ }
+ append(text, cursor, text.length)
+ }
+ }
+
+ return buildAnnotatedString {
+ append(display)
+
+ val usedIndices = mutableSetOf()
+
+ for ((range, id) in mentions) {
+ addLink(
+ LinkAnnotation.Clickable(tag = "mention", styles = TextLinkStyles(MentionSpanStyle)) {
+ onMentionClick(id)
+ },
+ range.first,
+ range.last + 1,
+ )
+ range.forEach { usedIndices.add(it) }
+ }
val matches = mutableListOf>()
- WEB_URL_REGEX.findAll(text).forEach { match ->
+ WEB_URL_REGEX.findAll(display).forEach { match ->
val url = match.value
val fullUrl = if (url.startsWith("www.", ignoreCase = true)) "https://$url" else url
matches.add(match.range to fullUrl)
}
- EMAIL_REGEX.findAll(text).forEach { match -> matches.add(match.range to "mailto:${match.value}") }
+ EMAIL_REGEX.findAll(display).forEach { match -> matches.add(match.range to "mailto:${match.value}") }
- PHONE_REGEX.findAll(text).forEach { match -> matches.add(match.range to "tel:${match.value}") }
+ PHONE_REGEX.findAll(display).forEach { match -> matches.add(match.range to "tel:${match.value}") }
// Sort by start position, then by length (longer first)
val sortedMatches = matches.sortedWith(compareBy({ it.first.first }, { -(it.first.last - it.first.first) }))
- val usedIndices = mutableSetOf()
for ((range, url) in sortedMatches) {
if (range.any { it in usedIndices }) continue
@@ -93,6 +150,7 @@ private fun buildAnnotatedStringWithLinks(text: String, linkStyles: TextLinkStyl
range.forEach { usedIndices.add(it) }
}
}
+}
/**
* A [Text] component that highlights occurrences of [query] within [text] using the tertiary container color. Each
diff --git a/feature/messaging/detekt-baseline.xml b/feature/messaging/detekt-baseline.xml
index 7efbee4c4..b4f5ccab3 100644
--- a/feature/messaging/detekt-baseline.xml
+++ b/feature/messaging/detekt-baseline.xml
@@ -5,7 +5,7 @@
ComposableParamOrder:MessageActions.kt:@Composable internal fun MessageActions
ComposableParamOrder:MessageItem.kt:@OptIn(ExperimentalMaterial3Api::class) @Suppress("LongMethod", "CyclomaticComplexMethod") @Composable fun MessageItem
ComposableParamOrder:MessageListPaged.kt:@Suppress("LongMethod", "CyclomaticComplexMethod") @Composable private fun MessageListPagedContent
- ComposableParamOrder:MessageListPaged.kt:@Suppress("LongParameterList") @Composable private fun RenderPagedChatMessageRow
+ ComposableParamOrder:MessageListPaged.kt:@Suppress("LongParameterList", "LongMethod") @Composable private fun RenderPagedChatMessageRow
ComposableParamOrder:MessageScreenComponents.kt:@Composable fun QuickChatRow
ComposableParamOrder:QuickChat.kt:@Composable fun QuickChatScreen
ComposableParamOrder:QuickChat.kt:@Composable private fun OutlinedTextFieldWithCounter
diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/Message.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/Message.kt
index 73b16fa78..691001bf8 100644
--- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/Message.kt
+++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/Message.kt
@@ -19,9 +19,12 @@
package org.meshtastic.feature.messaging
import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
+import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
@@ -30,6 +33,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.text.input.OutputTransformation
import androidx.compose.foundation.text.input.TextFieldLineLimits
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.clearText
@@ -52,6 +56,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
@@ -61,9 +66,12 @@ import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.text.TextRange
+import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -74,6 +82,7 @@ import org.meshtastic.core.common.util.HomoglyphCharacterStringTransformer
import org.meshtastic.core.database.entity.QuickChatAction
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.ContactKey
+import org.meshtastic.core.model.MENTION_TOKEN_REGEX
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.NodeAddress
import org.meshtastic.core.model.util.getChannel
@@ -414,6 +423,7 @@ fun MessageScreen(
isEnabled = connectionState is ConnectionState.Connected,
isHomoglyphEncodingEnabled = homoglyphEncodingEnabled,
textFieldState = messageInputState,
+ nodes = nodes,
onSendMessage = {
val messageText = messageInputState.text.toString().trim { it.isWhitespace() }
if (messageText.isNotEmpty()) {
@@ -486,21 +496,65 @@ private fun handleQuickChatAction(
)
}
+private const val MENTION_SUGGESTION_LIMIT = 5
+
+/** An in-progress `@name` the user is typing (before selection completes it into a `@!` token). */
+internal data class MentionQuery(val start: Int, val end: Int, val query: String)
+
+/**
+ * Detects an active @mention query: an `@` at the caret that starts a word and is followed only by non-whitespace.
+ * Returns null when there is nothing to autocomplete (already-completed tokens include a trailing space, so they fail
+ * the no-whitespace check).
+ */
+@Suppress("ReturnCount") // Guard clauses read more clearly than a single nested expression here.
+internal fun currentMentionQuery(text: String, selection: TextRange): MentionQuery? {
+ if (!selection.collapsed) return null
+ val cursor = selection.start
+ if (cursor == 0) return null
+ val at = text.lastIndexOf('@', cursor - 1)
+ if (at < 0) return null
+ if (at > 0 && !text[at - 1].isWhitespace()) return null
+ val query = text.substring(at + 1, cursor)
+ if (query.any { it.isWhitespace() }) return null
+ return MentionQuery(at, cursor, query)
+}
+
+private fun Node.matchesMention(query: String): Boolean {
+ if (query.isEmpty()) return true
+ val q = query.lowercase()
+ return user.long_name.lowercase().contains(q) ||
+ user.short_name.lowercase().contains(q) ||
+ user.id.lowercase().contains(q)
+}
+
+/** Displays `@!` tokens as `@FriendlyName` while typing; the stored buffer keeps the hex wire form. */
+private fun mentionOutputTransformation(nodesById: Map) = OutputTransformation {
+ val source = toString()
+ // ponytail: replace back-to-front so earlier offsets stay valid; editing inside a substituted token is imperfect
+ // but the caret sits outside tokens in normal use.
+ for (match in MENTION_TOKEN_REGEX.findAll(source).toList().asReversed()) {
+ val node = nodesById[match.groupValues[1]] ?: continue
+ replace(match.range.first, match.range.last + 1, "@" + node.user.long_name.ifEmpty { node.user.short_name })
+ }
+}
+
/**
* The text input field for composing messages.
*
* @param isEnabled Whether the input field should be enabled.
* @param textFieldState The [TextFieldState] managing the input's text.
+ * @param nodes Known nodes, used for the `@`-mention autocomplete and friendly-name display.
* @param modifier The modifier for this composable.
* @param maxByteSize The maximum allowed size of the message in bytes.
* @param onSendMessage Callback invoked when the send button is pressed or send IME action is triggered.
*/
-@Suppress("LongMethod") // Due to multiple parts of the OutlinedTextField
+@Suppress("LongMethod", "CyclomaticComplexMethod") // Due to multiple parts of the OutlinedTextField
@Composable
private fun MessageInput(
isEnabled: Boolean,
isHomoglyphEncodingEnabled: Boolean,
textFieldState: TextFieldState,
+ nodes: List,
modifier: Modifier = Modifier,
maxByteSize: Int = MESSAGE_CHARACTER_LIMIT_BYTES,
onSendMessage: () -> Unit,
@@ -523,55 +577,121 @@ private fun MessageInput(
val isOverLimit = currentByteLength > maxByteSize
val canSend = !isOverLimit && currentText.isNotEmpty() && isEnabled
- OutlinedTextField(
- modifier =
- modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp).onKeyEvent { keyEvent ->
- val isEnterNoShift = keyEvent.key == Key.Enter && !keyEvent.isShiftPressed
- if (isEnterNoShift) {
- if (keyEvent.type == KeyEventType.KeyUp && canSend) {
- onSendMessage()
+ val nodesById = remember(nodes) { nodes.associateBy { it.user.id } }
+ val mentionOutput = remember(nodesById) { mentionOutputTransformation(nodesById) }
+ val mentionQuery by
+ remember(textFieldState) {
+ derivedStateOf { currentMentionQuery(textFieldState.text.toString(), textFieldState.selection) }
+ }
+ val suggestions =
+ remember(mentionQuery, nodes) {
+ val query = mentionQuery?.query ?: return@remember emptyList()
+ nodes.filter { it.matchesMention(query) }.take(MENTION_SUGGESTION_LIMIT)
+ }
+
+ // While the mention popup is open, Enter / send completes the top suggestion instead of sending a raw @query.
+ val mentionActive = mentionQuery != null && suggestions.isNotEmpty()
+ fun insertMention(node: Node) {
+ val q = mentionQuery ?: return
+ textFieldState.edit {
+ val insert = "@${node.user.id} "
+ replace(q.start, q.end, insert)
+ selection = TextRange(q.start + insert.length)
+ }
+ }
+ val onSendAction: () -> Unit = {
+ if (mentionActive) {
+ insertMention(suggestions.first())
+ } else if (canSend) {
+ onSendMessage()
+ }
+ }
+
+ Column(modifier = modifier.fillMaxWidth()) {
+ if (mentionActive) {
+ MentionSuggestions(suggestions = suggestions, onPick = ::insertMention)
+ }
+ OutlinedTextField(
+ modifier =
+ Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp).onKeyEvent { keyEvent ->
+ val isEnterNoShift = keyEvent.key == Key.Enter && !keyEvent.isShiftPressed
+ if (isEnterNoShift) {
+ if (keyEvent.type == KeyEventType.KeyUp) onSendAction()
+ true // consume both KeyDown and KeyUp to prevent newline insertion
+ } else {
+ false
+ }
+ },
+ state = textFieldState,
+ outputTransformation = mentionOutput,
+ lineLimits = TextFieldLineLimits.MultiLine(1, MAX_LINES),
+ label = { Text(stringResource(Res.string.message_input_label)) },
+ enabled = isEnabled,
+ shape = RoundedCornerShape(ROUNDED_CORNER_PERCENT.toFloat()),
+ isError = isOverLimit,
+ placeholder = { Text(stringResource(Res.string.type_a_message)) },
+ keyboardOptions =
+ KeyboardOptions(capitalization = KeyboardCapitalization.Sentences, imeAction = ImeAction.Send),
+ onKeyboardAction = { onSendAction() },
+ supportingText = {
+ if (isEnabled) { // Only show supporting text if input is enabled
+ Text(
+ text = "$currentByteLength/$maxByteSize",
+ style = MaterialTheme.typography.bodySmall,
+ color =
+ if (isOverLimit) {
+ MaterialTheme.colorScheme.error
+ } else {
+ MaterialTheme.colorScheme.onSurfaceVariant
+ },
+ modifier = Modifier.fillMaxWidth(),
+ textAlign = TextAlign.End,
+ )
+ }
+ },
+ // Direct byte limiting via inputTransformation in TextFieldState is complex.
+ // The current approach (show error, disable send) is generally preferred for UX.
+ // If strict real-time byte trimming is required, it needs careful handling of
+ // cursor position and multi-byte characters, likely outside simple inputTransformation.
+ trailingIcon = {
+ IconButton(onClick = onSendAction, enabled = canSend || mentionActive) {
+ Icon(imageVector = MeshtasticIcons.Send, contentDescription = stringResource(Res.string.send))
+ }
+ },
+ )
+ }
+}
+
+@Composable
+private fun MentionSuggestions(suggestions: List, onPick: (Node) -> Unit) {
+ Surface(
+ modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
+ tonalElevation = 3.dp,
+ shape = RoundedCornerShape(12.dp),
+ ) {
+ Column {
+ suggestions.forEach { node ->
+ Row(
+ modifier =
+ Modifier.fillMaxWidth().clickable { onPick(node) }.padding(horizontal = 12.dp, vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Text(
+ text = node.user.short_name,
+ style = MaterialTheme.typography.labelLarge,
+ fontWeight = FontWeight.Bold,
+ )
+ Text(
+ text = node.user.long_name,
+ style = MaterialTheme.typography.bodyMedium,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
}
- true // consume both KeyDown and KeyUp to prevent newline insertion
- } else {
- false
}
- },
- state = textFieldState,
- lineLimits = TextFieldLineLimits.MultiLine(1, MAX_LINES),
- label = { Text(stringResource(Res.string.message_input_label)) },
- enabled = isEnabled,
- shape = RoundedCornerShape(ROUNDED_CORNER_PERCENT.toFloat()),
- isError = isOverLimit,
- placeholder = { Text(stringResource(Res.string.type_a_message)) },
- keyboardOptions =
- KeyboardOptions(capitalization = KeyboardCapitalization.Sentences, imeAction = ImeAction.Send),
- onKeyboardAction = { if (canSend) onSendMessage() },
- supportingText = {
- if (isEnabled) { // Only show supporting text if input is enabled
- Text(
- text = "$currentByteLength/$maxByteSize",
- style = MaterialTheme.typography.bodySmall,
- color =
- if (isOverLimit) {
- MaterialTheme.colorScheme.error
- } else {
- MaterialTheme.colorScheme.onSurfaceVariant
- },
- modifier = Modifier.fillMaxWidth(),
- textAlign = TextAlign.End,
- )
- }
- },
- // Direct byte limiting via inputTransformation in TextFieldState is complex.
- // The current approach (show error, disable send) is generally preferred for UX.
- // If strict real-time byte trimming is required, it needs careful handling of
- // cursor position and multi-byte characters, likely outside simple inputTransformation.
- trailingIcon = {
- IconButton(onClick = { if (canSend) onSendMessage() }, enabled = canSend) {
- Icon(imageVector = MeshtasticIcons.Send, contentDescription = stringResource(Res.string.send))
- }
- },
- )
+ }
+ }
}
@PreviewLightDark
@@ -583,6 +703,7 @@ fun MessageInputPreview() {
MessageInput(
isEnabled = true,
isHomoglyphEncodingEnabled = false,
+ nodes = emptyList(),
textFieldState = rememberTextFieldState("Hello"),
onSendMessage = {},
)
@@ -590,6 +711,7 @@ fun MessageInputPreview() {
MessageInput(
isEnabled = false,
isHomoglyphEncodingEnabled = false,
+ nodes = emptyList(),
textFieldState = rememberTextFieldState("Disabled"),
onSendMessage = {},
)
@@ -597,6 +719,7 @@ fun MessageInputPreview() {
MessageInput(
isEnabled = true,
isHomoglyphEncodingEnabled = false,
+ nodes = emptyList(),
textFieldState =
rememberTextFieldState(
"A very long message that might exceed the byte limit " +
@@ -610,6 +733,7 @@ fun MessageInputPreview() {
MessageInput(
isEnabled = true,
isHomoglyphEncodingEnabled = false,
+ nodes = emptyList(),
textFieldState = rememberTextFieldState("こんにちは世界"), // Hello World in Japanese
onSendMessage = {},
maxByteSize = 10,
diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageListPaged.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageListPaged.kt
index 620fa377e..d50e09963 100644
--- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageListPaged.kt
+++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageListPaged.kt
@@ -63,6 +63,8 @@ import org.meshtastic.feature.messaging.component.MessageStatusDialog
import org.meshtastic.feature.messaging.component.ReactionDialog
import org.meshtastic.feature.messaging.component.UnreadMessagesDivider
+private const val HEX_RADIX = 16
+
internal data class MessageListHandlers(
val onUnreadChanged: (Long, Long) -> Unit,
val onSendReaction: (String, Int) -> Unit,
@@ -297,7 +299,7 @@ private fun MessageListPagedContent(
}
}
-@Suppress("LongParameterList")
+@Suppress("LongParameterList", "LongMethod")
@Composable
private fun RenderPagedChatMessageRow(
message: Message,
@@ -323,6 +325,10 @@ private fun RenderPagedChatMessageRow(
}
val node = nodeMap[message.node.num] ?: message.node
+ // Resolve an @mention token ("!" = numeric node id) back to its node for live name + tap-to-open.
+ val resolveMention: (String) -> Node? =
+ remember(nodeMap) { { id -> id.removePrefix("!").toLongOrNull(HEX_RADIX)?.toInt()?.let { nodeMap[it] } } }
+
MessageItem(
modifier = modifier,
node = node,
@@ -340,6 +346,7 @@ private fun RenderPagedChatMessageRow(
onSelect = { state.selectedIds.toggle(message.uuid) },
onDelete = { handlers.onDeleteMessages(listOf(message.uuid)) },
onClickChip = handlers.onClickChip,
+ resolveMention = resolveMention,
onStatusClick = { onShowStatusDialog(message) },
onReply = { handlers.onReply(message) },
emojis = message.emojis,
diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageItem.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageItem.kt
index 585bf075e..c85ecd3ca 100644
--- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageItem.kt
+++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageItem.kt
@@ -110,6 +110,7 @@ fun MessageItem(
onSelect: () -> Unit = {},
onDelete: () -> Unit = {},
onClickChip: (Node) -> Unit = {},
+ resolveMention: (String) -> Node? = { null },
onNavigateToOriginalMessage: (Int) -> Unit = {},
onStatusClick: () -> Unit = {},
hasSamePrev: Boolean = false,
@@ -296,7 +297,19 @@ fun MessageItem(
color = contentColor,
)
} else {
- AutoLinkText(text = bodyText, style = MaterialTheme.typography.bodyLarge, color = contentColor)
+ val mentionDisplayName =
+ remember(resolveMention) {
+ { id: String ->
+ resolveMention(id)?.let { it.user.long_name.ifEmpty { it.user.short_name } }
+ }
+ }
+ AutoLinkText(
+ text = bodyText,
+ style = MaterialTheme.typography.bodyLarge,
+ color = contentColor,
+ mentionName = mentionDisplayName,
+ onMentionClick = { id -> resolveMention(id)?.let(onClickChip) },
+ )
}
Row(
diff --git a/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/MentionQueryTest.kt b/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/MentionQueryTest.kt
new file mode 100644
index 000000000..bb6dee84a
--- /dev/null
+++ b/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/MentionQueryTest.kt
@@ -0,0 +1,73 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.messaging
+
+import androidx.compose.ui.text.TextRange
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+
+class MentionQueryTest {
+
+ private fun query(text: String, cursor: Int = text.length) = currentMentionQuery(text, TextRange(cursor))
+
+ @Test
+ fun `detects a query at the start`() {
+ assertEquals(MentionQuery(start = 0, end = 3, query = "al"), query("@al"))
+ }
+
+ @Test
+ fun `detects a query after whitespace`() {
+ assertEquals(MentionQuery(start = 3, end = 7, query = "bob"), query("hi @bob"))
+ }
+
+ @Test
+ fun `bare at-sign yields an empty query for showing all nodes`() {
+ assertEquals(MentionQuery(start = 0, end = 1, query = ""), query("@"))
+ }
+
+ @Test
+ fun `no at-sign means no query`() {
+ assertNull(query("hello there"))
+ }
+
+ @Test
+ fun `whitespace after the at-sign ends the query`() {
+ assertNull(query("@bob said hi"))
+ }
+
+ @Test
+ fun `at-sign mid-word such as an email is not a mention`() {
+ assertNull(query("me@host"))
+ }
+
+ @Test
+ fun `a completed token followed by a space does not re-trigger`() {
+ assertNull(query("@!ffccee11 "))
+ }
+
+ @Test
+ fun `only the token left of the caret is considered`() {
+ // caret sits right after "@al"; the later "@bob" is ignored
+ assertEquals(MentionQuery(start = 0, end = 3, query = "al"), query("@al @bob", cursor = 3))
+ }
+
+ @Test
+ fun `a non-collapsed selection yields no query`() {
+ assertNull(currentMentionQuery("@bob", TextRange(1, 4)))
+ }
+}