From 4274280d604d0912064416106e54ec1484bb6bf9 Mon Sep 17 00:00:00 2001 From: Jeremiah K <17190268+jeremiah-k@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:16:54 -0500 Subject: [PATCH 1/2] fix(messaging): stabilize composer lifecycle and hot paths Project nodes into a persistent immutable map of lightweight mention candidates whose equality depends only on identifiers and display names. Keep the composer parameter stable when telemetry, position, last-heard, or other unrelated node fields change so Compose can skip the input subtree without recreating its output transformation or churning the platform input connection. Keep live editing bounded with a lightweight inline-markdown scanner, cached normalized candidate fields, one query normalization, limited lazy matching, and an empty-query fast path. Document the intentionally narrower live-preview dialect while leaving sent-message rendering unchanged. Drive debounced draft persistence from text snapshots, cancel superseded writes, and clear sent drafts synchronously. Cover candidate ordering and limits, supported inline styling, debounce replacement, and immediate draft clearing in behavior-focused tests. --- feature/messaging/build.gradle.kts | 1 + .../meshtastic/feature/messaging/Message.kt | 131 +++++++++++++----- .../feature/messaging/MessageViewModel.kt | 21 ++- .../messaging/MessageComposerBehaviorTest.kt | 75 ++++++++++ .../feature/messaging/MessageViewModelTest.kt | 40 ++++++ 5 files changed, 235 insertions(+), 33 deletions(-) create mode 100644 feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/MessageComposerBehaviorTest.kt diff --git a/feature/messaging/build.gradle.kts b/feature/messaging/build.gradle.kts index 7b6b3b3939..4a5c66230b 100644 --- a/feature/messaging/build.gradle.kts +++ b/feature/messaging/build.gradle.kts @@ -34,6 +34,7 @@ kotlin { implementation(libs.androidx.paging.common) implementation(libs.androidx.paging.compose) + implementation(libs.kotlinx.collections.immutable) // JetBrains Material 3 Adaptive (multiplatform ListDetailPaneScaffold) implementation(libs.jetbrains.compose.material3.adaptive) 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 7ee3e51def..f1765eac0d 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 @@ -82,6 +82,9 @@ import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.paging.compose.collectAsLazyPagingItems +import kotlinx.collections.immutable.ImmutableMap +import kotlinx.collections.immutable.persistentMapOf +import kotlinx.collections.immutable.toPersistentMap import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.common.util.HomoglyphCharacterStringTransformer @@ -99,7 +102,6 @@ import org.meshtastic.core.resources.type_a_message import org.meshtastic.core.resources.unknown_channel import org.meshtastic.core.ui.component.InlineStyle import org.meshtastic.core.ui.component.SharedContactDialog -import org.meshtastic.core.ui.component.inlineMarkdownStyleRanges import org.meshtastic.core.ui.component.smartScrollToIndex import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.icon.Send @@ -150,6 +152,13 @@ fun MessageScreen( val focusManager = LocalFocusManager.current val nodes by viewModel.nodeList.collectAsStateWithLifecycle() + val mentionCandidates by remember { + derivedStateOf { + nodes + .associate { it.user.id to MentionCandidate(it.user.id, it.user.long_name, it.user.short_name) } + .toPersistentMap() + } + } val ourNode by viewModel.ourNodeInfo.collectAsStateWithLifecycle() val connectionState by viewModel.connectionState.collectAsStateWithLifecycle() val channels by viewModel.channels.collectAsStateWithLifecycle() @@ -435,7 +444,7 @@ fun MessageScreen( isEnabled = connectionState is ConnectionState.Connected, isHomoglyphEncodingEnabled = homoglyphEncodingEnabled, textFieldState = messageInputState, - nodes = nodes, + mentionCandidates = mentionCandidates, onSendMessage = { val messageText = messageInputState.text.toString().trim { it.isWhitespace() } if (messageText.isNotEmpty()) { @@ -531,27 +540,82 @@ internal fun currentMentionQuery(text: String, selection: TextRange): MentionQue 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) +/** + * Lightweight mention target — contains only fields that affect composer presentation. A telemetry-only [Node] change + * leaves a [MentionCandidate] unchanged so the composable's recomposition scope stays narrow. Normalized fields are + * cached once so matching does not allocate three lowercase strings per candidate on every keystroke. + */ +internal data class MentionCandidate(val id: String, val longName: String, val shortName: String) { + private val normalizedId = id.lowercase() + private val normalizedLongName = longName.lowercase() + private val normalizedShortName = shortName.lowercase() + + fun matchesNormalizedQuery(query: String): Boolean = + normalizedLongName.contains(query) || normalizedShortName.contains(query) || normalizedId.contains(query) +} + +internal fun matchingMentionCandidates( + candidates: Collection, + query: String, + limit: Int = MENTION_SUGGESTION_LIMIT, +): List = when { + limit <= 0 -> emptyList() + + query.isEmpty() -> candidates.take(limit) + + else -> { + val normalizedQuery = query.lowercase() + candidates.asSequence().filter { it.matchesNormalizedQuery(normalizedQuery) }.take(limit).toList() + } +} + +/** Regex-backed inline-markdown style ranges for the live TextField path — avoids the IntelliJ GFM parser. */ +private val LIVE_BOLD = Regex("\\*\\*([^*]+)\\*\\*") +private val LIVE_ITALIC = Regex("(? { + if (!source.any { it in LIVE_STYLE_DELIMITER_SET }) return emptyList() + return buildList { + LIVE_BOLD.findAll(source).forEach { add(LiveStyleSpan(it.groups[1]!!.range, InlineStyle.Bold)) } + LIVE_ITALIC.findAll(source).forEach { add(LiveStyleSpan(it.groups[1]!!.range, InlineStyle.Italic)) } + LIVE_STRIKE.findAll(source).forEach { add(LiveStyleSpan(it.groups[1]!!.range, InlineStyle.Strikethrough)) } + LIVE_CODE.findAll(source).forEach { add(LiveStyleSpan(it.groups[1]!!.range, InlineStyle.Code)) } + } } +private val LIVE_STYLE_DELIMITER_SET = setOf('*', '~', '`') + /** * Displays `@!` tokens as `@FriendlyName` and applies live inline-markdown styling (bold/italic/strikethrough/ * code) while typing. Both are presentation-only via [OutputTransformation]: the stored buffer keeps the hex wire form * and the raw markdown delimiters, so the bytes sent are unchanged. */ @OptIn(ExperimentalFoundationApi::class) -private fun mentionOutputTransformation(nodesById: Map) = OutputTransformation { - for (match in MENTION_TOKEN_REGEX.findAll(toString()).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 }) +private fun mentionOutputTransformation(nodesById: Map) = OutputTransformation { + val source = toString() + // Fast path: plain text with no mention tokens or markdown delimiters — skip all scanning. + val hasMention = source.indexOf('@') >= 0 + val needsStyle = hasMention || source.any { it in LIVE_STYLE_DELIMITER_SET } + if (!needsStyle) return@OutputTransformation + + for (match in MENTION_TOKEN_REGEX.findAll(source).toList().asReversed()) { + val candidate = nodesById[match.groupValues[1]] ?: continue + replace(match.range.first, match.range.last + 1, "@" + candidate.longName.ifEmpty { candidate.shortName }) } - // Live markdown styling over the (mention-substituted) buffer. Delimiters stay visible; only spans are added. - for (span in inlineMarkdownStyleRanges(toString())) { + // Rescan the post-replacement buffer — mention replacements may have changed text length, + // so the original source offsets are no longer valid. + val postMentions = toString() + for (span in liveInlineMarkdownStyleRanges(postMentions)) { val spanStyle = when (span.style) { InlineStyle.Bold -> SpanStyle(fontWeight = FontWeight.Bold) @@ -569,7 +633,8 @@ private fun mentionOutputTransformation(nodesById: Map) = OutputTr * * @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 mentionCandidates Identity-stable node-id → mention entry. Only changes when a node's id or display name + * changes — telemetry-only updates leave this map structurally identical, preventing recomposition churn. * @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. @@ -580,7 +645,7 @@ private fun MessageInput( isEnabled: Boolean, isHomoglyphEncodingEnabled: Boolean, textFieldState: TextFieldState, - nodes: List, + mentionCandidates: ImmutableMap, modifier: Modifier = Modifier, maxByteSize: Int = MESSAGE_CHARACTER_LIMIT_BYTES, onSendMessage: () -> Unit, @@ -603,24 +668,23 @@ private fun MessageInput( val isOverLimit = currentByteLength > maxByteSize val canSend = !isOverLimit && currentText.isNotEmpty() && isEnabled - val nodesById = remember(nodes) { nodes.associateBy { it.user.id } } - val mentionOutput = remember(nodesById) { mentionOutputTransformation(nodesById) } + val mentionOutput = remember(mentionCandidates) { mentionOutputTransformation(mentionCandidates) } 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) + remember(mentionQuery, mentionCandidates) { + val query = mentionQuery?.query ?: return@remember emptyList() + matchingMentionCandidates(mentionCandidates.values, query) } // 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) { + fun insertMention(candidate: MentionCandidate) { val q = mentionQuery ?: return textFieldState.edit { - val insert = "@${node.user.id} " + val insert = "@${candidate.id} " replace(q.start, q.end, insert) selection = TextRange(q.start + insert.length) } @@ -698,27 +762,29 @@ private fun MessageInput( } @Composable -private fun MentionSuggestions(suggestions: List, onPick: (Node) -> Unit) { +private fun MentionSuggestions(suggestions: List, onPick: (MentionCandidate) -> Unit) { Surface( modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp), tonalElevation = 3.dp, shape = RoundedCornerShape(12.dp), ) { Column { - suggestions.forEach { node -> + suggestions.forEach { candidate -> Row( modifier = - Modifier.fillMaxWidth().clickable { onPick(node) }.padding(horizontal = 12.dp, vertical = 8.dp), + Modifier.fillMaxWidth() + .clickable { onPick(candidate) } + .padding(horizontal = 12.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { Text( - text = node.user.short_name, + text = candidate.shortName, style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold, ) Text( - text = node.user.long_name, + text = candidate.longName, style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -732,13 +798,14 @@ private fun MentionSuggestions(suggestions: List, onPick: (Node) -> Unit) @PreviewLightDark @Composable fun MessageInputPreview() { + val mentionCandidates = persistentMapOf() AppTheme { Surface { Column(modifier = Modifier.padding(8.dp)) { MessageInput( isEnabled = true, isHomoglyphEncodingEnabled = false, - nodes = emptyList(), + mentionCandidates = mentionCandidates, textFieldState = rememberTextFieldState("Hello"), onSendMessage = {}, ) @@ -746,7 +813,7 @@ fun MessageInputPreview() { MessageInput( isEnabled = false, isHomoglyphEncodingEnabled = false, - nodes = emptyList(), + mentionCandidates = mentionCandidates, textFieldState = rememberTextFieldState("Disabled"), onSendMessage = {}, ) @@ -754,7 +821,7 @@ fun MessageInputPreview() { MessageInput( isEnabled = true, isHomoglyphEncodingEnabled = false, - nodes = emptyList(), + mentionCandidates = mentionCandidates, textFieldState = rememberTextFieldState( "A very long message that might exceed the byte limit " + @@ -768,7 +835,7 @@ fun MessageInputPreview() { MessageInput( isEnabled = true, isHomoglyphEncodingEnabled = false, - nodes = emptyList(), + mentionCandidates = mentionCandidates, textFieldState = rememberTextFieldState("こんにちは世界"), // Hello World in Japanese onSendMessage = {}, maxByteSize = 10, diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt index b1b7f79016..3356ce6efd 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt @@ -22,6 +22,8 @@ import androidx.lifecycle.viewModelScope import androidx.paging.PagingData import androidx.paging.cachedIn import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -34,6 +36,7 @@ import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import org.koin.core.annotation.KoinViewModel import org.meshtastic.core.common.util.currentLocaleCode import org.meshtastic.core.common.util.ioDispatcher @@ -108,13 +111,28 @@ class MessageViewModel( private val _draftMessage = MutableStateFlow(savedStateHandle.get("draftMessage") ?: "") val draftMessage: StateFlow = _draftMessage.asStateFlow() + private var pendingDraftPersistence: Job? = null + + /** + * Updates the in-memory draft immediately. The durable [SavedStateHandle] write is debounced by + * [DRAFT_PERSISTENCE_DELAY_MS] — rapid edits cancel the prior pending flush, so only the trailing value persists. + * On process death within the debounce window the last ≤[DRAFT_PERSISTENCE_DELAY_MS] of typing is not durably + * saved; this is a conscious trade-off to avoid per-keystroke jank. + */ fun setDraftMessage(text: String) { _draftMessage.value = text - savedStateHandle["draftMessage"] = text + pendingDraftPersistence?.cancel() + pendingDraftPersistence = + viewModelScope.launch { + delay(DRAFT_PERSISTENCE_DELAY_MS) + savedStateHandle["draftMessage"] = text + } } fun clearDraftMessage() { _draftMessage.value = "" + pendingDraftPersistence?.cancel() + pendingDraftPersistence = null savedStateHandle["draftMessage"] = "" } @@ -413,5 +431,6 @@ class MessageViewModel( companion object { private const val SEARCH_DEBOUNCE_MS = 300L private const val MIN_SEARCH_LENGTH = 2 + private const val DRAFT_PERSISTENCE_DELAY_MS = 300L } } diff --git a/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/MessageComposerBehaviorTest.kt b/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/MessageComposerBehaviorTest.kt new file mode 100644 index 0000000000..2d32eb06da --- /dev/null +++ b/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/MessageComposerBehaviorTest.kt @@ -0,0 +1,75 @@ +/* + * 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 org.meshtastic.core.ui.component.InlineStyle +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class MessageComposerBehaviorTest { + + private val candidates = + listOf( + MentionCandidate(id = "!a1", longName = "Alpha Node", shortName = "ALP"), + MentionCandidate(id = "!b2", longName = "Bravo", shortName = "BRV"), + MentionCandidate(id = "!c3", longName = "Charlie", shortName = "CHR"), + ) + + @Test + fun `mention matching is case insensitive across id and display names`() { + assertEquals(listOf(candidates[0]), matchingMentionCandidates(candidates, "ALPHA")) + assertEquals(listOf(candidates[1]), matchingMentionCandidates(candidates, "brv")) + assertEquals(listOf(candidates[2]), matchingMentionCandidates(candidates, "!C3")) + } + + @Test + fun `mention matching preserves order and stops at the requested limit`() { + assertEquals(candidates.take(2), matchingMentionCandidates(candidates, query = "", limit = 2)) + assertTrue(matchingMentionCandidates(candidates, query = "", limit = 0).isEmpty()) + } + + @Test + fun `live markdown parser returns no spans for plain or incomplete text`() { + assertTrue(liveInlineMarkdownStyleRanges("plain text").isEmpty()) + assertTrue(liveInlineMarkdownStyleRanges("**unfinished").isEmpty()) + assertTrue(liveInlineMarkdownStyleRanges("`unfinished").isEmpty()) + } + + @Test + fun `live markdown parser identifies supported inline styles without styling delimiters`() { + val source = "**bold** *italic* ~~gone~~ `code`" + val spans = liveInlineMarkdownStyleRanges(source).map { span -> source.substring(span.range) to span.style } + + assertEquals( + listOf( + "bold" to InlineStyle.Bold, + "italic" to InlineStyle.Italic, + "gone" to InlineStyle.Strikethrough, + "code" to InlineStyle.Code, + ), + spans, + ) + } + + @Test + fun `bold delimiters are not double counted as italic`() { + val spans = liveInlineMarkdownStyleRanges("**bold**") + + assertEquals(listOf(LiveStyleSpan(2..5, InlineStyle.Bold)), spans) + } +} diff --git a/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/MessageViewModelTest.kt b/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/MessageViewModelTest.kt index 3e6ba9b7ea..351d9d3f13 100644 --- a/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/MessageViewModelTest.kt +++ b/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/MessageViewModelTest.kt @@ -56,6 +56,7 @@ import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull +import kotlin.test.assertNull class MessageViewModelTest { @@ -140,6 +141,45 @@ class MessageViewModelTest { @Test fun testInitialization() = runTest { assertNotNull(viewModel) } + @Test + fun testDraftPersistenceDebouncesRapidEdits() = runTest { + viewModel.setDraftMessage("a") + testDispatcher.scheduler.runCurrent() + testDispatcher.scheduler.advanceTimeBy(100L) + + viewModel.setDraftMessage("ab") + testDispatcher.scheduler.runCurrent() + testDispatcher.scheduler.advanceTimeBy(100L) + + viewModel.setDraftMessage("abc") + testDispatcher.scheduler.runCurrent() + + assertEquals("abc", viewModel.draftMessage.value) + assertNull(savedStateHandle.get("draftMessage")) + + testDispatcher.scheduler.advanceTimeBy(299L) + testDispatcher.scheduler.runCurrent() + assertNull(savedStateHandle.get("draftMessage")) + + testDispatcher.scheduler.advanceTimeBy(1L) + testDispatcher.scheduler.runCurrent() + assertEquals("abc", savedStateHandle.get("draftMessage")) + } + + @Test + fun testClearDraftCancelsPendingPersistenceAndClearsImmediately() = runTest { + viewModel.setDraftMessage("pending") + testDispatcher.scheduler.runCurrent() + + viewModel.clearDraftMessage() + assertEquals("", viewModel.draftMessage.value) + assertEquals("", savedStateHandle.get("draftMessage")) + + testDispatcher.scheduler.advanceTimeBy(300L) + testDispatcher.scheduler.runCurrent() + assertEquals("", savedStateHandle.get("draftMessage")) + } + @Test fun testSetTitle() = runTest { viewModel.title.test { From 830e0960fb798df381dcd1f932026dd6776d6431 Mon Sep 17 00:00:00 2001 From: Jeremiah K <17190268+jeremiah-k@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:18:14 -0500 Subject: [PATCH 2/2] fix(messaging): preserve markdown styling through mentions Build mention replacements and inline-style spans from the immutable source buffer before applying presentation edits. Remap style boundaries through length-changing friendly-name substitutions so replacing @! tokens cannot shift or discard surrounding markdown. Keep presentation-only transformations from changing the wire text, and prevent display names or delimiters inside code spans from introducing unintended bold, italic, or strikethrough styling. Use deterministic span ordering and lint-clean boundary traversal for overlapping syntax cases. Cover mentions nested within markdown, markdown contained by mention substitutions, delimiter-bearing display names, code-span isolation, and replacement length changes. Keep candidate terminology aligned throughout the transformation pipeline. --- .../meshtastic/feature/messaging/Message.kt | 94 ++++++++++++++++--- .../messaging/MessageComposerBehaviorTest.kt | 62 ++++++++++++ 2 files changed, 144 insertions(+), 12 deletions(-) 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 f1765eac0d..1fcf32d27b 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 @@ -585,37 +585,107 @@ internal data class LiveStyleSpan(val range: IntRange, val style: InlineStyle) */ internal fun liveInlineMarkdownStyleRanges(source: String): List { if (!source.any { it in LIVE_STYLE_DELIMITER_SET }) return emptyList() + val codeMatches = LIVE_CODE.findAll(source).toList() + // Returns true when either delimiter boundary of this match falls inside a code span. Markdown entirely inside + // code, or malformed markdown crossing a code boundary, must not add styling. A code span nested inside outer + // emphasis is still allowed, so outer bold text can retain both its bold span and an inner code span. + val isBlockedByCodeSpan: (MatchResult) -> Boolean = { match -> + codeMatches.any { codeMatch -> match.range.first in codeMatch.range || match.range.last in codeMatch.range } + } return buildList { - LIVE_BOLD.findAll(source).forEach { add(LiveStyleSpan(it.groups[1]!!.range, InlineStyle.Bold)) } - LIVE_ITALIC.findAll(source).forEach { add(LiveStyleSpan(it.groups[1]!!.range, InlineStyle.Italic)) } - LIVE_STRIKE.findAll(source).forEach { add(LiveStyleSpan(it.groups[1]!!.range, InlineStyle.Strikethrough)) } - LIVE_CODE.findAll(source).forEach { add(LiveStyleSpan(it.groups[1]!!.range, InlineStyle.Code)) } + LIVE_BOLD.findAll(source).filterNot(isBlockedByCodeSpan).forEach { + add(LiveStyleSpan(it.groups[1]!!.range, InlineStyle.Bold)) + } + LIVE_ITALIC.findAll(source).filterNot(isBlockedByCodeSpan).forEach { + add(LiveStyleSpan(it.groups[1]!!.range, InlineStyle.Italic)) + } + LIVE_STRIKE.findAll(source).filterNot(isBlockedByCodeSpan).forEach { + add(LiveStyleSpan(it.groups[1]!!.range, InlineStyle.Strikethrough)) + } + codeMatches.forEach { add(LiveStyleSpan(it.groups[1]!!.range, InlineStyle.Code)) } } + .sortedWith(compareBy({ it.range.first }, { it.range.last }, { it.style.ordinal })) } private val LIVE_STYLE_DELIMITER_SET = setOf('*', '~', '`') +internal data class MentionReplacement(val range: IntRange, val text: String) + +internal data class MentionOutputPlan(val replacements: List, val styleSpans: List) + +/** + * Builds presentation edits from the immutable source buffer. Markdown is parsed before friendly-name replacement so + * display names containing `*`, `~`, or backticks cannot introduce formatting that was not present in the stored text. + */ +internal fun mentionOutputPlan(source: String, candidatesById: Map): MentionOutputPlan { + val replacements = + MENTION_TOKEN_REGEX.findAll(source) + .mapNotNull { match -> + val candidate = candidatesById[match.groupValues[1]] ?: return@mapNotNull null + MentionReplacement(match.range, "@" + candidate.longName.ifEmpty { candidate.shortName }) + } + .toList() + val styleSpans = remapStyleSpans(liveInlineMarkdownStyleRanges(source), replacements) + return MentionOutputPlan(replacements, styleSpans) +} + +/** Remaps source-buffer style ranges through non-overlapping mention replacements. */ +internal fun remapStyleSpans(spans: List, replacements: List): List { + if (spans.isEmpty() || replacements.isEmpty()) return spans + val sortedReplacements = replacements.sortedBy { it.range.first } + return spans.mapNotNull { span -> + val start = remapBoundary(span.range.first, sortedReplacements, preferReplacementEnd = false) + val endExclusive = remapBoundary(span.range.last + 1, sortedReplacements, preferReplacementEnd = true) + if (start >= endExclusive) null else span.copy(range = start until endExclusive) + } +} + +private fun remapBoundary( + sourceOffset: Int, + replacements: List, + preferReplacementEnd: Boolean, +): Int { + var delta = 0 + var mappedOffset: Int? = null + val iterator = replacements.iterator() + while (iterator.hasNext() && mappedOffset == null) { + val replacement = iterator.next() + val sourceStart = replacement.range.first + val sourceEndExclusive = replacement.range.last + 1 + mappedOffset = + when { + sourceOffset <= sourceStart -> sourceOffset + delta + + sourceOffset < sourceEndExclusive -> + sourceStart + delta + if (preferReplacementEnd) replacement.text.length else 0 + + else -> { + delta += replacement.text.length - (sourceEndExclusive - sourceStart) + null + } + } + } + return mappedOffset ?: sourceOffset + delta +} + /** * Displays `@!` tokens as `@FriendlyName` and applies live inline-markdown styling (bold/italic/strikethrough/ * code) while typing. Both are presentation-only via [OutputTransformation]: the stored buffer keeps the hex wire form * and the raw markdown delimiters, so the bytes sent are unchanged. */ @OptIn(ExperimentalFoundationApi::class) -private fun mentionOutputTransformation(nodesById: Map) = OutputTransformation { +private fun mentionOutputTransformation(candidatesById: Map) = OutputTransformation { val source = toString() // Fast path: plain text with no mention tokens or markdown delimiters — skip all scanning. val hasMention = source.indexOf('@') >= 0 val needsStyle = hasMention || source.any { it in LIVE_STYLE_DELIMITER_SET } if (!needsStyle) return@OutputTransformation - for (match in MENTION_TOKEN_REGEX.findAll(source).toList().asReversed()) { - val candidate = nodesById[match.groupValues[1]] ?: continue - replace(match.range.first, match.range.last + 1, "@" + candidate.longName.ifEmpty { candidate.shortName }) + val plan = mentionOutputPlan(source, candidatesById) + for (replacement in plan.replacements.asReversed()) { + replace(replacement.range.first, replacement.range.last + 1, replacement.text) } - // Rescan the post-replacement buffer — mention replacements may have changed text length, - // so the original source offsets are no longer valid. - val postMentions = toString() - for (span in liveInlineMarkdownStyleRanges(postMentions)) { + for (span in plan.styleSpans) { val spanStyle = when (span.style) { InlineStyle.Bold -> SpanStyle(fontWeight = FontWeight.Bold) diff --git a/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/MessageComposerBehaviorTest.kt b/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/MessageComposerBehaviorTest.kt index 2d32eb06da..99a8813d90 100644 --- a/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/MessageComposerBehaviorTest.kt +++ b/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/MessageComposerBehaviorTest.kt @@ -66,6 +66,68 @@ class MessageComposerBehaviorTest { ) } + @Test + fun `live markdown spans are returned in source order`() { + val source = "`code` before **bold**" + val spans = liveInlineMarkdownStyleRanges(source).map { span -> source.substring(span.range) to span.style } + + assertEquals(listOf("code" to InlineStyle.Code, "bold" to InlineStyle.Bold), spans) + } + + @Test + fun `live markdown parser ignores style delimiters inside code spans`() { + val source = "`**not bold** *not italic* ~~not strike~~`" + val spans = liveInlineMarkdownStyleRanges(source).map { span -> source.substring(span.range) to span.style } + + assertEquals(listOf("**not bold** *not italic* ~~not strike~~" to InlineStyle.Code), spans) + } + + @Test + fun `live markdown parser preserves style surrounding code spans`() { + val source = "**before `code` after**" + val spans = liveInlineMarkdownStyleRanges(source).map { span -> source.substring(span.range) to span.style } + + assertEquals(listOf("before `code` after" to InlineStyle.Bold, "code" to InlineStyle.Code), spans) + } + + @Test + fun `mention replacement remaps later markdown span`() { + val source = "@!aabbccdd **bold**" + val candidate = MentionCandidate(id = "!aabbccdd", longName = "A", shortName = "A") + val plan = mentionOutputPlan(source, mapOf(candidate.id to candidate)) + + assertEquals(listOf(MentionReplacement(0..9, "@A")), plan.replacements) + assertEquals(listOf(LiveStyleSpan(5..8, InlineStyle.Bold)), plan.styleSpans) + } + + @Test + fun `multiple mention replacements accumulate offsets before later markdown`() { + val source = "@!aabbccdd and @!11223344 then `code`" + val alpha = MentionCandidate(id = "!aabbccdd", longName = "A", shortName = "A") + val bravo = MentionCandidate(id = "!11223344", longName = "Long Bravo", shortName = "B") + val plan = mentionOutputPlan(source, mapOf(alpha.id to alpha, bravo.id to bravo)) + + assertEquals(listOf(LiveStyleSpan(25..28, InlineStyle.Code)), plan.styleSpans) + } + + @Test + fun `markdown surrounding mention expands to friendly display name`() { + val source = "**@!aabbccdd**" + val candidate = MentionCandidate(id = "!aabbccdd", longName = "Alpha", shortName = "A") + val plan = mentionOutputPlan(source, mapOf(candidate.id to candidate)) + + assertEquals(listOf(LiveStyleSpan(2..7, InlineStyle.Bold)), plan.styleSpans) + } + + @Test + fun `friendly mention delimiters do not introduce markdown styling`() { + val source = "@!aabbccdd plain" + val candidate = MentionCandidate(id = "!aabbccdd", longName = "**Alpha**", shortName = "A") + val plan = mentionOutputPlan(source, mapOf(candidate.id to candidate)) + + assertTrue(plan.styleSpans.isEmpty()) + } + @Test fun `bold delimiters are not double counted as italic`() { val spans = liveInlineMarkdownStyleRanges("**bold**")