Post a story to a group #54
@@ -720,3 +720,25 @@ about the considered case and not the careless one.
|
||||
⚠️ Neither platform puts the control on a gesture that advances or dismisses a
|
||||
story. An acknowledgement delivered by a mis-tap is a disclosure the person did
|
||||
not make.
|
||||
## Stories posted to a group (§6.24, ios#55)
|
||||
An ordinary story envelope carrying `gid`, fanned out to the members and
|
||||
rendered in the story surface.
|
||||
🔴 **The group's roster is the audience; the sender's story audience 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 group story that silently dropped members would be a group
|
||||
message part of the group cannot see. Both platforms expose `groupStoryReach`,
|
||||
which returns *the overlap* rather than a warning — who is excluded by the
|
||||
story audience, and who is not a contact at all, counted separately, since a
|
||||
non-contact was never on that list.
|
||||
🔴 **The story branch runs BEFORE group routing on both platforms.** Falling
|
||||
through would render a story as a message in the group's conversation: a story
|
||||
that is a message, and one that never expires out of the thread.
|
||||
🔴 **Android adopts the group label only from an actual member.** Its roster
|
||||
check guards group *messages* and 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. The label is dropped to null rather than the story
|
||||
refused — the story is legitimately theirs; only the claim about where it was
|
||||
posted is unverified.
|
||||
⚠️ `exp` is the ordinary story lifetime on both. Group membership does not
|
||||
extend it.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -28,6 +28,15 @@ import javax.crypto.spec.GCMParameterSpec
|
||||
*/
|
||||
class Vault(private val storage: Storage, private val key: SecretKey) {
|
||||
|
||||
/** What a group story will actually reach (§6.24). */
|
||||
data class GroupStoryReach(
|
||||
val recipients: List<Long>,
|
||||
/** Members this account excluded from its stories, who get it anyway. */
|
||||
val excludedByAudience: List<Long>,
|
||||
/** Members who are not contacts at all. */
|
||||
val notContacts: List<Long>,
|
||||
)
|
||||
|
||||
/** Wherever bytes live. A directory on a phone, a map in a test. */
|
||||
interface Storage {
|
||||
fun read(name: String): ByteArray?
|
||||
@@ -213,6 +222,42 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
|
||||
return order.map { key -> sets.getValue(key).sortedBy { it.set?.i ?: 0 } }
|
||||
}
|
||||
|
||||
/**
|
||||
* Who a group story will reach, and who among them this account has
|
||||
* excluded from its stories (§6.24).
|
||||
*
|
||||
* 🔴 **The roster decides; the story audience does not apply.** Those
|
||||
* are two different lists making two different statements, and they
|
||||
* will disagree. This returns the overlap rather than a warning,
|
||||
* because 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 do not double-count. Somebody who is not a
|
||||
* contact is not "excluded from your stories" — they were never on
|
||||
* that list at all.
|
||||
*/
|
||||
fun groupStoryReach(
|
||||
members: List<Long>,
|
||||
me: Long,
|
||||
/**
|
||||
* Who this account's story audience would have allowed, already
|
||||
* resolved. Passed in rather than an audience type: the audience
|
||||
* lives in the app layer and this module has no dependency on it.
|
||||
*/
|
||||
allowedByAudience: Set<Long>,
|
||||
contacts: List<Long>,
|
||||
): GroupStoryReach {
|
||||
val recipients = members.filter { it != me }
|
||||
val allowed = allowedByAudience
|
||||
val contactSet = contacts.toSet()
|
||||
return GroupStoryReach(
|
||||
recipients = recipients,
|
||||
excludedByAudience = recipients.filter { it in contactSet && it !in allowed },
|
||||
notContacts = recipients.filter { it !in contactSet },
|
||||
)
|
||||
}
|
||||
|
||||
fun threadForUser(userId: Long) = "user:$userId"
|
||||
fun threadForGroup(groupId: String) = "group:$groupId"
|
||||
|
||||
@@ -547,6 +592,16 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
|
||||
|
||||
/** Which sequence this story belongs to (§6.23), if any. */
|
||||
val set: Content.StorySet? = null,
|
||||
|
||||
/**
|
||||
* The group this story was posted to (§6.24), if any.
|
||||
*
|
||||
* ⚠️ Names the AUDIENCE, not a destination thread. A group story
|
||||
* belongs in the story surface; rendering it into the group's messages
|
||||
* would make a story into a message, which it is not — and one that
|
||||
* never expires out of the conversation.
|
||||
*/
|
||||
val gid: String? = null,
|
||||
) {
|
||||
/**
|
||||
* When this story stops rendering, unix seconds. 0 = never.
|
||||
@@ -796,6 +851,8 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
|
||||
background = if (o.has("bg")) o.optInt("bg") else null,
|
||||
|
||||
set = o.optJSONObject("set")?.let { Content.StorySet.fromJson(it) },
|
||||
|
||||
gid = o.optString("gid").takeIf { it.isNotEmpty() },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -829,6 +886,8 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
|
||||
.put("caption", story.caption)
|
||||
.put("at", story.postedAt)
|
||||
.also { j -> story.set?.let { j.put("set", it.toJson()) } }
|
||||
|
||||
.also { j -> story.gid?.let { j.put("gid", it) } }
|
||||
.put("seen", story.seen))
|
||||
}
|
||||
return array
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
package io.flamenet.e2e
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Stories posted to a group (spec §6.24, ios#55).
|
||||
*
|
||||
* 🔴 **The group's roster is the audience, and the sender's story audience does
|
||||
* not apply.** Two different lists making two different statements — who may
|
||||
* see this account's stories, and who is in a conversation. They will disagree,
|
||||
* and the roster wins.
|
||||
*/
|
||||
class GroupStoryTest {
|
||||
|
||||
@Test
|
||||
fun `every member but yourself is a recipient`() {
|
||||
val reach = Vault.groupStoryReach(
|
||||
members = listOf(1, 2, 3), me = 1,
|
||||
allowedByAudience = setOf(2, 3), contacts = listOf(2, 3))
|
||||
assertEquals(listOf(2L, 3L), reach.recipients)
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 The hazard the disclosure exists for: somebody excluded from this
|
||||
* account's stories still receives a story posted to a group they are in.
|
||||
*/
|
||||
@Test
|
||||
fun `a member excluded from your stories still receives a group story`() {
|
||||
val reach = Vault.groupStoryReach(
|
||||
members = listOf(1, 2, 3), me = 1,
|
||||
allowedByAudience = setOf(2), contacts = listOf(2, 3))
|
||||
assertEquals(listOf(2L, 3L), reach.recipients)
|
||||
assertEquals(listOf(3L), reach.excludedByAudience)
|
||||
}
|
||||
|
||||
/** The other half: a member who is not a contact at all. */
|
||||
@Test
|
||||
fun `a member who is not a contact is named separately`() {
|
||||
val reach = Vault.groupStoryReach(
|
||||
members = listOf(1, 2, 9), me = 1,
|
||||
allowedByAudience = setOf(2), contacts = listOf(2))
|
||||
assertEquals(listOf(9L), reach.notContacts)
|
||||
assertTrue(reach.excludedByAudience.isEmpty())
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ The categories must not double-count. Somebody who is not a contact
|
||||
* is not "excluded from your stories" — they were never on that list.
|
||||
*/
|
||||
@Test
|
||||
fun `a non-contact is not also counted as excluded`() {
|
||||
val reach = Vault.groupStoryReach(
|
||||
members = listOf(1, 9), me = 1,
|
||||
allowedByAudience = emptySet(), contacts = emptyList())
|
||||
assertEquals(listOf(9L), reach.notContacts)
|
||||
assertTrue(reach.excludedByAudience.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nothing is disclosed when the lists agree`() {
|
||||
val reach = Vault.groupStoryReach(
|
||||
members = listOf(1, 2), me = 1,
|
||||
allowedByAudience = setOf(2), contacts = listOf(2))
|
||||
assertTrue(reach.excludedByAudience.isEmpty())
|
||||
assertTrue(reach.notContacts.isEmpty())
|
||||
}
|
||||
|
||||
// -- Storage --------------------------------------------------------------
|
||||
|
||||
private class MapStorage : Vault.Storage {
|
||||
val records = LinkedHashMap<String, ByteArray>()
|
||||
override fun read(name: String) = records[name]
|
||||
override fun write(name: String, bytes: ByteArray) { records[name] = bytes }
|
||||
override fun delete(name: String) { records.remove(name) }
|
||||
override fun names() = records.keys.toList()
|
||||
}
|
||||
|
||||
/** ⚠️ Without persisting `gid`, a reload forgets which story went where. */
|
||||
@Test
|
||||
fun `the group survives the vault round trip`() {
|
||||
val vault = Vault(MapStorage(),
|
||||
javax.crypto.KeyGenerator.getInstance("AES").apply { init(256) }.generateKey())
|
||||
val now = System.currentTimeMillis() / 1000.0
|
||||
vault.saveStories(listOf(Vault.Story(
|
||||
id = "s1", authorId = 7, mine = false,
|
||||
ref = Attachment.Ref(
|
||||
kind = Attachment.STORY, id = "b1", key = ByteArray(32),
|
||||
digest = ByteArray(32), bytes = 1, mime = "image/jpeg",
|
||||
width = 1, height = 1, duration = 0.0, expires = now + 600),
|
||||
caption = "", postedAt = 1, gid = "g1")), now)
|
||||
assertEquals("g1", vault.loadStories().single().gid)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user