fix(messaging): reduce composer recomposition and typing jank (#6318)

This commit is contained in:
Jeremiah K authored and GitHub committed 2026-07-23 15:21:07 +00:00
1 parent 19c61fc37a
commit bc5cf83df7
5 files changed
+367 -33

No files matched your search

+1
View File
@@ -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)
@@ -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,
@@ -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
}
}
@@ -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)
}
}
@@ -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 {