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:
co-authored by
Claude Opus 4.8
parent
6b2896db86
commit
b43be1b780
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user