Keep a half-written message across a relaunch

Sealed in the vault, not in SharedPreferences — and that is the whole
point. A draft is plaintext the person has not decided to send, very
often the most sensitive text in the app because it is the thing they
are still weighing. SharedPreferences writes readable XML that survives
into backups; the vault seals it under the conversation's own key with
the record name bound into the AAD, and forgetEverything takes it on
sign-out. There is a test that asserts the bytes on disk do not contain
the text, because the wrong implementation would pass every other test
here.

Keyed by the same threadFor* ids the transcript uses, so a group called
"7", the contact who is user 7, and the address "7" are three
different drafts.

Every write to the composer state goes through one setDraft: send clears
it by assigning directly in three places, and any one of those that
forgot to clear the stored copy would bring the message back next time
as a draft of something already sent.

Closes #47 on iOS side; Android half of the same feature

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-02 15:50:01 -04:00
co-authored by Claude Opus 4.8
parent 1fc1a561b3
commit f95cb1e6aa
5 changed files with 285 additions and 5 deletions
@@ -1619,7 +1619,18 @@ fun ColumnScope.ChatScreen(
/** Pictures, so a received bubble can carry its author's. */
avatars: Map<Long, io.flamenet.e2e.Attachment.Ref> = emptyMap(),
) {
var draft by remember { mutableStateOf("") }
// ⚠️ Keyed on the thread, so switching conversations re-reads rather than
// carrying one person's half-written message into somebody else's composer.
var draft by remember(chat.threadId) { mutableStateOf(model.openDraft()) }
// 🔴 **Every write to `draft` goes through here.** Sending clears the
// composer by assigning to the state directly in three places, and each one
// that forgot to also clear the stored copy would bring the message back the
// next time the conversation was opened — as a draft of something already
// sent, which is worse than not having drafts at all.
val setDraft: (String) -> Unit = { text ->
draft = text
model.rememberDraft(text)
}
val listState = rememberLazyListState()
// Open AT the newest message — a jump, not a tour of the history.
@@ -1691,7 +1702,7 @@ fun ColumnScope.ChatScreen(
onClick = { model.requestCall(chat.peerId) },
)
}
if (chat.peerId != null) ConversationMenu(model, chat, draft) { draft = "" }
if (chat.peerId != null) ConversationMenu(model, chat, draft) { setDraft("") }
chat.group?.let { group ->
IconAction(
icon = KitIcon.LeaveGroup,
@@ -1848,7 +1859,7 @@ fun ColumnScope.ChatScreen(
}
BasicTextField(
value = draft,
onValueChange = { draft = it },
onValueChange = setDraft,
textStyle = LocalTextStyle.current.copy(
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurface,
@@ -1863,7 +1874,7 @@ fun ColumnScope.ChatScreen(
onSend = {
if (draft.isNotBlank()) {
model.sendMessage(draft)
draft = ""
setDraft("")
}
}
),
@@ -1922,7 +1933,7 @@ fun ColumnScope.ChatScreen(
Button(
onClick = {
model.sendMessage(draft)
draft = ""
setDraft("")
},
shape = CircleShape,
contentPadding = PaddingValues(0.dp),
@@ -2194,6 +2194,37 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
}.onFailure { failure -> update { it.copy(error = describe(failure)) } }
}
/**
* How the open conversation is named in the vault — the same ids the
* transcript uses, so a group, a local contact and somebody on another
* relay cannot collide.
*/
private fun Phase.Chat.vaultThreadId(): String = when {
group != null -> Vault.threadForGroup(group.id)
address != null -> Vault.threadForAddress(address)
peerId != null -> Vault.threadForUser(peerId)
else -> threadId
}
/** What was half-written in the open conversation, restored on open. */
fun openDraft(): String {
val chat = signedIn()?.openChat ?: return ""
return vault?.draft(chat.vaultThreadId()).orEmpty()
}
/**
* Keep, or forget, what is in the composer.
*
* ⚠️ Called on every change including back to empty — clearing the
* composer has to clear what was stored, or a draft somebody deliberately
* deleted reappears the next time they open the conversation.
*/
fun rememberDraft(text: String) {
val chat = signedIn()?.openChat ?: return
val vault = vault ?: return
viewModelScope.launch(Dispatchers.IO) { vault.saveDraft(chat.vaultThreadId(), text) }
}
fun sendMessage(text: String) = viewModelScope.launch {
val current = session ?: return@launch
val chat = signedIn()?.openChat ?: return@launch