Disappearing messages, symmetric and from send time (§6.11)

The relay has had per-conversation lifetimes since the port and **no client
could set one** — its own comment says so. For E2E conversations the server
timer would not have helped anyway: it expires rows in the legacy plaintext
table, not the envelopes these apps actually exchange. So this is client-side,
which is also the only place it can be honest.

**Symmetric.** Either party sets the timer; the other is told inside the ratchet
and adopts it. A timer only the setter honoured would be worse than none — they
would believe their words were gone from a phone still holding them.

**Inside the ratchet**, so the relay never learns that a conversation is timed,
let alone for how long. It rides as ordinary control traffic carrying the `ctl`
flag from §6.10, which is what makes it safe to add at all: without that, this
envelope would surface in every older install as an "[unsupported attachment]"
bubble.

**Expiry runs on read**, in `Vault.history`. A sweep on a timer leaves expired
messages on disk between runs, and a disappearing message still sitting in the
vault is a claim rather than a feature. Reading is the one path every caller
shares, so nothing can see — or keep — an expired message.

⚠️ **Ages come from this device's own timestamp**, not one the peer supplies.
"From send time" in practice means when this device sent or received it, which
differs by seconds and cannot be manipulated: a peer who chose the clock could
backdate a message so it vanished from your phone immediately, or forward-date
one so it never expired.

⚠️ **A message with no timestamp is kept.** Those predate the field, and an
unknown age is not evidence of an old one — deleting somebody's history on a
guess cannot be undone.

The panel sits on the thread rather than in a menu, because "fewer taps to turn
it on" and "clearer per-conversation state" are the same problem: a timer nobody
can see is one nobody trusts. Presets only — arbitrary durations are the ones
nobody can reason about later.

engine 155, client 94, app 32.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-08-29 11:23:04 -04:00
co-authored by Claude Opus 4.8
parent c9e386ec0d
commit f9f063a211
8 changed files with 471 additions and 4 deletions
@@ -1369,6 +1369,11 @@ fun ColumnScope.ChatScreen(
// identities and no single answer, so this is for one-to-one threads only.
if (chat.peerId != null) IdentityPanel(model, chat)
// Directly on the thread, for the same reason as the identity panel: both
// are facts about this conversation, and a fact the user has to go looking
// for is one they will not rely on.
if (chat.peerId != null) DisappearingPanel(model, chat)
LazyColumn(
state = listState,
modifier = Modifier.fillMaxWidth().weight(1f).testTag("messages"),
@@ -2598,3 +2603,71 @@ fun RegisteredDevicesSection(
}
}
}
/**
* The disappearing-message timer for one conversation (§6.11).
*
* 🔴 **On the thread, not in a menu.** The request was "fewer taps to turn on
* and clearer per-conversation state", and both halves are the same problem: a
* timer nobody can see is a timer nobody trusts. This shows what is set and
* changes it in one tap.
*
* ⚠️ Both sides delete. The peer is told inside the ratchet and adopts the same
* timer, so the copy says "for both of you" — a timer only the setter honoured
* would be worse than none, because they would believe their words were gone
* from a device still holding them.
*/
@Composable
private fun ColumnScope.DisappearingPanel(
model: MessengerViewModel,
chat: MessengerViewModel.Phase.Chat,
) {
Column(modifier = Modifier.fillMaxWidth().padding(vertical = Space.sm)) {
Text("Disappearing messages", style = MaterialTheme.typography.titleSmall)
Row(
modifier = Modifier.fillMaxWidth().padding(top = Space.xs),
horizontalArrangement = Arrangement.spacedBy(Space.xs),
verticalAlignment = Alignment.CenterVertically,
) {
DISAPPEAR_CHOICES.forEach { (label, seconds) ->
KitChip(
text = label,
selected = chat.disappearSeconds == seconds,
onClick = { model.setDisappearing(seconds) },
tag = "disappear-$seconds",
)
}
}
Text(
if (chat.disappearSeconds == 0) {
"Messages stay until one of you deletes them."
} else {
"New and existing messages disappear from BOTH phones " +
"${DISAPPEAR_LABELS[chat.disappearSeconds] ?: "after the timer"} " +
"after they are sent. The relay is never told this is set."
},
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(top = Space.xs),
)
}
}
/**
* ⚠️ Presets, not a free-form duration. Every extra option is a decision the
* user has to make about something they mostly want to be roughly right, and
* the arbitrary values are the ones nobody can reason about later.
*/
private val DISAPPEAR_CHOICES = listOf(
"Off" to 0,
"1 hour" to 3_600,
"1 day" to 86_400,
"1 week" to 604_800,
)
private val DISAPPEAR_LABELS = mapOf(
3_600 to "an hour",
86_400 to "a day",
604_800 to "a week",
)
@@ -205,6 +205,15 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
val trust: IdentityTrust = IdentityTrust.UNKNOWN,
/** The number both people read aloud. Null until something is pinned. */
val safetyNumber: String? = null,
/**
* This conversation's disappearing-message timer, seconds, 0 = off.
*
* ⚠️ Shown on the thread rather than buried in a menu. A timer the
* user cannot see is one they cannot trust — and both sides of the
* conversation are deleting on it, so it is a fact about the room,
* not a preference.
*/
val disappearSeconds: Int = 0,
)
/**
@@ -553,6 +562,7 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
thread, peerName, current.history(thread), peerId = peerId,
trust = current.conversations.trust.state(peerId),
safetyNumber = current.conversations.trust.safetyNumber(peerId),
disappearSeconds = current.conversations.disappearSeconds(peerId),
),
// Opening the thread is what marks it read. Nothing is sent to
// the relay for this — read state is local, because a read
@@ -792,6 +802,35 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
}
}
// -- Disappearing messages (§6.11) ---------------------------------------
/**
* Set the timer for the open conversation, on both sides.
*
* ⚠️ The peer is told inside the ratchet and adopts it. That is what makes
* this worth offering: a timer only this device honoured would leave the
* user believing their words were gone from a phone still holding them.
*/
fun setDisappearing(seconds: Int) = viewModelScope.launch {
val current = session ?: return@launch
val chat = signedIn()?.openChat ?: return@launch
val peerId = chat.peerId ?: return@launch
val done = withContext(Dispatchers.IO) {
runCatching { current.conversations.setDisappearing(peerId, seconds) }
}
update { state ->
state.copy(
openChat = state.openChat?.copy(
disappearSeconds = current.conversations.disappearSeconds(peerId),
// Applying the timer expires anything already past it, so
// the thread has to be re-read rather than left on screen.
lines = current.history(chat.threadId),
),
error = done.exceptionOrNull()?.let(::describe),
)
}
}
// -- Presence ------------------------------------------------------------
/**