diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt index fe017fd..4e520fd 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt @@ -1377,6 +1377,8 @@ fun ColumnScope.ChatScreen( // for is one they will not rely on. if (chat.peerId != null) DisappearingPanel(model, chat) + if (chat.peerId != null) AttentionPanel(model, chat) + LazyColumn( state = listState, modifier = Modifier.fillMaxWidth().weight(1f).testTag("messages"), @@ -2731,3 +2733,92 @@ private fun ColumnScope.MediaQualitySection() { 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, +) diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerService.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerService.kt index 087d165..9b2480e 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerService.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerService.kt @@ -183,6 +183,13 @@ class MessengerService : Service() { while (true) { val touched = runCatching { s.receive() }.getOrDefault(emptySet()) 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 // the relay for WAIT_SECONDS, so this only paces a relay that // answers immediately. @@ -222,6 +229,26 @@ class MessengerService : Service() { 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) { val open = PendingIntent.getActivity( this, 2, Intent(this, MainActivity::class.java), diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt index 71f1b26..62d3316 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt @@ -82,6 +82,15 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { * groups invisible on iOS for far longer. */ 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 = emptySet(), /** Same question for the group list. See [buddiesLoaded]. */ val groupsLoaded: Boolean = false, @@ -214,6 +223,8 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { * not a preference. */ 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 { 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) { runCatching { current.roster() } // 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), safetyNumber = current.conversations.trust.safetyNumber(peerId), disappearSeconds = current.conversations.disappearSeconds(peerId), + replyReminderAt = current.replyReminder(peerId), ), // Opening the thread is what marks it read. Nothing is sent to // 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) --------------------------------------- /** diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt index fc21e32..e3bbcfe 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt @@ -126,11 +126,51 @@ class Session( } } .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. */ 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 = + 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 = + vault.dueReplyReminders(now) + /** Delete one message from this device (delete for me). */ fun deleteMessage(threadId: String, at: Long, mine: Boolean, text: String) = vault.deleteLine(threadId, at, mine, text) diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt index eea6765..d60f600 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt @@ -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 { + 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 { + 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) --------------------------------------- /** diff --git a/engine/src/test/kotlin/io/flamenet/e2e/AttentionStoreTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/AttentionStoreTest.kt new file mode 100644 index 0000000..50f591b --- /dev/null +++ b/engine/src/test/kotlin/io/flamenet/e2e/AttentionStoreTest.kt @@ -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() + 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")) + } +}