Post several pictures as one story sequence #53

Merged
paulhitt merged 1 commits from feat/multi-item-stories into main 2026-09-02 21:48:29 +00:00
4 changed files with 231 additions and 1 deletions
Showing only changes of commit d525ef3d69 - Show all commits
+19
View File
@@ -691,3 +691,22 @@ Android has a separate record and a test named for exactly that.
⚠️ Android injects the setting into `Session` as a lambda rather than reading
preferences in the engine layer, which has no Android dependency. An absent
value reads as OFF on both platforms.
||||||| parent of f67edeb (Post several pictures as one story sequence)
## Multi-item stories (§6.23, ios#53)
Several pictures as one sequence. Each item is an ordinary story envelope
carrying a `set` that names the sequence, its position and its size.
🔴 **One envelope per item, not one carrying an array.** The array is the
obvious encoding and degrades badly: a client predating §6.23 requires a single
`att`, fails that guard, and renders the whole set as a message in the
conversation. Separate envelopes degrade to what they actually are — several
stories from one person, in order — which every existing build already draws.
🔴 **Expiry is per set, and the earliest stamp wins.** Items are required to
carry the same `exp`, but a sender can write different ones; if the latest won,
the difference would keep part of a sequence alive after the rest had gone. That
decay is confusing to look at *and* a disclosure — the gaps reveal the order the
author added things. Both engines have `setExpiry` and a test for it. A zero
means "never" and is skipped rather than read as "expires at the epoch", which
would kill the whole set.
⚠️ **Grouping renders what arrived.** `n` is the sender's claim and any item can
fail to arrive, so a set is never held back or shown as incomplete. Stories with
no set are keyed so two of them cannot collide into one group.
@@ -114,6 +114,9 @@ data class Content(
* the trap §6.19 documents for pins, through a different door.
*/
val storyReplyId: String? = null,
/** Which sequence this story belongs to (§6.23), if any. */
val storySet: StorySet? = null,
) {
/**
@@ -146,6 +149,27 @@ data class Content(
* was said. The preview is the sender's copy of their own words, the only
* copy anyone can be sure of.
*/
/**
* Which sequence a story belongs to, and where in it (§6.23).
*
* ⚠️ [n] is the SENDER'S CLAIM and may be wrong: items are separate
* envelopes and any of them can fail to arrive. Never wait for [n] items
* and never show a set as incomplete — a missing item is indistinguishable
* from one that was never sent.
*/
data class StorySet(val id: String, val i: Int, val n: Int) {
fun toJson(): JSONObject =
JSONObject().put("id", id).put("i", i).put("n", n)
companion object {
fun fromJson(o: JSONObject): StorySet? {
val id = o.optString("id")
if (id.isEmpty()) return null
return StorySet(id, o.optInt("i", 0), o.optInt("n", 1))
}
}
}
data class Reference(val messageId: String, val author: Long, val preview: String) {
fun toJson(): JSONObject = JSONObject()
.put("mid", messageId).put("author", author).put("preview", preview)
@@ -459,6 +483,16 @@ data class Content(
groupName: String? = null,
forwarded: Boolean = false,
lifetimeSeconds: Int? = null,
/**
* The sequence this item belongs to (§6.23).
*
* ⚠️ Every item of a set must carry the same `exp`. Expiry is per
* set: per-item expiry lets a sequence decay into items 0 and 2,
* which is confusing and is also a disclosure — the gaps reveal
* the order the author added things.
*/
storySet: StorySet? = null,
): String = assemble(
kind = ref.kind,
caption = caption?.takeIf { it.isNotEmpty() },
@@ -471,6 +505,8 @@ data class Content(
groupName = groupName,
forwarded = forwarded,
lifetimeSeconds = lifetimeSeconds,
storySet = storySet,
)
/**
@@ -553,6 +589,8 @@ data class Content(
background: Int? = null,
storyReplyId: String? = null,
storySet: StorySet? = null,
): String {
val parts = mutableListOf("\"fnc\":1", "\"kind\":${quote(kind)}")
// ⚠️ `1`, not `true` — §6.10's shape for every flag on this
@@ -588,6 +626,9 @@ data class Content(
streamUrl?.let { parts += "\"stream\":${quote(it)}" }
// §6.21 — a text story's background style. A hint, never required.
background?.let { parts += "\"bg\":$it" }
// §6.23 — the sequence this item belongs to.
storySet?.let { parts += "\"set\":${it.toJson()}" }
return parts.joinToString(",", prefix = "{", postfix = "}")
}
@@ -811,7 +852,8 @@ data class Content(
)
return Content(caption, kind = kind, raw = json, attachment = ref,
groupId = groupId, messageId = messageId, about = about,
streamUrl = json.optString("stream").takeIf { it.isNotEmpty() })
streamUrl = json.optString("stream").takeIf { it.isNotEmpty() },
storySet = json.optJSONObject("set")?.let { StorySet.fromJson(it) })
}
}
}
@@ -176,6 +176,40 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
return result.sortedByDescending { it.postedAt }
}
/**
* Collapse a set (§6.23) to the expiry every item shares.
*
* 🔴 **The EARLIEST wins.** Every item is required to carry the same
* `exp`, but a sender can write different ones — and if the latest
* won, the difference would keep part of a sequence alive after the
* rest had gone, which is the decay the per-set rule exists to stop.
*
* ⚠️ A zero means "no expiry on that item" and is skipped, not read
* as "expires at the epoch" — which would kill the whole set.
*/
fun setExpiry(items: List<Story>): Double =
items.mapNotNull { it.ref.expires.takeIf { e -> e > 0 } }.minOrNull() ?: 0.0
/**
* Group one author's stories into sets, in position order (§6.23).
*
* ⚠️ Renders what arrived. `n` is the sender's claim and any item can
* fail to arrive, so a set is never held back or shown as incomplete —
* a missing item is indistinguishable from one never sent.
*/
fun groupedStories(stories: List<Story>): List<List<Story>> {
val order = mutableListOf<String>()
val sets = LinkedHashMap<String, MutableList<Story>>()
stories.forEach { story ->
// A story with no set is its own group of one, keyed so it can
// never collide with a real set id.
val key = story.set?.let { "set:${it.id}" } ?: "one:${story.id}"
if (sets[key] == null) { order += key; sets[key] = mutableListOf() }
sets.getValue(key) += story
}
return order.map { key -> sets.getValue(key).sortedBy { it.set?.i ?: 0 } }
}
fun threadForUser(userId: Long) = "user:$userId"
fun threadForGroup(groupId: String) = "group:$groupId"
@@ -507,6 +541,9 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
* a story that is the whole audience, not one correspondent.
*/
val link: Content.LinkPreview? = null,
/** Which sequence this story belongs to (§6.23), if any. */
val set: Content.StorySet? = null,
) {
/**
* When this story stops rendering, unix seconds. 0 = never.
@@ -754,6 +791,8 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
seen = o.optBoolean("seen", false),
textExpiry = o.optDouble("exp", 0.0).takeIf { it > 0.0 },
background = if (o.has("bg")) o.optInt("bg") else null,
set = o.optJSONObject("set")?.let { Content.StorySet.fromJson(it) },
)
}
}
@@ -786,6 +825,7 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
.also { if (story.background != null) it.put("bg", story.background) }
.put("caption", story.caption)
.put("at", story.postedAt)
.also { j -> story.set?.let { j.put("set", it.toJson()) } }
.put("seen", story.seen))
}
return array
@@ -0,0 +1,129 @@
package io.flamenet.e2e
import org.json.JSONObject
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
/** Multi-item stories (spec §6.23, ios#53). */
class StorySetTest {
private fun ref(id: Int, expires: Double = 100.0) = Attachment.Ref(
kind = Attachment.STORY, id = "b$id", key = ByteArray(32),
digest = ByteArray(32), bytes = 1, mime = "image/jpeg",
width = 1, height = 1, duration = 0.0, expires = expires,
)
private fun item(id: Int, set: String?, i: Int = 0, n: Int = 1, expires: Double = 100.0) =
Vault.Story(
id = "s$id", authorId = 7, mine = false, ref = ref(id, expires),
caption = "", postedAt = id.toLong(),
set = set?.let { Content.StorySet(it, i, n) },
)
// -- The wire -------------------------------------------------------------
@Test
fun `an item carries its set position and size`() {
val wire = Content.attachment(ref(1), storySet = Content.StorySet("set-1", 2, 5))
val content = Content.parse(wire)
assertEquals("set-1", content.storySet?.id)
assertEquals(2, content.storySet?.i)
assertEquals(5, content.storySet?.n)
}
/**
* 🔴 One envelope per item. An array of attachments would fail the
* single-`att` guard on any build predating §6.23 and render the whole set
* as a message in the conversation.
*/
@Test
fun `an item is an ordinary story envelope`() {
val json = JSONObject(Content.attachment(ref(1), storySet = Content.StorySet("s", 0, 2)))
assertEquals(Attachment.STORY, json.getString("kind"))
assertTrue(json.has("att"))
assertFalse(json.has("atts"))
}
@Test
fun `an ordinary story has no set`() {
assertNull(Content.parse(Content.attachment(ref(1))).storySet)
}
// -- Grouping -------------------------------------------------------------
@Test
fun `items of one set are grouped in position order`() {
val groups = Vault.groupedStories(listOf(
item(3, "a", i = 2, n = 3), item(1, "a", i = 0, n = 3), item(2, "a", i = 1, n = 3)))
assertEquals(1, groups.size)
assertEquals(listOf("s1", "s2", "s3"), groups[0].map { it.id })
}
/** ⚠️ `n` says three; two came. Render what arrived. */
@Test
fun `a partly arrived set is still rendered`() {
val groups = Vault.groupedStories(listOf(
item(1, "a", i = 0, n = 3), item(3, "a", i = 2, n = 3)))
assertEquals(1, groups.size)
assertEquals(2, groups[0].size)
}
/** ⚠️ Two singles must not collide just because neither has a set id. */
@Test
fun `two sets and a single stay apart`() {
val groups = Vault.groupedStories(listOf(
item(1, "a", i = 0, n = 2), item(2, "b"), item(3, null), item(4, "a", i = 1, n = 2)))
assertEquals(3, groups.size)
assertEquals(listOf("s1", "s4"), groups[0].map { it.id })
}
// -- Expiry ---------------------------------------------------------------
/** 🔴 The earliest wins, so a sender cannot outlive part of its own set. */
@Test
fun `a set expires at its earliest stamp`() {
assertEquals(100.0, Vault.setExpiry(listOf(
item(1, "a", expires = 300.0),
item(2, "a", expires = 100.0),
item(3, "a", expires = 200.0))))
}
/** ⚠️ A zero means "never" and must not be read as "expires at the epoch". */
@Test
fun `a zero stamp does not expire the whole set`() {
assertEquals(500.0, Vault.setExpiry(listOf(
item(1, "a", expires = 0.0), item(2, "a", expires = 500.0))))
}
@Test
fun `a set of never-expiring items never expires`() {
assertEquals(0.0, Vault.setExpiry(listOf(
item(1, "a", expires = 0.0), item(2, "a", expires = 0.0))))
}
// -- 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()
}
/** ⚠️ The set survives a reload, or a relaunch scatters every sequence. */
@Test
fun `the set 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(item(1, "a", i = 1, n = 4, expires = now + 600)), now)
val back = vault.loadStories().single()
assertEquals("a", back.set?.id)
assertEquals(1, back.set?.i)
assertEquals(4, back.set?.n)
}
}