diff --git a/PARITY.md b/PARITY.md index c06a1bc..1da4068 100644 --- a/PARITY.md +++ b/PARITY.md @@ -568,3 +568,22 @@ silently omitted it because the file was added after the project was generated: Still unproven: the feature surface end-to-end on a device — voice notes, calls, groups, stories. Unit tests and an archive do not cover those, and testers will be the first to exercise them. + +## Muted story authors (ios#33) + +Both platforms hide one contact's stories without blocking them. Local on each +device: no envelope, no relay call, and the author is never told. + +⚠️ **Stored in the vault, not shared preferences.** Who somebody has muted is a +statement about their social graph, and the point of the vault is that such a +statement is not readable by anything that reaches the file. iOS puts it in the +sealed history snapshot for the same reason. + +⚠️ **A muted contact's stories are still received and still stored.** The filter +runs on the way out to the strip — `Vault.visibleStories` — and `saveStories` +runs before it. Dropping them on receipt would make unmuting show an empty strip +until their next post, so muting would quietly mean something other than it says. + +⚠️ Your own stories are never hidden, even if your own id reaches the muted set +from a restored backup. Hiding somebody's own posts from them reads as the posts +having failed to send. 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 359f374..5c56328 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt @@ -2355,6 +2355,7 @@ private fun VoiceBubble(ref: io.flamenet.e2e.Attachment.Ref, model: MessengerVie * would be telling people a story is safe to post when it is not. */ @Composable +@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class) fun ColumnScope.StoryStrip( model: MessengerViewModel, phase: MessengerViewModel.Phase.SignedIn, @@ -2431,7 +2432,18 @@ fun ColumnScope.StoryStrip( .padding(if (story.seen) 0.dp else 2.dp) .clip(CircleShape) .background(Palette.AvatarFill) - .clickable { model.openStory(story) }, + // Long press hides this contact's stories (ios#33). + // On the bubble because the moment somebody wants this + // is the moment they are looking at the bubble. + .combinedClickable( + onClick = { model.openStory(story) }, + onLongClick = { + if (!story.mine) { + model.setStoryMuted( + story.authorId, !model.isStoryMuted(story.authorId)) + } + }, + ), contentAlignment = Alignment.Center, ) { Text( 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 ae1c1b3..8d1f519 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt @@ -1913,6 +1913,20 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { * would need the relay to know who may read it, which is a list of your * contacts handed to the server for a feature that works without it. */ + /** + * Hide or restore one contact's stories (ios#33). + * + * Local only — no envelope and no relay call, so the author is never told. + */ + fun setStoryMuted(authorId: Long, muted: Boolean) = viewModelScope.launch { + val current = session ?: return@launch + withContext(Dispatchers.IO) { runCatching { current.setStoryMuted(authorId, muted) } } + refreshStories() + } + + /** Whether this contact's stories are hidden on this device. */ + fun isStoryMuted(authorId: Long): Boolean = session?.isStoryMuted(authorId) ?: false + /** Change who may see this account's stories. */ fun setStoryAudience(audience: Session.StoryAudience) = viewModelScope.launch { val current = session ?: return@launch 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 4374c31..14fd02a 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt @@ -820,8 +820,29 @@ class Session( val live = vault.loadStories().filter { it.isActive(now) } // Written back so expired bytes leave the device rather than lingering // until the next post. + // + // ⚠️ Saved BEFORE the mute filter, deliberately. A muted contact's + // stories are still stored: hiding is a display choice, and dropping + // them here would make unmuting show an empty strip until their next + // post — muting would then quietly mean something other than it says. vault.saveStories(live, now) - return live.sortedByDescending { it.postedAt } + return Vault.visibleStories( + live.sortedByDescending { it.postedAt }, vault.mutedStoryAuthors(), userId) + } + + /** Whether this contact's stories are hidden on this device (ios#33). */ + fun isStoryMuted(authorId: Long): Boolean = authorId in vault.mutedStoryAuthors() + + /** + * Hide or restore one contact's stories. Local only; nothing is sent. + * + * ⚠️ Refuses to mute the signed-in user. See [Vault.visibleStories]. + */ + fun setStoryMuted(authorId: Long, muted: Boolean) { + if (authorId == userId) return + val current = vault.mutedStoryAuthors() + val next = if (muted) current + authorId else current - authorId + if (next != current) vault.saveMutedStoryAuthors(next) } /** diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt index f241cd1..553b339 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt @@ -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, muted: Set, me: Long): List = + 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 { + val array = readJson(MUTED_STORIES)?.optJSONArray("ids") ?: return emptySet() + return (0 until array.length()).map { array.getLong(it) }.toSet() + } + + fun saveMutedStoryAuthors(ids: Set) { + val array = org.json.JSONArray() + ids.sorted().forEach { array.put(it) } + writeJson(MUTED_STORIES, JSONObject().put("ids", array)) + } + // -- Key-log issuer pin (audit M1) --------------------------------------- /** diff --git a/engine/src/test/kotlin/io/flamenet/e2e/StoryMuteTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/StoryMuteTest.kt new file mode 100644 index 0000000..c38d262 --- /dev/null +++ b/engine/src/test/kotlin/io/flamenet/e2e/StoryMuteTest.kt @@ -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() + 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()) + } +}