Mute one contact's stories, without blocking them #47
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user