mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-07-09 12:55:45 -04:00
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user