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
@@ -800,6 +800,22 @@ class Session(
// offline — is dropped rather than shown late.
val ref = message.content.attachment
if (ref != null && ref.kind == Attachment.STORY && message.fromAddress == null) {
// §6.24 — a story addressed to a group. This branch sits BEFORE
// the group routing below, which is what keeps it out of the
// conversation: filed there it would be a story that is a
// message, and one that never expires out of the thread.
//
// 🔴 **The group label is only adopted from an actual member.**
// The roster check that guards group messages runs after this
// 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 refused: the story
// itself is legitimately theirs, and only the claim about where
// it was posted is unverified.
val claimed = message.content.groupId
val storyGid = claimed?.takeIf { g ->
groupRoster(g)?.contains(message.fromUser) == true
}
val story = Vault.Story(
id = ref.id,
authorId = message.fromUser,
@@ -807,6 +823,7 @@ class Session(
ref = ref,
caption = message.content.text,
postedAt = System.currentTimeMillis(),
gid = storyGid,
)
if (story.isActive(now)) incomingStories += story
continue
@@ -1146,6 +1163,54 @@ class Session(
conversations.send(authorId, Content.storyReaction(emoji, storyId))
}
/**
* Post a story to a group (§6.24).
*
* 🔴 **The group's roster is the audience, and [storyAudience] does NOT
* apply.** 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: a story addressed to a group that silently
* dropped members would be a group message part of the group cannot see.
* The caller is responsible for having disclosed that before the first one.
*
* ⚠️ Recorded in the STORY set, never the group thread. `gid` names the
* audience, not a destination — a story rendered into the conversation
* would be a story that is a message, and one that never expires out of it.
*/
fun postGroupStory(
bytes: ByteArray,
mime: String,
width: Int,
height: Int,
caption: String?,
gid: String,
memberIds: List<Long>,
lifetimeSeconds: Double,
): Int {
val recipients = memberIds.filter { it != userId }
if (recipients.isEmpty()) return 0
val posted = conversations.postStory(
bytes, mime, width, height, caption, recipients,
lifetimeSeconds = lifetimeSeconds, groupId = gid)
if (posted.reached > 0) {
val now = System.currentTimeMillis() / 1000.0
vault.saveStories(
vault.loadStories() + Vault.Story(
id = posted.ref.id,
authorId = userId,
mine = true,
ref = posted.ref,
caption = caption.orEmpty(),
postedAt = System.currentTimeMillis(),
seen = true,
gid = gid,
),
now,
)
}
return posted.reached
}
/**
* Members of a group we are in, or null if the relay does not list it for
* us (audit H1). Cached for a short window: a poll pass can carry many