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:
co-authored by
Claude Opus 4.8
parent
96b612597d
commit
9a2ff570e9
@@ -1623,7 +1623,18 @@ fun ColumnScope.ChatScreen(
|
||||
/** Ours, so the mention list never offers you yourself. */
|
||||
myUserId: Long = 0L,
|
||||
) {
|
||||
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)
|
||||
}
|
||||
// 🔴 Recorded by ID at the moment of picking, never re-derived from the
|
||||
// text when sending. Deriving it would mean typing somebody's name reached
|
||||
// them whether or not they were chosen, and renaming a contact would
|
||||
@@ -1700,7 +1711,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,
|
||||
@@ -1921,7 +1932,10 @@ fun ColumnScope.ChatScreen(
|
||||
.filter { draft.contains("@" + memberLabel(it, buddies)) }
|
||||
.map { it.toInt() }
|
||||
model.sendMessage(draft, named)
|
||||
draft = ""
|
||||
// ⚠️ `setDraft`, not `draft = ""`. Clearing only the composer leaves
|
||||
// the stored copy behind, and the message comes back next time as a
|
||||
// draft of something already sent.
|
||||
setDraft("")
|
||||
pendingMentions = emptySet()
|
||||
}
|
||||
|
||||
@@ -1951,7 +1965,7 @@ fun ColumnScope.ChatScreen(
|
||||
}
|
||||
BasicTextField(
|
||||
value = draft,
|
||||
onValueChange = { draft = it },
|
||||
onValueChange = setDraft,
|
||||
textStyle = LocalTextStyle.current.copy(
|
||||
fontSize = 14.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
|
||||
@@ -2279,6 +2279,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, mentions: List<Int> = emptyList()) = viewModelScope.launch {
|
||||
val current = session ?: return@launch
|
||||
val chat = signedIn()?.openChat ?: return@launch
|
||||
|
||||
Reference in New Issue
Block a user