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
@@ -1377,6 +1377,8 @@ fun ColumnScope.ChatScreen(
|
|||||||
// for is one they will not rely on.
|
// for is one they will not rely on.
|
||||||
if (chat.peerId != null) DisappearingPanel(model, chat)
|
if (chat.peerId != null) DisappearingPanel(model, chat)
|
||||||
|
|
||||||
|
if (chat.peerId != null) AttentionPanel(model, chat)
|
||||||
|
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
state = listState,
|
state = listState,
|
||||||
modifier = Modifier.fillMaxWidth().weight(1f).testTag("messages"),
|
modifier = Modifier.fillMaxWidth().weight(1f).testTag("messages"),
|
||||||
@@ -2731,3 +2733,92 @@ private fun ColumnScope.MediaQualitySection() {
|
|||||||
color = Palette.SecondaryText,
|
color = Palette.SecondaryText,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Snooze, and "remind me if no reply", for one conversation.
|
||||||
|
*
|
||||||
|
* 🔴 **Both are local to this device and always will be.** "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.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun ColumnScope.AttentionPanel(
|
||||||
|
model: MessengerViewModel,
|
||||||
|
chat: MessengerViewModel.Phase.Chat,
|
||||||
|
) {
|
||||||
|
val peerId = chat.peerId ?: return
|
||||||
|
val snoozed = chat.threadId in (model.phase.value as? MessengerViewModel.Phase.SignedIn)
|
||||||
|
?.snoozedThreads.orEmpty()
|
||||||
|
|
||||||
|
Column(modifier = Modifier.fillMaxWidth().padding(vertical = Space.sm)) {
|
||||||
|
Text("Snooze this chat", style = MaterialTheme.typography.titleSmall)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(top = Space.xs),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(Space.xs),
|
||||||
|
) {
|
||||||
|
SNOOZE_CHOICES.forEach { (label, seconds) ->
|
||||||
|
KitChip(
|
||||||
|
text = label,
|
||||||
|
selected = seconds == 0 && !snoozed,
|
||||||
|
onClick = { model.snooze(peerId, seconds) },
|
||||||
|
tag = "snooze-$seconds",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
if (snoozed) {
|
||||||
|
"Snoozed. Messages still arrive — this chat just stops asking " +
|
||||||
|
"for your attention until it wakes."
|
||||||
|
} else {
|
||||||
|
"Snoozing stops this chat notifying you for a while. Nothing is " +
|
||||||
|
"blocked and nothing is lost."
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
modifier = Modifier.padding(top = Space.xs),
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(Space.sm))
|
||||||
|
Text("Remind me if no reply", style = MaterialTheme.typography.titleSmall)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(top = Space.xs),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(Space.xs),
|
||||||
|
) {
|
||||||
|
REMINDER_CHOICES.forEach { (label, seconds) ->
|
||||||
|
KitChip(
|
||||||
|
text = label,
|
||||||
|
selected = (seconds == 0) == (chat.replyReminderAt == 0L),
|
||||||
|
onClick = { model.setReplyReminder(peerId, seconds) },
|
||||||
|
tag = "remind-$seconds",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
if (chat.replyReminderAt == 0L) {
|
||||||
|
"Nothing set. A reminder nudges you if this chat goes unanswered."
|
||||||
|
} else {
|
||||||
|
// ⚠️ Says the cancellation rule out loud. A reminder that fired
|
||||||
|
// after they answered would teach people to ignore reminders,
|
||||||
|
// so the fact that it will not is worth stating.
|
||||||
|
"Set. It is cancelled automatically the moment they reply."
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
modifier = Modifier.padding(top = Space.xs),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val SNOOZE_CHOICES = listOf(
|
||||||
|
"Off" to 0,
|
||||||
|
"1 hour" to 3_600,
|
||||||
|
"8 hours" to 28_800,
|
||||||
|
"1 day" to 86_400,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val REMINDER_CHOICES = listOf(
|
||||||
|
"Off" to 0,
|
||||||
|
"1 hour" to 3_600,
|
||||||
|
"3 hours" to 10_800,
|
||||||
|
"Tomorrow" to 86_400,
|
||||||
|
)
|
||||||
|
|||||||
@@ -183,6 +183,13 @@ class MessengerService : Service() {
|
|||||||
while (true) {
|
while (true) {
|
||||||
val touched = runCatching { s.receive() }.getOrDefault(emptySet())
|
val touched = runCatching { s.receive() }.getOrDefault(emptySet())
|
||||||
if (touched.isNotEmpty()) notifyMessages(touched.size)
|
if (touched.isNotEmpty()) notifyMessages(touched.size)
|
||||||
|
// 🔴 Reply reminders fire from THIS loop, after `receive()`, and
|
||||||
|
// the order matters: an inbound message clears the reminder inside
|
||||||
|
// `Session.record`, so a reply that has just landed cancels its own
|
||||||
|
// reminder before it can go off. Checking first would fire a
|
||||||
|
// reminder for a conversation that was answered a moment ago —
|
||||||
|
// which is the one thing that would teach people to ignore them.
|
||||||
|
fireDueReplyReminders(s)
|
||||||
// ⚠️ A short gap, not a poll interval: `receive()` is held open by
|
// ⚠️ A short gap, not a poll interval: `receive()` is held open by
|
||||||
// the relay for WAIT_SECONDS, so this only paces a relay that
|
// the relay for WAIT_SECONDS, so this only paces a relay that
|
||||||
// answers immediately.
|
// answers immediately.
|
||||||
@@ -222,6 +229,26 @@ class MessengerService : Service() {
|
|||||||
number = threads)
|
number = threads)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notify about conversations nobody answered, and forget them.
|
||||||
|
*
|
||||||
|
* ⚠️ Cleared as it fires. A reminder is a one-shot: leaving the row would
|
||||||
|
* re-notify on the next cycle, half a second later, for ever.
|
||||||
|
*/
|
||||||
|
private fun fireDueReplyReminders(s: Session) {
|
||||||
|
val due = runCatching { s.dueReplyReminders() }.getOrDefault(emptyMap())
|
||||||
|
if (due.isEmpty()) return
|
||||||
|
due.keys.forEach { threadId -> runCatching { s.vault.saveReplyReminder(threadId, 0L) } }
|
||||||
|
val open = PendingIntent.getActivity(
|
||||||
|
this, 4, Intent(this, MainActivity::class.java),
|
||||||
|
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
|
||||||
|
// Same privacy rule as a message notification: a count, never a name.
|
||||||
|
// Who you are waiting on is as sensitive as what they said.
|
||||||
|
val text = if (due.size == 1) "Still no reply"
|
||||||
|
else "Still no reply in ${due.size} chats"
|
||||||
|
notify(CHANNEL_MESSAGES, 5, text, NotificationCompat.PRIORITY_DEFAULT, open)
|
||||||
|
}
|
||||||
|
|
||||||
private fun notifyCall(from: String) {
|
private fun notifyCall(from: String) {
|
||||||
val open = PendingIntent.getActivity(
|
val open = PendingIntent.getActivity(
|
||||||
this, 2, Intent(this, MainActivity::class.java),
|
this, 2, Intent(this, MainActivity::class.java),
|
||||||
|
|||||||
@@ -82,6 +82,15 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
* groups invisible on iOS for far longer.
|
* groups invisible on iOS for far longer.
|
||||||
*/
|
*/
|
||||||
val buddiesLoaded: Boolean = false,
|
val buddiesLoaded: Boolean = false,
|
||||||
|
/**
|
||||||
|
* Thread ids currently snoozed.
|
||||||
|
*
|
||||||
|
* ⚠️ Recomputed rather than stored as a flag: a snooze expires by
|
||||||
|
* the clock passing, and nothing runs at that moment. A cached
|
||||||
|
* boolean would leave a conversation muted until something happened
|
||||||
|
* to refresh it.
|
||||||
|
*/
|
||||||
|
val snoozedThreads: Set<String> = emptySet(),
|
||||||
|
|
||||||
/** Same question for the group list. See [buddiesLoaded]. */
|
/** Same question for the group list. See [buddiesLoaded]. */
|
||||||
val groupsLoaded: Boolean = false,
|
val groupsLoaded: Boolean = false,
|
||||||
@@ -214,6 +223,8 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
* not a preference.
|
* not a preference.
|
||||||
*/
|
*/
|
||||||
val disappearSeconds: Int = 0,
|
val disappearSeconds: Int = 0,
|
||||||
|
/** When a reply reminder is set for this thread, epoch millis; 0 = none. */
|
||||||
|
val replyReminderAt: Long = 0,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -497,6 +508,10 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
|
|
||||||
fun refreshBuddies() = viewModelScope.launch {
|
fun refreshBuddies() = viewModelScope.launch {
|
||||||
val current = session ?: return@launch
|
val current = session ?: return@launch
|
||||||
|
// Snoozes expire by the clock passing and nothing runs at that moment,
|
||||||
|
// so the set is recomputed whenever the list is drawn rather than
|
||||||
|
// cached — a stale flag leaves a conversation muted past its deadline.
|
||||||
|
refreshSnoozed()
|
||||||
val fetched = withContext(Dispatchers.IO) {
|
val fetched = withContext(Dispatchers.IO) {
|
||||||
runCatching { current.roster() }
|
runCatching { current.roster() }
|
||||||
// Pin each contact's global address the first time we see them.
|
// Pin each contact's global address the first time we see them.
|
||||||
@@ -563,6 +578,7 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
trust = current.conversations.trust.state(peerId),
|
trust = current.conversations.trust.state(peerId),
|
||||||
safetyNumber = current.conversations.trust.safetyNumber(peerId),
|
safetyNumber = current.conversations.trust.safetyNumber(peerId),
|
||||||
disappearSeconds = current.conversations.disappearSeconds(peerId),
|
disappearSeconds = current.conversations.disappearSeconds(peerId),
|
||||||
|
replyReminderAt = current.replyReminder(peerId),
|
||||||
),
|
),
|
||||||
// Opening the thread is what marks it read. Nothing is sent to
|
// Opening the thread is what marks it read. Nothing is sent to
|
||||||
// the relay for this — read state is local, because a read
|
// the relay for this — read state is local, because a read
|
||||||
@@ -802,6 +818,43 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -- Attention management: snooze and reply reminders ---------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Snooze a conversation for [seconds]; 0 wakes it now.
|
||||||
|
*
|
||||||
|
* ⚠️ Local to this device, deliberately. "I am ignoring this person until
|
||||||
|
* Tuesday" is a statement about a relationship, and the relay has no
|
||||||
|
* business holding it — the cost is that a snooze does not follow you to a
|
||||||
|
* new device, which is the better half of that trade.
|
||||||
|
*/
|
||||||
|
fun snooze(peerId: Long, seconds: Int) = viewModelScope.launch {
|
||||||
|
val current = session ?: return@launch
|
||||||
|
val until = if (seconds <= 0) 0L else System.currentTimeMillis() + seconds * 1000L
|
||||||
|
withContext(Dispatchers.IO) { current.snooze(peerId, until) }
|
||||||
|
refreshSnoozed()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Recompute which threads are snoozed right now. */
|
||||||
|
fun refreshSnoozed() {
|
||||||
|
val current = session ?: return
|
||||||
|
update { it.copy(snoozedThreads = current.snoozedThreads()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ask to be reminded if this conversation goes unanswered for [seconds].
|
||||||
|
*
|
||||||
|
* 🔴 The reminder is cancelled by a reply ARRIVING, in `Session.record`,
|
||||||
|
* not merely by firing. One that still goes off after they answered teaches
|
||||||
|
* people to ignore reminders.
|
||||||
|
*/
|
||||||
|
fun setReplyReminder(peerId: Long, seconds: Int) = viewModelScope.launch {
|
||||||
|
val current = session ?: return@launch
|
||||||
|
val at = if (seconds <= 0) 0L else System.currentTimeMillis() + seconds * 1000L
|
||||||
|
withContext(Dispatchers.IO) { current.setReplyReminder(peerId, at) }
|
||||||
|
update { it.copy(openChat = it.openChat?.copy(replyReminderAt = at)) }
|
||||||
|
}
|
||||||
|
|
||||||
// -- Disappearing messages (§6.11) ---------------------------------------
|
// -- Disappearing messages (§6.11) ---------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -126,11 +126,51 @@ class Session(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.also { if (attachment != null) it.put("kind", attachment.kind).put("att", attachment.toJson()) })
|
.also { if (attachment != null) it.put("kind", attachment.kind).put("att", attachment.toJson()) })
|
||||||
|
|
||||||
|
// 🔴 A reply arriving cancels the reminder, and it is done HERE because
|
||||||
|
// this is 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 — which teaches
|
||||||
|
// people to ignore reminders, and is worse than not having them.
|
||||||
|
if (!mine) clearReplyReminderOnInbound(threadId)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Clear a thread. Keeps the session, so the conversation still works. */
|
/** Clear a thread. Keeps the session, so the conversation still works. */
|
||||||
fun clearHistory(threadId: String) = vault.forgetHistory(threadId)
|
fun clearHistory(threadId: String) = vault.forgetHistory(threadId)
|
||||||
|
|
||||||
|
// -- Attention management -------------------------------------------------
|
||||||
|
|
||||||
|
/** Snooze a conversation until [until] (epoch millis); 0 clears it. */
|
||||||
|
fun snooze(peerId: Long, until: Long) =
|
||||||
|
vault.saveSnoozedUntil(Vault.threadForUser(peerId), until)
|
||||||
|
|
||||||
|
fun snoozedUntil(peerId: Long): Long = vault.snoozedUntil(Vault.threadForUser(peerId))
|
||||||
|
|
||||||
|
/** Thread ids still snoozed at [now]. */
|
||||||
|
fun snoozedThreads(now: Long = System.currentTimeMillis()): Set<String> =
|
||||||
|
vault.snoozedThreads(now)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ask to be reminded about this conversation if nobody replies by [at].
|
||||||
|
*
|
||||||
|
* 🔴 **Cancelled by a reply arriving**, not only by firing — see
|
||||||
|
* [clearReplyReminderOnInbound]. A reminder that still goes off after they
|
||||||
|
* answered is worse than none: it teaches people to ignore it.
|
||||||
|
*/
|
||||||
|
fun setReplyReminder(peerId: Long, at: Long) =
|
||||||
|
vault.saveReplyReminder(Vault.threadForUser(peerId), at)
|
||||||
|
|
||||||
|
fun replyReminder(peerId: Long): Long = vault.replyReminder(Vault.threadForUser(peerId))
|
||||||
|
|
||||||
|
/** Drop any pending reminder for a thread that has just heard back. */
|
||||||
|
fun clearReplyReminderOnInbound(threadId: String) {
|
||||||
|
if (vault.replyReminder(threadId) != 0L) vault.saveReplyReminder(threadId, 0L)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reminders that have come due at [now]. */
|
||||||
|
fun dueReplyReminders(now: Long = System.currentTimeMillis()): Map<String, Long> =
|
||||||
|
vault.dueReplyReminders(now)
|
||||||
|
|
||||||
/** Delete one message from this device (delete for me). */
|
/** Delete one message from this device (delete for me). */
|
||||||
fun deleteMessage(threadId: String, at: Long, mine: Boolean, text: String) =
|
fun deleteMessage(threadId: String, at: Long, mine: Boolean, text: String) =
|
||||||
vault.deleteLine(threadId, at, mine, text)
|
vault.deleteLine(threadId, at, mine, text)
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
|
|||||||
private const val CALL_LOG = "call-log"
|
private const val CALL_LOG = "call-log"
|
||||||
private const val STORY_AUDIENCE = "story-audience"
|
private const val STORY_AUDIENCE = "story-audience"
|
||||||
private const val DISAPPEAR = "disappear"
|
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 ROSTER = "roster"
|
||||||
private const val DEVICE_OWNER_PREFIX = "devowner/"
|
private const val DEVICE_OWNER_PREFIX = "devowner/"
|
||||||
private const val KT_ISSUER_PIN = "kt-issuer-pin"
|
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))
|
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) ---------------------------------------
|
// -- 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