mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-09-15 06:39:33 -04:00
fix(messaging): reduce composer recomposition and typing jank (#6318)
This commit is contained in:
1 parent
19c61fc37a
commit
bc5cf83df7
5 files changed
+367
-33
No files matched your search
@@ -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)
|
||||
|
||||
+169
-32
@@ -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,12 +540,132 @@ 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<MentionCandidate>,
|
||||
query: String,
|
||||
limit: Int = MENTION_SUGGESTION_LIMIT,
|
||||
): List<MentionCandidate> = 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("(?<!\\*)\\*([^*]+)\\*(?!\\*)") // single *, not part of **
|
||||
private val LIVE_STRIKE = Regex("~~([^~]+)~~")
|
||||
private val LIVE_CODE = Regex("`([^`]+)`")
|
||||
|
||||
internal data class LiveStyleSpan(val range: IntRange, val style: InlineStyle)
|
||||
|
||||
/**
|
||||
* Detects live-markdown spans in [source] using simple regex patterns instead of a full GFM AST parser.
|
||||
*
|
||||
* The live composer intentionally previews only asterisk emphasis, strikethrough, and inline code. Underscore emphasis
|
||||
* remains raw while editing, but the sent message still uses the full Markdown renderer.
|
||||
*/
|
||||
internal fun liveInlineMarkdownStyleRanges(source: String): List<LiveStyleSpan> {
|
||||
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).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<LiveStyleSpan>({ 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<MentionReplacement>, val styleSpans: List<LiveStyleSpan>)
|
||||
|
||||
/**
|
||||
* 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<String, MentionCandidate>): 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<LiveStyleSpan>, replacements: List<MentionReplacement>): List<LiveStyleSpan> {
|
||||
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<MentionReplacement>,
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -545,13 +674,18 @@ private fun Node.matchesMention(query: String): Boolean {
|
||||
* and the raw markdown delimiters, so the bytes sent are unchanged.
|
||||
*/
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
private fun mentionOutputTransformation(nodesById: Map<String, Node>) = 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(candidatesById: Map<String, MentionCandidate>) = 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
|
||||
|
||||
val plan = mentionOutputPlan(source, candidatesById)
|
||||
for (replacement in plan.replacements.asReversed()) {
|
||||
replace(replacement.range.first, replacement.range.last + 1, replacement.text)
|
||||
}
|
||||
// Live markdown styling over the (mention-substituted) buffer. Delimiters stay visible; only spans are added.
|
||||
for (span in inlineMarkdownStyleRanges(toString())) {
|
||||
for (span in plan.styleSpans) {
|
||||
val spanStyle =
|
||||
when (span.style) {
|
||||
InlineStyle.Bold -> SpanStyle(fontWeight = FontWeight.Bold)
|
||||
@@ -569,7 +703,8 @@ private fun mentionOutputTransformation(nodesById: Map<String, Node>) = 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 +715,7 @@ private fun MessageInput(
|
||||
isEnabled: Boolean,
|
||||
isHomoglyphEncodingEnabled: Boolean,
|
||||
textFieldState: TextFieldState,
|
||||
nodes: List<Node>,
|
||||
mentionCandidates: ImmutableMap<String, MentionCandidate>,
|
||||
modifier: Modifier = Modifier,
|
||||
maxByteSize: Int = MESSAGE_CHARACTER_LIMIT_BYTES,
|
||||
onSendMessage: () -> Unit,
|
||||
@@ -603,24 +738,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<Node>()
|
||||
nodes.filter { it.matchesMention(query) }.take(MENTION_SUGGESTION_LIMIT)
|
||||
remember(mentionQuery, mentionCandidates) {
|
||||
val query = mentionQuery?.query ?: return@remember emptyList<MentionCandidate>()
|
||||
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 +832,29 @@ private fun MessageInput(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MentionSuggestions(suggestions: List<Node>, onPick: (Node) -> Unit) {
|
||||
private fun MentionSuggestions(suggestions: List<MentionCandidate>, 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 +868,14 @@ private fun MentionSuggestions(suggestions: List<Node>, onPick: (Node) -> Unit)
|
||||
@PreviewLightDark
|
||||
@Composable
|
||||
fun MessageInputPreview() {
|
||||
val mentionCandidates = persistentMapOf<String, MentionCandidate>()
|
||||
AppTheme {
|
||||
Surface {
|
||||
Column(modifier = Modifier.padding(8.dp)) {
|
||||
MessageInput(
|
||||
isEnabled = true,
|
||||
isHomoglyphEncodingEnabled = false,
|
||||
nodes = emptyList(),
|
||||
mentionCandidates = mentionCandidates,
|
||||
textFieldState = rememberTextFieldState("Hello"),
|
||||
onSendMessage = {},
|
||||
)
|
||||
@@ -746,7 +883,7 @@ fun MessageInputPreview() {
|
||||
MessageInput(
|
||||
isEnabled = false,
|
||||
isHomoglyphEncodingEnabled = false,
|
||||
nodes = emptyList(),
|
||||
mentionCandidates = mentionCandidates,
|
||||
textFieldState = rememberTextFieldState("Disabled"),
|
||||
onSendMessage = {},
|
||||
)
|
||||
@@ -754,7 +891,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 +905,7 @@ fun MessageInputPreview() {
|
||||
MessageInput(
|
||||
isEnabled = true,
|
||||
isHomoglyphEncodingEnabled = false,
|
||||
nodes = emptyList(),
|
||||
mentionCandidates = mentionCandidates,
|
||||
textFieldState = rememberTextFieldState("こんにちは世界"), // Hello World in Japanese
|
||||
onSendMessage = {},
|
||||
maxByteSize = 10,
|
||||
|
||||
+20
-1
@@ -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<String>("draftMessage") ?: "")
|
||||
val draftMessage: StateFlow<String> = _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
|
||||
}
|
||||
}
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
/*
|
||||
* 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 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 `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**")
|
||||
|
||||
assertEquals(listOf(LiveStyleSpan(2..5, InlineStyle.Bold)), spans)
|
||||
}
|
||||
}
|
||||
+40
@@ -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<String>("draftMessage"))
|
||||
|
||||
testDispatcher.scheduler.advanceTimeBy(299L)
|
||||
testDispatcher.scheduler.runCurrent()
|
||||
assertNull(savedStateHandle.get<String>("draftMessage"))
|
||||
|
||||
testDispatcher.scheduler.advanceTimeBy(1L)
|
||||
testDispatcher.scheduler.runCurrent()
|
||||
assertEquals("abc", savedStateHandle.get<String>("draftMessage"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testClearDraftCancelsPendingPersistenceAndClearsImmediately() = runTest {
|
||||
viewModel.setDraftMessage("pending")
|
||||
testDispatcher.scheduler.runCurrent()
|
||||
|
||||
viewModel.clearDraftMessage()
|
||||
assertEquals("", viewModel.draftMessage.value)
|
||||
assertEquals("", savedStateHandle.get<String>("draftMessage"))
|
||||
|
||||
testDispatcher.scheduler.advanceTimeBy(300L)
|
||||
testDispatcher.scheduler.runCurrent()
|
||||
assertEquals("", savedStateHandle.get<String>("draftMessage"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSetTitle() = runTest {
|
||||
viewModel.title.test {
|
||||
|
||||
Reference in new issue
Block a user