Post a story to a group

Android half of ios#55, spec §6.24. An ordinary story envelope carrying
`gid`, fanned out to the members and recorded in the story set.

🔴 `Vault.groupStoryReach` returns the overlap rather than a warning: who
is excluded by this account's story audience and who is not a contact at
all, counted separately. The roster decides either way — those are two
different lists making two different statements and they will disagree —
so a disclosure that cannot name the consequence is not one.

🔴 The group label is adopted only from an actual member. The roster
check that guards group messages runs after the story branch, so without
this a sender could attach the id of a group they are not in and have
their story shown as having come from it. Dropped to null rather than
refusing the story: the story is legitimately theirs, and only the claim
about where it was posted is unverified.

The story branch already sat before group routing, which is what keeps a
group story out of the conversation — filed there it would be a story
that is a message, and one that never expires out of the thread. That
ordering is now written down rather than incidental.

⚠️ `gid` is persisted, or a reload forgets which story went where.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-02 17:53:48 -04:00
co-authored by Claude Opus 4.8
parent 6b2896db86
commit b43be1b780
5 changed files with 251 additions and 1 deletions
@@ -347,6 +347,15 @@ class Conversations(
* the viewer's device contacts that host directly.
*/
streamUrl: String? = null,
/**
* The group this story is addressed to (§6.24), if any.
*
* ⚠️ Names the AUDIENCE, not a destination thread. Receivers render it
* in the story surface; a story drawn into the group's conversation
* would be a story that is a message, and one that never expires out
* of the thread.
*/
groupId: String? = null,
): StoryPosted {
val sealed = Attachment.seal(data)
val id = relay.uploadAttachment(sealed.blob)
@@ -356,7 +365,7 @@ class Conversations(
duration = duration,
expires = System.currentTimeMillis() / 1000.0 + lifetimeSeconds,
)
val body = Content.attachment(ref, caption, null, streamUrl = streamUrl)
val body = Content.attachment(ref, caption, groupId, streamUrl = streamUrl)
var reached = 0
var missed = 0