feat(messaging): @mention with deep-link to node detail (#6098) (#6108)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
James Rich
2026-07-06 09:06:45 -05:00
committed by GitHub
parent 0643e54242
commit 34c25d3982
9 changed files with 448 additions and 61 deletions

View File

@@ -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(

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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) }
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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"))
}
}

View File

@@ -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, `@!<hex>` 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 `!<hex>` 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<Pair<IntRange, String>>() // 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<Int>()
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<Pair<IntRange, String>>()
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<Int>()
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

View File

@@ -5,7 +5,7 @@
<ID>ComposableParamOrder:MessageActions.kt:@Composable internal fun MessageActions</ID>
<ID>ComposableParamOrder:MessageItem.kt:@OptIn(ExperimentalMaterial3Api::class) @Suppress("LongMethod", "CyclomaticComplexMethod") @Composable fun MessageItem</ID>
<ID>ComposableParamOrder:MessageListPaged.kt:@Suppress("LongMethod", "CyclomaticComplexMethod") @Composable private fun MessageListPagedContent</ID>
<ID>ComposableParamOrder:MessageListPaged.kt:@Suppress("LongParameterList") @Composable private fun RenderPagedChatMessageRow</ID>
<ID>ComposableParamOrder:MessageListPaged.kt:@Suppress("LongParameterList", "LongMethod") @Composable private fun RenderPagedChatMessageRow</ID>
<ID>ComposableParamOrder:MessageScreenComponents.kt:@Composable fun QuickChatRow</ID>
<ID>ComposableParamOrder:QuickChat.kt:@Composable fun QuickChatScreen</ID>
<ID>ComposableParamOrder:QuickChat.kt:@Composable private fun OutlinedTextFieldWithCounter</ID>

View File

@@ -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 `@!<hex>` 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 `@!<hex>` tokens as `@FriendlyName` while typing; the stored buffer keeps the hex wire form. */
private fun mentionOutputTransformation(nodesById: Map<String, Node>) = 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<Node>,
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<Node>()
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<Node>, 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,

View File

@@ -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 ("!<hex>" = 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,

View File

@@ -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(

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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)))
}
}