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..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 @@ -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, + 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() + 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({ 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 } /** @@ -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) = 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) = 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) = 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, + mentionCandidates: ImmutableMap, 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() - 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 +832,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 +868,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 +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, 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..99a8813d90 --- /dev/null +++ b/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/MessageComposerBehaviorTest.kt @@ -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 . + */ +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) + } +} 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 {