mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-09-12 21:30:02 -04:00
feat(messaging): per-contact drafts and a quieter composer (#6851)
This commit is contained in:
1 parent
ef18af3610
commit
1ab4da0fa6
18 files changed
+2037
-21
No files matched your search
Generated
+1
@@ -295,6 +295,7 @@ connected_sleeping
|
||||
connecting
|
||||
connection_status
|
||||
connections
|
||||
contact_draft_prefix
|
||||
contrast
|
||||
contrast_high
|
||||
contrast_medium
|
||||
|
||||
+8
@@ -508,6 +508,13 @@ class PacketRepositoryImpl(private val dbManager: DatabaseProvider, private val
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun setDraft(contactKey: String, draft: String) {
|
||||
withContext(dispatchers.io) { dbManager.withDb { it.packetDao().setDraft(contactKey, draft) } }
|
||||
}
|
||||
|
||||
override suspend fun getDraft(contactKey: String): String =
|
||||
withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().getDraft(contactKey).orEmpty() }
|
||||
|
||||
override suspend fun clearPacketDB() {
|
||||
withContext(dispatchers.io) { dbManager.withDb { it.packetDao().deleteAll() } }
|
||||
}
|
||||
@@ -533,6 +540,7 @@ class PacketRepositoryImpl(private val dbManager: DatabaseProvider, private val
|
||||
lastReadMessageTimestamp = lastReadMessageTimestamp,
|
||||
filteringDisabled = filteringDisabled,
|
||||
isMuted = isMuted,
|
||||
draft = draft,
|
||||
)
|
||||
|
||||
private fun Reaction.toEntity(myNodeNum: Int) = RoomReaction(
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
+2
-1
@@ -143,8 +143,9 @@ import org.meshtastic.core.database.entity.TracerouteNodePositionEntity
|
||||
// 52 -> 53 is the manual MIGRATION_52_53 (FTS rebuild), applied via configureCommon().
|
||||
AutoMigration(from = 53, to = 54),
|
||||
AutoMigration(from = 54, to = 55),
|
||||
AutoMigration(from = 55, to = 56),
|
||||
],
|
||||
version = 55,
|
||||
version = 56,
|
||||
exportSchema = true,
|
||||
)
|
||||
@androidx.room3.ConstructedBy(MeshtasticDatabaseConstructor::class)
|
||||
|
||||
@@ -528,6 +528,22 @@ interface PacketDao {
|
||||
@Query("UPDATE contact_settings SET muteUntil = :muteUntil WHERE contact_key IN (:contactKeys)")
|
||||
suspend fun updateMuteUntil(contactKeys: List<String>, muteUntil: Long)
|
||||
|
||||
@Query("UPDATE contact_settings SET draft = :draft WHERE contact_key = :contact")
|
||||
suspend fun updateDraft(contact: String, draft: String)
|
||||
|
||||
/**
|
||||
* Persists the unsent composer text for [contact], creating the settings row if absent. INSERT OR IGNORE plus a
|
||||
* targeted UPDATE, like [updateLastReadMessage], so mute and filtering survive without a read-modify-write.
|
||||
*/
|
||||
@Transaction
|
||||
suspend fun setDraft(contact: String, draft: String) {
|
||||
insertContactSettingsIgnore(listOf(ContactSettings(contact_key = contact)))
|
||||
updateDraft(contact, draft)
|
||||
}
|
||||
|
||||
@Query("SELECT draft FROM contact_settings WHERE contact_key = :contact LIMIT 1")
|
||||
suspend fun getDraft(contact: String): String?
|
||||
|
||||
@Transaction
|
||||
suspend fun setMuteUntil(contacts: List<String>, until: Long) {
|
||||
val absoluteMuteUntil =
|
||||
|
||||
@@ -175,6 +175,8 @@ data class ContactSettings(
|
||||
@ColumnInfo(name = "last_read_message_uuid") val lastReadMessageUuid: Long? = null,
|
||||
@ColumnInfo(name = "last_read_message_timestamp") val lastReadMessageTimestamp: Long? = null,
|
||||
@ColumnInfo(name = "filtering_disabled", defaultValue = "0") val filteringDisabled: Boolean = false,
|
||||
/** Unsent composer text for this conversation. Empty when there is nothing in progress. */
|
||||
@ColumnInfo(name = "draft", defaultValue = "''") val draft: String = "",
|
||||
) {
|
||||
val isMuted
|
||||
get() = nowMillis <= muteUntil
|
||||
|
||||
+65
@@ -156,6 +156,69 @@ class MeshtasticDatabaseMigrationTest {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 55→56 adds `contact_settings.draft`. [migrateAll] only proves the resulting schema validates from an empty
|
||||
* database; this proves an existing install's per-conversation state survives the addition — mute, last-read and
|
||||
* filtering are what stop a notification firing for a muted channel or re-announcing a message already read, so
|
||||
* they must not revert to defaults on upgrade. The new column must arrive as an empty string, because the draft UI
|
||||
* treats blank as "nothing in progress" and NULL would surface as a phantom draft row.
|
||||
*/
|
||||
@Test
|
||||
fun draftColumnAddedWithoutDisturbingContactSettings() = runTest {
|
||||
helper.createDatabase(DRAFT_COLUMN_FROM_VERSION).use { connection ->
|
||||
connection.execSQL(
|
||||
"INSERT INTO contact_settings (contact_key, muteUntil, last_read_message_uuid, " +
|
||||
"last_read_message_timestamp, filtering_disabled) VALUES ('0^all', 9999, 7, 5000, 1)",
|
||||
)
|
||||
connection.execSQL("INSERT INTO contact_settings (contact_key, muteUntil) VALUES ('0!abcdef01', 0)")
|
||||
}
|
||||
|
||||
helper.runMigrationsAndValidate(
|
||||
DRAFT_COLUMN_TO_VERSION,
|
||||
listOf(MeshtasticDatabase.MIGRATION_52_53),
|
||||
).use { connection ->
|
||||
assertEquals(
|
||||
listOf("0!abcdef01", "0^all"),
|
||||
queryColumn(connection, "SELECT contact_key FROM contact_settings ORDER BY contact_key"),
|
||||
)
|
||||
assertEquals(
|
||||
listOf("9999"),
|
||||
queryColumn(connection, "SELECT muteUntil FROM contact_settings " + "WHERE contact_key = '0^all'"),
|
||||
)
|
||||
assertEquals(
|
||||
listOf("7"),
|
||||
queryColumn(
|
||||
connection,
|
||||
"SELECT last_read_message_uuid FROM contact_settings " + "WHERE contact_key = '0^all'",
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
listOf("5000"),
|
||||
queryColumn(
|
||||
connection,
|
||||
"SELECT last_read_message_timestamp FROM contact_settings " + "WHERE contact_key = '0^all'",
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
listOf("1"),
|
||||
queryColumn(
|
||||
connection,
|
||||
"SELECT filtering_disabled FROM contact_settings " + "WHERE contact_key = '0^all'",
|
||||
),
|
||||
)
|
||||
// Empty, never NULL — blank is what the UI reads as "no draft".
|
||||
assertEquals(
|
||||
listOf("", ""),
|
||||
queryColumn(connection, "SELECT draft FROM contact_settings ORDER BY contact_key"),
|
||||
)
|
||||
connection.execSQL("UPDATE contact_settings SET draft = 'half typed' WHERE contact_key = '0^all'")
|
||||
assertEquals(
|
||||
listOf("half typed"),
|
||||
queryColumn(connection, "SELECT draft FROM contact_settings WHERE contact_key = '0^all'"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 50→51 makes the three `rssi` columns nullable, which Room implements by recreating `packet`, `reactions` and
|
||||
* `discovered_node` (DROP + RENAME). [migrateAll] only proves the resulting schema validates from an empty
|
||||
@@ -243,6 +306,8 @@ class MeshtasticDatabaseMigrationTest {
|
||||
const val FTS_REBUILD_TO_VERSION = 53
|
||||
const val MAINTENANCE_UF2_FROM_VERSION = 54
|
||||
const val MAINTENANCE_UF2_TO_VERSION = 55
|
||||
const val DRAFT_COLUMN_FROM_VERSION = 55
|
||||
const val DRAFT_COLUMN_TO_VERSION = 56
|
||||
|
||||
/** Room's runtime FTS content-sync triggers, verbatim from the generated MeshtasticDatabase_Impl. */
|
||||
val FTS_SYNC_TRIGGERS =
|
||||
|
||||
@@ -27,6 +27,8 @@ data class Contact(
|
||||
val isMuted: Boolean,
|
||||
val isUnmessageable: Boolean,
|
||||
val nodeColors: Pair<Int, Int>? = null,
|
||||
/** Unsent composer text for this conversation; empty when there is nothing in progress. */
|
||||
val draft: String = "",
|
||||
)
|
||||
|
||||
data class ContactSettings(
|
||||
@@ -36,4 +38,5 @@ data class ContactSettings(
|
||||
val lastReadMessageTimestamp: Long? = null,
|
||||
val filteringDisabled: Boolean = false,
|
||||
val isMuted: Boolean = false,
|
||||
val draft: String = "",
|
||||
)
|
||||
+5
@@ -231,6 +231,11 @@ interface PacketRepository {
|
||||
/** Disables or enables message filtering for a specific contact. */
|
||||
suspend fun setContactFilteringDisabled(contactKey: String, disabled: Boolean)
|
||||
|
||||
/** Persists unsent composer text for [contactKey] so it survives leaving the screen, and shows in the list. */
|
||||
suspend fun setDraft(contactKey: String, draft: String)
|
||||
|
||||
suspend fun getDraft(contactKey: String): String
|
||||
|
||||
/** Clears all packet and message history from the database. */
|
||||
suspend fun clearPacketDB()
|
||||
|
||||
|
||||
@@ -319,6 +319,7 @@
|
||||
<string name="connecting">Connecting</string>
|
||||
<string name="connection_status">Current connections:</string>
|
||||
<string name="connections">Connection</string>
|
||||
<string name="contact_draft_prefix">Draft: %1$s</string>
|
||||
<string name="contrast">Contrast</string>
|
||||
<string name="contrast_high">High</string>
|
||||
<string name="contrast_medium">Medium</string>
|
||||
|
||||
+34
-6
@@ -98,7 +98,6 @@ import org.meshtastic.core.model.Node
|
||||
import org.meshtastic.core.model.NodeAddress
|
||||
import org.meshtastic.core.model.util.getChannel
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.message_input_label
|
||||
import org.meshtastic.core.resources.send
|
||||
import org.meshtastic.core.resources.type_a_message
|
||||
import org.meshtastic.core.resources.unknown_channel
|
||||
@@ -127,6 +126,9 @@ private const val MAX_LINES = 3
|
||||
// Minimum draft length before the markdown formatting toolbar appears (matches the iOS client).
|
||||
private const val FORMATTING_TOOLBAR_MIN_CHARS = 3
|
||||
|
||||
// Byte counter appears only once the draft is within this much of the limit.
|
||||
private const val COUNTER_VISIBLE_WITHIN_BYTES = 20
|
||||
|
||||
/**
|
||||
* The main screen for displaying and sending messages to a contact or channel.
|
||||
*
|
||||
@@ -174,7 +176,7 @@ fun MessageScreen(
|
||||
var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
|
||||
var sharedContact by rememberSaveable { mutableStateOf<Node?>(null) }
|
||||
val selectedMessageIds = rememberSaveable { mutableStateOf(emptySet<Long>()) }
|
||||
val messageInputState = rememberTextFieldState(message.ifEmpty { viewModel.draftMessage.value })
|
||||
val messageInputState = rememberTextFieldState(message)
|
||||
val showQuickChat by viewModel.showQuickChat.collectAsStateWithLifecycle()
|
||||
val showFullMessageTimestamps by viewModel.showFullMessageTimestamps.collectAsStateWithLifecycle()
|
||||
val filteredCount by viewModel.filteredCount.collectAsStateWithLifecycle()
|
||||
@@ -188,6 +190,19 @@ fun MessageScreen(
|
||||
val translationAvailable by viewModel.translationAvailable.collectAsStateWithLifecycle()
|
||||
val translationDialogState by viewModel.translationDialogState.collectAsStateWithLifecycle()
|
||||
|
||||
// Read the stored draft before wiring the composer up, so its initial empty value cannot erase one.
|
||||
LaunchedEffect(contactKey) { viewModel.loadDraft(contactKey) }
|
||||
|
||||
val storedDraft by viewModel.draftMessage.collectAsStateWithLifecycle()
|
||||
|
||||
// Seed the composer once the draft arrives, unless the screen was opened with a message to prefill.
|
||||
LaunchedEffect(storedDraft) {
|
||||
val draft = storedDraft
|
||||
if (!draft.isNullOrEmpty() && messageInputState.text.isEmpty()) {
|
||||
messageInputState.setTextAndPlaceCursorAtEnd(draft)
|
||||
}
|
||||
}
|
||||
|
||||
// Sync text field changes back to ViewModel draft
|
||||
LaunchedEffect(messageInputState) {
|
||||
snapshotFlow { messageInputState.text.toString() }.collect { text -> viewModel.setDraftMessage(text) }
|
||||
@@ -800,7 +815,6 @@ private fun MessageInput(
|
||||
state = textFieldState,
|
||||
outputTransformation = mentionOutput,
|
||||
lineLimits = TextFieldLineLimits.MultiLine(1, MAX_LINES),
|
||||
label = { Text(stringResource(Res.string.message_input_label)) },
|
||||
enabled = isEnabled,
|
||||
shape = RoundedCornerShape(ROUNDED_CORNER_PERCENT.toFloat()),
|
||||
isError = isOverLimit,
|
||||
@@ -809,7 +823,9 @@ private fun MessageInput(
|
||||
KeyboardOptions(capitalization = KeyboardCapitalization.Sentences, imeAction = ImeAction.Send),
|
||||
onKeyboardAction = { onSendAction() },
|
||||
supportingText = {
|
||||
if (isEnabled) { // Only show supporting text if input is enabled
|
||||
// The counter is only useful as the limit approaches. Showing 0/200 before a character is typed is
|
||||
// chrome that every chat client has learned to hide.
|
||||
if (isEnabled && currentByteLength >= maxByteSize - COUNTER_VISIBLE_WITHIN_BYTES) {
|
||||
Text(
|
||||
text = "$currentByteLength/$maxByteSize",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
@@ -829,8 +845,20 @@ private fun MessageInput(
|
||||
// If strict real-time byte trimming is required, it needs careful handling of
|
||||
// cursor position and multi-byte characters, likely outside simple inputTransformation.
|
||||
trailingIcon = {
|
||||
IconButton(onClick = onSendAction, enabled = canSend || mentionActive) {
|
||||
Icon(imageVector = MeshtasticIcons.Send, contentDescription = stringResource(Res.string.send))
|
||||
// Colour, not just enablement, carries "this will send" — a greyed-out icon reads as broken rather
|
||||
// than as waiting for input.
|
||||
val sendEnabled = isEnabled && (canSend || mentionActive)
|
||||
IconButton(onClick = onSendAction, enabled = sendEnabled) {
|
||||
Icon(
|
||||
imageVector = MeshtasticIcons.Send,
|
||||
contentDescription = stringResource(Res.string.send),
|
||||
tint =
|
||||
if (sendEnabled) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
+46
-8
@@ -39,6 +39,7 @@ import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koin.core.annotation.KoinViewModel
|
||||
import org.meshtastic.core.common.util.currentLocaleCode
|
||||
import org.meshtastic.core.common.util.ioDispatcher
|
||||
@@ -113,26 +114,56 @@ class MessageViewModel(
|
||||
private val _title = MutableStateFlow("")
|
||||
val title: StateFlow<String> = _title.asStateFlow()
|
||||
|
||||
private val _draftMessage = MutableStateFlow(savedStateHandle.get<String>("draftMessage") ?: "")
|
||||
val draftMessage: StateFlow<String> = _draftMessage.asStateFlow()
|
||||
private val _draftMessage = MutableStateFlow<String?>(null)
|
||||
|
||||
/** Persisted draft for the conversation on screen. Null until [loadDraft] has read it back. */
|
||||
val draftMessage: StateFlow<String?> = _draftMessage.asStateFlow()
|
||||
|
||||
private val sendErrorEvents = errorEventFlow()
|
||||
|
||||
private var pendingDraftPersistence: Job? = null
|
||||
|
||||
private var draftContactKey: String? = 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.
|
||||
* Reads the stored draft for [contactKey] once per conversation.
|
||||
*
|
||||
* [SavedStateHandle] wins over the database when it holds something, because it can carry keystrokes newer than the
|
||||
* last debounced write — the database copy is what makes the draft visible to the conversation list and survive the
|
||||
* screen being popped.
|
||||
*/
|
||||
fun loadDraft(contactKey: String) {
|
||||
if (draftContactKey == contactKey) return
|
||||
draftContactKey = contactKey
|
||||
_draftMessage.value = null
|
||||
safeLaunch(context = ioDispatcher, tag = "loadDraft") {
|
||||
val restored = savedStateHandle.get<String>(draftKey(contactKey))
|
||||
val loaded = restored?.takeIf { it.isNotEmpty() } ?: packetRepository.getDraft(contactKey)
|
||||
// Two loads can be in flight after a fast switch between conversations; only the one still current may
|
||||
// publish, or one conversation's unsent text surfaces in another.
|
||||
if (draftContactKey == contactKey) _draftMessage.value = loaded
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the in-memory draft immediately; the durable writes are debounced by [DRAFT_PERSISTENCE_DELAY_MS], so
|
||||
* rapid edits cancel the prior pending flush and only the trailing value persists. On process death within that
|
||||
* window the last ≤[DRAFT_PERSISTENCE_DELAY_MS] of typing is not durably saved — a conscious trade-off to avoid
|
||||
* per-keystroke jank.
|
||||
*
|
||||
* No-ops while [draftMessage] is still null, so the composer's initial empty value cannot erase a stored draft
|
||||
* before it has been read back.
|
||||
*/
|
||||
fun setDraftMessage(text: String) {
|
||||
if (_draftMessage.value == null) return
|
||||
_draftMessage.value = text
|
||||
val contactKey = draftContactKey ?: return
|
||||
pendingDraftPersistence?.cancel()
|
||||
pendingDraftPersistence =
|
||||
viewModelScope.launch {
|
||||
delay(DRAFT_PERSISTENCE_DELAY_MS)
|
||||
savedStateHandle["draftMessage"] = text
|
||||
savedStateHandle[draftKey(contactKey)] = text
|
||||
withContext(ioDispatcher) { packetRepository.setDraft(contactKey, text) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,7 +171,10 @@ class MessageViewModel(
|
||||
_draftMessage.value = ""
|
||||
pendingDraftPersistence?.cancel()
|
||||
pendingDraftPersistence = null
|
||||
savedStateHandle["draftMessage"] = ""
|
||||
draftContactKey?.let { contactKey ->
|
||||
savedStateHandle[draftKey(contactKey)] = ""
|
||||
safeLaunch(context = ioDispatcher, tag = "clearDraft") { packetRepository.setDraft(contactKey, "") }
|
||||
}
|
||||
}
|
||||
|
||||
val ourNodeInfo = nodeRepository.ourNodeInfo
|
||||
@@ -462,5 +496,9 @@ class MessageViewModel(
|
||||
private const val SEARCH_DEBOUNCE_MS = 300L
|
||||
private const val MIN_SEARCH_LENGTH = 2
|
||||
private const val DRAFT_PERSISTENCE_DELAY_MS = 300L
|
||||
private const val KEY_DRAFT_MESSAGE_PREFIX = "draftMessage:"
|
||||
|
||||
/** Saved-state drafts are keyed per conversation; one shared entry would leak text between them. */
|
||||
private fun draftKey(contactKey: String) = KEY_DRAFT_MESSAGE_PREFIX + contactKey
|
||||
}
|
||||
}
|
||||
+12
-1
@@ -52,9 +52,12 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.common.util.DateFormatter
|
||||
import org.meshtastic.core.model.Contact
|
||||
import org.meshtastic.core.model.ContactKey
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.contact_draft_prefix
|
||||
import org.meshtastic.core.ui.component.SecurityIcon
|
||||
import org.meshtastic.core.ui.icon.MeshtasticIcons
|
||||
import org.meshtastic.core.ui.icon.VolumeOff
|
||||
@@ -171,10 +174,18 @@ private fun ChatMetadata(contact: Contact, modifier: Modifier = Modifier) {
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
// An unsent draft outranks the last message: it is the thing this row is waiting on the user for.
|
||||
val hasDraft = contact.draft.isNotBlank()
|
||||
Text(
|
||||
text = contact.lastMessageText.orEmpty(),
|
||||
text =
|
||||
if (hasDraft) {
|
||||
stringResource(Res.string.contact_draft_prefix, contact.draft)
|
||||
} else {
|
||||
contact.lastMessageText.orEmpty()
|
||||
},
|
||||
modifier = Modifier.weight(1f).semantics { isSensitiveData = true },
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (hasDraft) MaterialTheme.colorScheme.tertiary else Color.Unspecified,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 2,
|
||||
)
|
||||
|
||||
+1
@@ -124,6 +124,7 @@ class ContactsViewModel(
|
||||
unreadCount = packetRepository.getUnreadCount(contactKey),
|
||||
messageCount = packetRepository.getMessageCount(contactKey),
|
||||
isMuted = settings[contactKey]?.isMuted == true,
|
||||
draft = settings[contactKey]?.draft.orEmpty(),
|
||||
isUnmessageable = user.is_unmessagable ?: false,
|
||||
nodeColors =
|
||||
if (!toBroadcast) {
|
||||
|
||||
+34
-5
@@ -150,8 +150,34 @@ class MessageViewModelTest {
|
||||
|
||||
@Test fun testInitialization() = runTest { assertNotNull(viewModel) }
|
||||
|
||||
private val draftContact = "0!12345678"
|
||||
|
||||
/** Draft edits are ignored until the stored value has been read back, so every draft test loads first. */
|
||||
private suspend fun loadDraftAndAwait(stored: String = "") {
|
||||
everySuspend { packetRepository.getDraft(draftContact) } returns stored
|
||||
viewModel.draftMessage.test {
|
||||
assertNull(awaitItem())
|
||||
viewModel.loadDraft(draftContact)
|
||||
assertEquals(stored, awaitItem())
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun testDraftIsRestoredFromTheRepository() = runTest { loadDraftAndAwait(stored = "half typed") }
|
||||
|
||||
@Test
|
||||
fun testDraftEditsAreIgnoredBeforeTheStoredValueIsRead() = runTest {
|
||||
// The composer reports its initial empty value as soon as it composes; that must not erase a stored draft.
|
||||
viewModel.setDraftMessage("")
|
||||
assertNull(viewModel.draftMessage.value)
|
||||
|
||||
loadDraftAndAwait(stored = "survived")
|
||||
assertEquals("survived", viewModel.draftMessage.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDraftPersistenceDebouncesRapidEdits() = runTest {
|
||||
loadDraftAndAwait()
|
||||
viewModel.setDraftMessage("a")
|
||||
testDispatcher.scheduler.runCurrent()
|
||||
testDispatcher.scheduler.advanceTimeBy(100L)
|
||||
@@ -164,29 +190,32 @@ class MessageViewModelTest {
|
||||
testDispatcher.scheduler.runCurrent()
|
||||
|
||||
assertEquals("abc", viewModel.draftMessage.value)
|
||||
assertNull(savedStateHandle.get<String>("draftMessage"))
|
||||
assertNull(savedStateHandle.get<String>("draftMessage:$draftContact"))
|
||||
|
||||
testDispatcher.scheduler.advanceTimeBy(299L)
|
||||
testDispatcher.scheduler.runCurrent()
|
||||
assertNull(savedStateHandle.get<String>("draftMessage"))
|
||||
assertNull(savedStateHandle.get<String>("draftMessage:$draftContact"))
|
||||
|
||||
testDispatcher.scheduler.advanceTimeBy(1L)
|
||||
testDispatcher.scheduler.runCurrent()
|
||||
assertEquals("abc", savedStateHandle.get<String>("draftMessage"))
|
||||
assertEquals("abc", savedStateHandle.get<String>("draftMessage:$draftContact"))
|
||||
advanceUntilIdle()
|
||||
verifySuspend { packetRepository.setDraft(draftContact, "abc") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testClearDraftCancelsPendingPersistenceAndClearsImmediately() = runTest {
|
||||
loadDraftAndAwait()
|
||||
viewModel.setDraftMessage("pending")
|
||||
testDispatcher.scheduler.runCurrent()
|
||||
|
||||
viewModel.clearDraftMessage()
|
||||
assertEquals("", viewModel.draftMessage.value)
|
||||
assertEquals("", savedStateHandle.get<String>("draftMessage"))
|
||||
assertEquals("", savedStateHandle.get<String>("draftMessage:$draftContact"))
|
||||
|
||||
testDispatcher.scheduler.advanceTimeBy(300L)
|
||||
testDispatcher.scheduler.runCurrent()
|
||||
assertEquals("", savedStateHandle.get<String>("draftMessage"))
|
||||
assertEquals("", savedStateHandle.get<String>("draftMessage:$draftContact"))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 88 KiB |
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 65 KiB |
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 65 KiB |
Reference in new issue
Block a user