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
@@ -954,6 +954,16 @@ class Session(
}
val ref = message.content.attachment
if (ref != null && ref.kind == Attachment.STORY && message.fromAddress == null) {
// 🔴 A story is caught before the group-message branch, so
// without this check 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,
@@ -961,6 +971,8 @@ class Session(
ref = ref,
caption = message.content.text,
postedAt = System.currentTimeMillis(),
set = message.content.storySet,
gid = storyGid,
)
if (story.isActive(now)) incomingStories += story
continue
@@ -1079,6 +1091,54 @@ class Session(
/** Empty the archive (ios#54). */
fun clearStoryArchive() = vault.saveStoryArchive(emptyList())
/**
* 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
}
fun reactionsFor(storyKey: String): Map<Long, String> = storyReactions[storyKey].orEmpty()
fun myReactionTo(storyKey: String): String? = storyReactions[storyKey]?.get(userId)