From 34c25d3982426a8ebfde41d6e2c3cf3aefa88fba Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Mon, 6 Jul 2026 09:06:45 -0500 Subject: [PATCH] feat(messaging): @mention with deep-link to node detail (#6098) (#6108) Co-authored-by: Claude Opus 4.8 --- .../core/data/manager/MeshDataHandlerImpl.kt | 7 +- .../org/meshtastic/core/model/Mention.kt | 35 +++ .../org/meshtastic/core/model/MentionTest.kt | 72 ++++++ .../core/ui/component/AutoLinkText.kt | 76 +++++- feature/messaging/detekt-baseline.xml | 2 +- .../meshtastic/feature/messaging/Message.kt | 220 ++++++++++++++---- .../feature/messaging/MessageListPaged.kt | 9 +- .../messaging/component/MessageItem.kt | 15 +- .../feature/messaging/MentionQueryTest.kt | 73 ++++++ 9 files changed, 448 insertions(+), 61 deletions(-) create mode 100644 core/model/src/commonMain/kotlin/org/meshtastic/core/model/Mention.kt create mode 100644 core/model/src/commonTest/kotlin/org/meshtastic/core/model/MentionTest.kt create mode 100644 feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/MentionQueryTest.kt 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))) + } +}