Post a story to a group (§6.24)

Taken by hand, like the two before it — the original was cut from a tree
whose text stories and story replies differ from this branch's.

🔴 The group's roster is the audience, and the story audience does NOT
apply. Those are two different lists making two different statements —
who may see this account's stories, and who is in a conversation — and
they will disagree. The roster wins, because a story addressed to a
group that silently dropped members would be a group message part of the
group cannot see.

`groupStoryReach` returns that overlap rather than a warning. A sender
who believes their story audience still applies is being misled by the
interface, and a disclosure that cannot name the consequence is not one.
The two categories deliberately do not double-count: somebody who is not
a contact was never on that list to be excluded from.

🔴 On receipt, a claimed group id is checked against our own roster. A
story is caught before the group-message branch, so without that check a
sender could attach the id of a group they are not in and have their
story shown as having come from it. The claim is dropped to null rather
than the story refused — the story is legitimately theirs; only the
claim about where it was posted is unverified.

⚠️ `gid` names the audience, never a destination. Rendering a group
story into the group's messages would make a story into a message, and
one that never expires out of it.
This commit is contained in:
Paul Hitt
2026-09-03 19:19:37 -04:00
parent adf7858940
commit 32e2f6b5df
4 changed files with 220 additions and 1 deletions
@@ -345,6 +345,14 @@ class Conversations(
* the viewer's device contacts that host directly.
*/
streamUrl: String? = null,
/**
* The group this story is addressed to (§6.24).
*
* ⚠️ Names the AUDIENCE, not a destination thread. It rides on the
* ordinary story envelope; the recipient decides whether to believe
* the claim by checking its own roster.
*/
groupId: String? = null,
): StoryPosted {
val sealed = Attachment.seal(data)
val id = relay.uploadAttachment(sealed.blob)
@@ -354,7 +362,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