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
@@ -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))