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:
co-authored by
Claude Opus 4.8
parent
c9e386ec0d
commit
f9f063a211
@@ -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 ------------------------------------------------------------
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user