Story audiences (Android): all contacts, only chosen, or all except

Three modes, stored in the vault, applied at post time.

🔴 The audience is enforced by who is SENT the bytes, not by a flag. A story is
sealed to each recipient's devices through the ratchet, so somebody outside the
list is never sent it - there is no copy on the relay for them to be denied.
Every mainstream product holds one server-side copy and decides who is shown
it; this cannot work that way even if somebody wanted it to, and that is the
feature rather than a limitation.

Which also answers "make sure stories are only visible to friends": they
already were, structurally. There is no public audience and there cannot be
one - a story has to be encrypted to somebody in particular.

⚠️ The saved list is intersected with the live contact list in every branch,
including "only chosen". An id left behind after that person was removed as a
contact must not resurrect them as a recipient.

The screen says what will actually happen - "your next story goes to 3
contacts" - resolved the same way, and refuses a post that would reach nobody
rather than silently succeeding at nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-08-27 19:11:20 -04:00
co-authored by Claude Opus 5
parent 85d6825fdf
commit 780dc50652
4 changed files with 206 additions and 2 deletions
@@ -31,6 +31,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Divider
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
@@ -692,6 +693,9 @@ fun ColumnScope.AccountScreen(
SettingsSection { ProfilePicture(model, phase) }
SectionBand()
SettingsSection { StoryAudienceSection(model, phase) }
SectionBand()
SettingsSection { AddressCard(fnid = phase.fnid, relay = phase.relay) }
@@ -831,6 +835,119 @@ fun ColumnScope.AccountScreen(
* means telling every contact again. That trade is stated on the screen rather
* than left for somebody to discover when a new contact sees initials.
*/
/**
* Who may see this account's stories.
*
* 🔴 **Enforced by who is SENT the bytes.** A story is sealed to each
* recipient's devices through the ratchet, so somebody outside this list is
* never sent it — there is no copy on the relay for them to be denied. Every
* mainstream product holds one copy server-side and decides who is shown it;
* this cannot work that way even if somebody wanted it to, and that is the
* feature.
*
* ⚠️ There is no "public" option and cannot be one. A story only ever goes to
* contacts, because it has to be encrypted to somebody in particular.
*/
@Composable
private fun ColumnScope.StoryAudienceSection(
model: MessengerViewModel,
phase: MessengerViewModel.Phase.SignedIn,
) {
val audience = phase.storyAudience
Text("Who can see your stories", style = MaterialTheme.typography.titleMedium)
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = Space.sm),
horizontalArrangement = Arrangement.spacedBy(Space.sm),
) {
KitChip(
text = "All contacts",
selected = audience.mode == Session.AudienceMode.EVERYONE,
onClick = {
model.setStoryAudience(
Session.StoryAudience(Session.AudienceMode.EVERYONE, audience.userIds))
},
tag = "audience-everyone",
)
KitChip(
text = "Only chosen",
selected = audience.mode == Session.AudienceMode.ONLY,
onClick = {
model.setStoryAudience(
Session.StoryAudience(Session.AudienceMode.ONLY, audience.userIds))
},
tag = "audience-only",
)
KitChip(
text = "All except",
selected = audience.mode == Session.AudienceMode.EXCEPT,
onClick = {
model.setStoryAudience(
Session.StoryAudience(Session.AudienceMode.EXCEPT, audience.userIds))
},
tag = "audience-except",
)
}
Text(
when (audience.mode) {
Session.AudienceMode.EVERYONE ->
"Every contact receives your stories. Nobody else can — a story " +
"is encrypted to the people it goes to, so there is no copy " +
"for a stranger to find."
Session.AudienceMode.ONLY ->
"Only the people ticked below are sent your stories."
Session.AudienceMode.EXCEPT ->
"Every contact except the people ticked below."
},
fontSize = 14.sp,
color = Palette.SecondaryText,
)
// The list only earns its place when the mode uses one.
if (audience.mode != Session.AudienceMode.EVERYONE) {
if (phase.buddies.isEmpty()) {
Text("Add a contact first.", fontSize = 14.sp, color = Palette.SecondaryText)
}
phase.buddies.forEach { buddy ->
val name = buddy.displayName.ifBlank { buddy.handle }
val ticked = buddy.userId in audience.userIds
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
val next = if (ticked) audience.userIds - buddy.userId
else audience.userIds + buddy.userId
model.setStoryAudience(audience.copy(userIds = next))
}
.padding(vertical = Space.sm)
.testTag("audience-${buddy.userId}"),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Kit.gutter),
) {
Checkbox(checked = ticked, onCheckedChange = null)
Text(name, fontSize = 16.sp, modifier = Modifier.weight(1f))
}
}
// ⚠️ Says what will actually happen, resolved against the real contact
// list — an id left over from a removed contact must not read as a
// recipient, and the resolver drops it.
val reach = audience.recipients(phase.buddies.map { it.userId }).size
Text(
when (reach) {
0 -> "This would go to nobody."
1 -> "Your next story goes to 1 contact."
else -> "Your next story goes to $reach contacts."
},
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
color = if (reach == 0) Palette.Pink else Palette.SecondaryText,
modifier = Modifier.padding(top = Space.sm).testTag("audience-reach"),
)
}
}
@Composable
private fun ColumnScope.ProfilePicture(
model: MessengerViewModel,
@@ -103,6 +103,9 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
/** Calls placed and received, newest first. */
val calls: List<Session.CallEntry> = emptyList(),
/** Who may see this account's stories. */
val storyAudience: Session.StoryAudience = Session.StoryAudience(),
val myAvatar: io.flamenet.e2e.Attachment.Ref? = null,
/** Live stories, newest first (§6.6). */
val stories: List<Vault.Story> = emptyList(),
@@ -299,6 +302,7 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
fetchedBuddies.getOrDefault(emptyList()).map { b -> b.userId }
) ?: emptyMap(),
calls = session?.callLog() ?: emptyList(),
storyAudience = session?.storyAudience() ?: Session.StoryAudience(),
myAvatar = session?.myAvatar(),
groups = fetchedGroups.getOrDefault(emptyList()),
groupsLoaded = fetchedGroups.isSuccess,
@@ -1109,14 +1113,31 @@ 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.
*/
/** Change who may see this account's stories. */
fun setStoryAudience(audience: Session.StoryAudience) = viewModelScope.launch {
val current = session ?: return@launch
withContext(Dispatchers.IO) { runCatching { current.saveStoryAudience(audience) } }
update { it.copy(storyAudience = audience) }
}
fun postStory(bytes: ByteArray, mime: String, width: Int, height: Int, caption: String?) =
viewModelScope.launch {
val current = session ?: return@launch
val recipients = signedIn()?.buddies?.map { it.userId }.orEmpty()
if (recipients.isEmpty()) {
val contacts = signedIn()?.buddies?.map { it.userId }.orEmpty()
if (contacts.isEmpty()) {
update { it.copy(error = "Add a contact before posting a story.") }
return@launch
}
// 🔴 The audience decides who is SENT the bytes. There is no copy
// on the relay that somebody could be denied — see Session.StoryAudience.
val recipients = current.storyAudience().recipients(contacts)
if (recipients.isEmpty()) {
update {
it.copy(error = "Nobody is in your story audience, so this "
+ "would go to no one. Change who can see your stories on Account.")
}
return@launch
}
update { it.copy(busy = true, error = null) }
val done = withContext(Dispatchers.IO) {
runCatching { current.postStory(bytes, mime, width, height, caption, recipients) }
@@ -262,6 +262,54 @@ class Session(
)
}
/**
* Who may see this account's stories.
*
* `EVERYONE` means every contact — which is already the narrowest a story
* can be without a list, because a story only ever goes to people who are
* contacts. There is no public audience and cannot be one: a story is
* sealed per recipient.
*/
enum class AudienceMode { EVERYONE, ONLY, EXCEPT }
data class StoryAudience(
val mode: AudienceMode = AudienceMode.EVERYONE,
val userIds: Set<Long> = emptySet(),
) {
/**
* Resolve against the current contact list.
*
* ⚠️ Intersected with `contacts` in every branch, including ONLY. An
* id left in a saved list after that person was removed as a contact
* must not resurrect them as a recipient.
*/
fun recipients(contacts: List<Long>): List<Long> = when (mode) {
AudienceMode.EVERYONE -> contacts
AudienceMode.ONLY -> contacts.filter { it in userIds }
AudienceMode.EXCEPT -> contacts.filter { it !in userIds }
}
}
fun storyAudience(): StoryAudience {
val json = vault.storyAudience() ?: return StoryAudience()
val mode = runCatching {
AudienceMode.valueOf(json.optString("mode"))
}.getOrDefault(AudienceMode.EVERYONE)
val ids = json.optJSONArray("ids")
val set = buildSet {
for (i in 0 until (ids?.length() ?: 0)) add(ids!!.optLong(i))
}
return StoryAudience(mode, set)
}
fun saveStoryAudience(audience: StoryAudience) {
val ids = org.json.JSONArray()
audience.userIds.forEach { ids.put(it) }
vault.saveStoryAudience(JSONObject()
.put("mode", audience.mode.name)
.put("ids", ids))
}
// MARK: - Outbox
/**
@@ -61,6 +61,7 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
private const val MY_AVATAR = "my-avatar"
private const val OUTBOX = "outbox"
private const val CALL_LOG = "call-log"
private const val STORY_AUDIENCE = "story-audience"
/** Kept because the whole log is re-sealed on every write. */
private const val CALL_LOG_MAX = 500
@@ -554,6 +555,23 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
writeJson(CALL_LOG, JSONObject().put("calls", trimmed))
}
/**
* Who may see this account's stories.
*
* 🔴 **Enforced by who receives the bytes, not by a flag.** A story is
* sealed to each recipient's devices through the ratchet, so somebody
* outside the audience is never sent it at all — there is no copy on the
* relay for them to be denied. That is a stronger guarantee than every
* mainstream product, where the server holds one copy and decides who is
* shown it, and it is worth not weakening later for convenience.
*
* ⚠️ In the vault. An audience list is a statement about who somebody
* trusts, which is exactly the metadata this app exists to keep private.
*/
fun storyAudience(): JSONObject? = readJson(STORY_AUDIENCE)
fun saveStoryAudience(audience: JSONObject) = writeJson(STORY_AUDIENCE, audience)
/** Rewrite a thread wholesale, for restating a message's delivery state. */
fun replaceHistory(threadId: String, messages: JSONArray) =
writeJson(historyName(threadId), JSONObject().put("messages", messages))