feat(messaging): per-contact drafts and a quieter composer (#6851)

This commit is contained in:
James Rich authored and GitHub committed 2026-08-24 10:43:59 -05:00
1 parent ef18af3610
commit 1ab4da0fa6
18 files changed
+2037 -21

No files matched your search

+1
View File
@@ -295,6 +295,7 @@ connected_sleeping
connecting
connection_status
connections
contact_draft_prefix
contrast
contrast_high
contrast_medium
@@ -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
@@ -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
@@ -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 = "",
)
@@ -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>
@@ -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
},
)
}
},
)
@@ -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
}
}
@@ -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,
)
@@ -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) {
@@ -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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 65 KiB