Mute one contact's stories, without blocking them

Android half of ios#33. Same shape as iOS: the muted set lives in the
vault beside the audience list, because who somebody has muted is a
statement about their social graph and shared preferences are readable
by anything that reaches the file.

The filter runs on the way out of `stories()`, after `saveStories` has
already written the live set back. A muted contact's stories are still
received and still stored — dropping them on receipt would be cheaper,
but unmuting would then show an empty strip until their next post, and
muting would quietly mean something other than what it says.

`Vault.visibleStories` never drops the signed-in user, even if their own
id is in the set. `setStoryMuted` refuses to put it there, but a restored
backup from a build without that guard could carry one, and hiding
somebody's own posts from them reads as the posts having failed to send.

Long press on a story bubble to hide or restore.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-02 15:47:55 -04:00
co-authored by Claude Opus 4.8
parent bf44c15266
commit 0c1af3e45f
6 changed files with 203 additions and 2 deletions
@@ -82,6 +82,7 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
private const val OUTBOX = "outbox"
private const val CALL_LOG = "call-log"
private const val STORY_AUDIENCE = "story-audience"
private const val MUTED_STORIES = "muted-stories"
private const val DISAPPEAR = "disappear"
private const val SNOOZE = "snooze"
private const val REPLY_REMINDER = "reply-reminder"
@@ -100,6 +101,19 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
private const val MAX_GROUP_NAME = 80
/** The two kinds of thread, named once so they cannot drift apart. */
/**
* Drop muted authors' stories from the strip (ios#33).
*
* ⚠️ `me` is never dropped. Muting yourself is not offered, but a
* muted id that reached the set some other way — a restored backup
* from a build without the guard — must not hide the user's own posts
* from them, which reads as the posts having failed to send.
*
* ⚠️ Order is the caller's, newest first. Filtering must not re-sort.
*/
fun visibleStories(stories: List<Story>, muted: Set<Long>, me: Long): List<Story> =
stories.filter { it.authorId == me || it.authorId !in muted }
fun threadForUser(userId: Long) = "user:$userId"
fun threadForGroup(groupId: String) = "group:$groupId"
@@ -1061,6 +1075,31 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
fun saveStoryAudience(audience: JSONObject) = writeJson(STORY_AUDIENCE, audience)
// -- Muted story authors (ios#33) ----------------------------------------
/**
* Contacts whose stories this device hides.
*
* 🔴 **Local, and the author is never told.** No envelope and no relay
* call: muting is a decision about what this device draws, and making it
* visible to them would turn a quiet act into a social one — which is the
* reason people want it instead of blocking.
*
* ⚠️ In the vault, beside the audience list and for the same reason. Who
* somebody has muted is a statement about their social graph, and plain
* shared preferences are readable by anything that reaches the file.
*/
fun mutedStoryAuthors(): Set<Long> {
val array = readJson(MUTED_STORIES)?.optJSONArray("ids") ?: return emptySet()
return (0 until array.length()).map { array.getLong(it) }.toSet()
}
fun saveMutedStoryAuthors(ids: Set<Long>) {
val array = org.json.JSONArray()
ids.sorted().forEach { array.put(it) }
writeJson(MUTED_STORIES, JSONObject().put("ids", array))
}
// -- Key-log issuer pin (audit M1) ---------------------------------------
/**
@@ -0,0 +1,96 @@
package io.flamenet.e2e
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* Muting one contact's stories (ios#33).
*
* The ordinary middle between seeing everything and blocking somebody.
* Entirely local: no envelope, no relay call, and the author is never told.
*/
class StoryMuteTest {
private class MapStorage : Vault.Storage {
val records = LinkedHashMap<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 key(): SecretKey =
KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()
private fun story(author: Long, at: Long) = Vault.Story(
id = "s$author-$at",
authorId = author,
mine = false,
ref = Attachment.Ref(
kind = Attachment.STORY, id = "blob$at", key = ByteArray(32),
digest = ByteArray(32), bytes = 1, mime = "image/jpeg",
width = 1, height = 1, duration = 0.0, expires = 0.0,
),
caption = "",
postedAt = at,
seen = false,
)
@Test
fun `a muted author is dropped from the strip`() {
val visible = Vault.visibleStories(
listOf(story(7, 3), story(8, 2), story(9, 1)), setOf(8), me = 1)
assertEquals(listOf(7L, 9L), visible.map { it.authorId })
}
/** ⚠️ Order is the caller's — newest first. Filtering must not re-sort. */
@Test
fun `filtering preserves the order it was given`() {
val visible = Vault.visibleStories(
listOf(story(9, 3), story(7, 2), story(8, 1)), emptySet(), me = 1)
assertEquals(listOf(9L, 7L, 8L), visible.map { it.authorId })
}
/**
* 🔴 Your own posts survive your own id being in the set. `setStoryMuted`
* refuses to add it, but a restored backup could carry one, and hiding
* somebody's own stories from them reads as the posts having failed.
*/
@Test
fun `your own stories are never hidden even if your id is muted`() {
val visible = Vault.visibleStories(
listOf(story(1, 2), story(7, 1)), setOf(1, 7), me = 1)
assertEquals(listOf(1L), visible.map { it.authorId })
}
@Test
fun `muting nobody changes nothing`() {
val all = listOf(story(7, 2), story(8, 1))
assertEquals(2, Vault.visibleStories(all, emptySet(), me = 1).size)
}
@Test
fun `a muted author with no stories is simply absent`() {
val visible = Vault.visibleStories(listOf(story(7, 1)), setOf(99), me = 1)
assertEquals(listOf(7L), visible.map { it.authorId })
}
// -- Persistence ---------------------------------------------------------
@Test
fun `the muted set round-trips through the vault`() {
val vault = Vault(MapStorage(), key())
assertTrue(vault.mutedStoryAuthors().isEmpty())
vault.saveMutedStoryAuthors(setOf(8L, 12L))
assertEquals(setOf(8L, 12L), vault.mutedStoryAuthors())
}
/** An absent record is an empty set, not a crash. */
@Test
fun `a vault written before this feature reads as nothing muted`() {
assertTrue(Vault(MapStorage(), key()).mutedStoryAuthors().isEmpty())
}
}