The triage pair: snooze, and "remind me if no reply"
Two halves of the same problem — a conversation you are not ready for, and one you are waiting on — and both are local to this device on purpose. "I am ignoring this person until Tuesday" and "remind me if they have not answered" are statements about a relationship, and the relay has no business holding either. The honest cost is that neither follows you to a new phone; the alternative is handing a server the list of people you are avoiding. 🔴 **A reply arriving cancels the reminder, and it happens in `Session.record`** — the one place every inbound message passes through. Hanging it off a screen or a poll handler means the one path that forgot to call it produces a reminder that fires after they answered, and that is the single thing that would teach people to ignore reminders. The service fires due reminders from its existing loop, deliberately AFTER `receive()`: a reply that has just landed cancels its own reminder before it can go off. Checking first would nudge about a conversation answered a moment ago. Cleared as it fires, too — a reminder is one-shot, and leaving the row would re-notify every half second for ever. ⚠️ Snoozes are recomputed, never cached as a flag. A snooze expires by the clock passing and nothing runs at that moment, so a stored boolean leaves a conversation muted past its own deadline. ⚠️ Same privacy rule as message notifications: a count, never a name. Who you are waiting on is as sensitive as what they said. engine 163, client 96, app 35. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
020eba2228
commit
3f0dfd669c
@@ -63,6 +63,8 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
|
||||
private const val CALL_LOG = "call-log"
|
||||
private const val STORY_AUDIENCE = "story-audience"
|
||||
private const val DISAPPEAR = "disappear"
|
||||
private const val SNOOZE = "snooze"
|
||||
private const val REPLY_REMINDER = "reply-reminder"
|
||||
private const val ROSTER = "roster"
|
||||
private const val DEVICE_OWNER_PREFIX = "devowner/"
|
||||
private const val KT_ISSUER_PIN = "kt-issuer-pin"
|
||||
@@ -533,6 +535,56 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
|
||||
writeJson(historyName(threadId), JSONObject().put("messages", existing))
|
||||
}
|
||||
|
||||
// -- Attention management: snooze and reply reminders ---------------------
|
||||
|
||||
/**
|
||||
* When this conversation stops being snoozed, epoch millis. 0 = not snoozed.
|
||||
*
|
||||
* ⚠️ In the vault and nowhere else. "I am ignoring this person until
|
||||
* Tuesday" is a statement about a relationship, and it is exactly the kind
|
||||
* of thing a server has no business holding. It also means a snooze does
|
||||
* not follow you to a new device, which is the honest trade — the
|
||||
* alternative is telling the relay who you are avoiding.
|
||||
*/
|
||||
fun snoozedUntil(threadId: String): Long =
|
||||
readJson(SNOOZE)?.optLong(threadId, 0L) ?: 0L
|
||||
|
||||
fun saveSnoozedUntil(threadId: String, until: Long) {
|
||||
val all = readJson(SNOOZE) ?: JSONObject()
|
||||
if (until <= 0L) all.remove(threadId) else all.put(threadId, until)
|
||||
writeJson(SNOOZE, all)
|
||||
}
|
||||
|
||||
/** Thread ids whose snooze has not yet run out at [now]. */
|
||||
fun snoozedThreads(now: Long): Set<String> {
|
||||
val all = readJson(SNOOZE) ?: return emptySet()
|
||||
return all.keys().asSequence().filter { all.optLong(it, 0L) > now }.toSet()
|
||||
}
|
||||
|
||||
/**
|
||||
* When to remind about an unanswered message, epoch millis. 0 = no reminder.
|
||||
*
|
||||
* 🔴 **Cleared by a reply arriving, not by the reminder firing.** The whole
|
||||
* point is "remind me if they have not answered", so a reminder that still
|
||||
* fires after they did is worse than none — it trains people to ignore it.
|
||||
*/
|
||||
fun replyReminder(threadId: String): Long =
|
||||
readJson(REPLY_REMINDER)?.optLong(threadId, 0L) ?: 0L
|
||||
|
||||
fun saveReplyReminder(threadId: String, at: Long) {
|
||||
val all = readJson(REPLY_REMINDER) ?: JSONObject()
|
||||
if (at <= 0L) all.remove(threadId) else all.put(threadId, at)
|
||||
writeJson(REPLY_REMINDER, all)
|
||||
}
|
||||
|
||||
/** Reminders that are due at [now], as thread id -> when it was set for. */
|
||||
fun dueReplyReminders(now: Long): Map<String, Long> {
|
||||
val all = readJson(REPLY_REMINDER) ?: return emptyMap()
|
||||
return all.keys().asSequence()
|
||||
.mapNotNull { id -> all.optLong(id, 0L).takeIf { it in 1..now }?.let { id to it } }
|
||||
.toMap()
|
||||
}
|
||||
|
||||
// -- Disappearing messages (§6.11) ---------------------------------------
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
package io.flamenet.e2e
|
||||
|
||||
import javax.crypto.KeyGenerator
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Snooze and reply reminders, at the vault.
|
||||
*
|
||||
* ⚠️ **Both live here and nowhere else.** "I am ignoring this person until
|
||||
* Tuesday" and "remind me if they have not answered" are statements about a
|
||||
* relationship, and a server has no business holding either. The honest cost is
|
||||
* that neither follows you to a new device — the alternative is telling the
|
||||
* relay who you are avoiding.
|
||||
*/
|
||||
class AttentionStoreTest {
|
||||
|
||||
private class MapStorage : Vault.Storage {
|
||||
private val records = mutableMapOf<String, ByteArray>()
|
||||
override fun read(name: String) = records[name]
|
||||
override fun write(name: String, bytes: ByteArray) { records[name] = bytes }
|
||||
override fun delete(name: String) { records.remove(name) }
|
||||
override fun names() = records.keys.toList()
|
||||
}
|
||||
|
||||
private fun vault() = Vault(
|
||||
MapStorage(), KeyGenerator.getInstance("AES").apply { init(256) }.generateKey())
|
||||
|
||||
private val now = 1_700_000_000_000L
|
||||
|
||||
// -- Snooze --------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `a thread is not snoozed by default`() {
|
||||
assertEquals(0L, vault().snoozedUntil("t"))
|
||||
assertTrue(vault().snoozedThreads(now).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a snoozed thread is listed until its time runs out`() {
|
||||
val v = vault()
|
||||
v.saveSnoozedUntil("t", now + 60_000)
|
||||
assertTrue("t" in v.snoozedThreads(now))
|
||||
// ⚠️ Expiry is read at query time, not swept. A snooze that outlived
|
||||
// its own deadline because nothing ran is a conversation the user
|
||||
// silently stopped being told about.
|
||||
assertFalse("t" in v.snoozedThreads(now + 120_000))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `un-snoozing forgets it rather than storing a zero`() {
|
||||
val v = vault()
|
||||
v.saveSnoozedUntil("t", now + 60_000)
|
||||
v.saveSnoozedUntil("t", 0)
|
||||
assertEquals(0L, v.snoozedUntil("t"))
|
||||
assertTrue(v.snoozedThreads(now).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `snooze is per conversation`() {
|
||||
val v = vault()
|
||||
v.saveSnoozedUntil("quiet", now + 60_000)
|
||||
assertEquals(setOf("quiet"), v.snoozedThreads(now))
|
||||
assertEquals(0L, v.snoozedUntil("loud"))
|
||||
}
|
||||
|
||||
// -- Reply reminders -----------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `a reminder is due only once its time has passed`() {
|
||||
val v = vault()
|
||||
v.saveReplyReminder("t", now + 60_000)
|
||||
assertTrue(v.dueReplyReminders(now).isEmpty())
|
||||
assertEquals(setOf("t"), v.dueReplyReminders(now + 120_000).keys)
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 A reminder must be clearable by a reply ARRIVING, not only by firing.
|
||||
* "Remind me if they have not answered" that still fires after they did is
|
||||
* worse than none — it teaches people to ignore the reminder.
|
||||
*/
|
||||
@Test
|
||||
fun `a reminder can be cancelled before it fires`() {
|
||||
val v = vault()
|
||||
v.saveReplyReminder("t", now + 60_000)
|
||||
v.saveReplyReminder("t", 0)
|
||||
assertTrue(v.dueReplyReminders(now + 120_000).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reminders are independent per conversation`() {
|
||||
val v = vault()
|
||||
v.saveReplyReminder("a", now - 1_000)
|
||||
v.saveReplyReminder("b", now + 60_000)
|
||||
assertEquals(setOf("a"), v.dueReplyReminders(now).keys)
|
||||
}
|
||||
|
||||
/** The two stores must not read each other's rows. */
|
||||
@Test
|
||||
fun `snooze and reminders do not collide`() {
|
||||
val v = vault()
|
||||
v.saveSnoozedUntil("t", now + 60_000)
|
||||
assertEquals(0L, v.replyReminder("t"))
|
||||
v.saveReplyReminder("t", now + 30_000)
|
||||
assertEquals(now + 60_000, v.snoozedUntil("t"))
|
||||
assertEquals(now + 30_000, v.replyReminder("t"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user