From 780dc50652fbed2fd35dfa17c21e9449e5409156 Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Thu, 27 Aug 2026 19:11:20 -0400 Subject: [PATCH] Story audiences (Android): all contacts, only chosen, or all except MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../io/flamenet/messenger/app/ChatScreens.kt | 117 ++++++++++++++++++ .../messenger/app/MessengerViewModel.kt | 25 +++- .../io/flamenet/messenger/app/Session.kt | 48 +++++++ .../src/main/kotlin/io/flamenet/e2e/Vault.kt | 18 +++ 4 files changed, 206 insertions(+), 2 deletions(-) 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 d2d45cb..9291f49 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt @@ -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, 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 46153f6..6b93e85 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt @@ -103,6 +103,9 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { /** Calls placed and received, newest first. */ val calls: List = 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 = 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) } 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 759797d..c2b7c6a 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt @@ -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 = 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): List = 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 /** diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt index b5933b4..0b10e41 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt @@ -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))