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
@@ -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)
}
/**