Understand stories and mentions from iOS

Engine parity for the five features iOS regained: text stories, mentions,
story reactions, story replies. This build could already receive them —
legibly, because each degrades to its caption — but understood none of
them as what they are.

🔴 Every envelope in the test is the shape **iOS** assembles, not this
engine's own output. Two implementations that each only test against
themselves agree right up to the day somebody sends a real message, and
the symptom is a mention that never rings or a story that arrives as
"[unsupported attachment]", with nothing in either log to say why.

Two things the tests caught that reasoning would not have:

⚠️ Kotlin prints a Double in scientific notation, so this engine writes
`"exp":1.8E9` where iOS writes `1800000000.0`. Both are valid JSON and
both sides read either — but a story's expiry decides whether it shows at
all, and "arrived already expired" is indistinguishable from "never
arrived". Now pinned on both sides. Not a new risk: picture stories have
carried `exp` this way all along, which is the reason it works.

⚠️ An earlier version of one test built a JSONObject from an assembled
envelope and re-serialised it — which reordered the keys and moved `fnc`
off the front. It failed, correctly, and is a neat demonstration of why
both engines hand-assemble these strings instead of encoding a map.

UI comes next; this is the layer that decides whether the two apps agree
about what a message means.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-03 14:15:11 -04:00
co-authored by Claude Opus 4.8
parent 688a519e0c
commit bfe9fff430
2 changed files with 267 additions and 3 deletions
@@ -61,6 +61,36 @@ data class Content(
val link: LinkPreview? = null,
/** A live stream this story embeds (§6.13). */
val streamUrl: String? = null,
/**
* Who was named in this message, by user id.
*
* 🔴 Ids, not names. A mention has to reach somebody whose group is muted,
* and matching on a display name would mean anybody could be reached by
* typing their name — or missed, because they had renamed themselves.
*/
val mentions: List<Int> = emptyList(),
/**
* When this is a story made only of words, when it stops showing.
*
* 🔴 A story envelope with no attachment. Every story path here assumed a
* picture, so a text story had nowhere to be carried — the same shape that
* was missing on iOS.
*/
val textStoryExpires: Double? = null,
/**
* This reaction is about a STORY, not a message.
*
* ⚠️ Both are `kind: "react"`; only this separates them. Without it a story
* reaction tries to fold its emoji onto a message with that id — and if one
* ever matched, onto entirely the wrong thing.
*/
val reactsToStory: Boolean = false,
/** This message replies to a story rather than to a bubble. */
val repliesToStory: Boolean = false,
) {
/**
@@ -297,8 +327,46 @@ data class Content(
messageId: String? = null,
about: Reference? = null,
groupName: String? = null,
mentions: List<Int> = emptyList(),
): String = assemble("text", caption = body, groupId = groupId,
messageId = messageId, about = about, groupName = groupName)
messageId = messageId, about = about, groupName = groupName,
mentions = mentions)
/**
* A story that is only words.
*
* ⚠️ Carries a [messageId], which is what identifies it across the
* sender's devices — a picture story is identified by its blob token
* and a text story has none. Without it the same story appears once
* per phone the sender posted from.
*/
fun textStory(text: String, expires: Double, messageId: String): String =
assemble(Attachment.STORY, caption = text, expires = expires,
messageId = messageId)
/**
* React to somebody's story.
*
* 🔴 Goes to the AUTHOR alone. A story is sent to each viewer rather
* than hosted, so there is no shared object to attach a reaction to
* and nobody else to tell — which is why other viewers never learn who
* reacted: there is nowhere for them to look.
*/
fun storyReaction(emoji: String, storyKey: String, author: Long): String =
assemble(Kind.REACT, caption = emoji,
about = Reference(storyKey, author, ""), aboutAStory = true)
/**
* Reply to somebody's story, as an ordinary sealed direct message.
*
* ⚠️ [preview] travels with the reply because stories expire: by the
* time this is read the story may be gone, and "replied to your story"
* with no idea which one cannot be understood.
*/
fun storyReply(body: String, storyKey: String, author: Long,
preview: String, messageId: String): String =
assemble("text", caption = body, messageId = messageId,
about = Reference(storyKey, author, preview), aboutAStory = true)
fun attachment(
ref: Attachment.Ref,
@@ -374,6 +442,8 @@ data class Content(
groupName: String? = null,
filename: String? = null,
label: Pair<Int, List<Int>>? = null,
mentions: List<Int> = emptyList(),
aboutAStory: Boolean = false,
): String {
val parts = mutableListOf("\"fnc\":1", "\"kind\":${quote(kind)}")
messageId?.let { parts += "\"mid\":${quote(it)}" }
@@ -396,6 +466,10 @@ data class Content(
// predates them ignores them and still reads the caption, which is
// what keeps a file message legible to an older peer.
filename?.takeIf { it.isNotEmpty() }?.let { parts += "\"fn\":${quote(it)}" }
if (mentions.isNotEmpty()) {
parts += "\"men\":${mentions.sorted().joinToString(",", "[", "]")}"
}
if (aboutAStory) parts += "\"st\":1"
if (label != null) {
parts += "\"cls\":${label.first}"
parts += "\"cmp\":${label.second.sorted().joinToString(",", "[", "]")}"
@@ -450,13 +524,30 @@ data class Content(
val messageId = json.optString("mid").takeIf { it.isNotEmpty() }
val about = json.optJSONObject("re")?.let(Reference::fromJson)
val caption = json.optString("caption")
// Additive keys. An older peer omits them and every default here
// is what that peer meant.
val aboutAStory = json.optInt("st", 0) != 0 || json.optBoolean("st", false)
val mentions = json.optJSONArray("men")?.let { array ->
(0 until array.length()).map { array.optInt(it) }
} ?: emptyList()
// A reaction is not a message and must never become a bubble.
// Checked before everything else so a field-ordering change cannot
// let one fall through and render as an empty line in the thread.
if (kind == Kind.REACT && about != null) {
return Content("", kind = kind, raw = json, groupId = groupId,
messageId = messageId, about = about, reaction = caption)
messageId = messageId, about = about, reaction = caption,
reactsToStory = aboutAStory)
}
// A story made of words. Checked before the attachment paths, which
// would otherwise treat it as an attachment reference that failed
// to parse — and before the "[unsupported attachment]" fallback an
// older build correctly shows for it.
if (kind == Attachment.STORY && json.optJSONObject("att") == null) {
return Content(caption, kind = kind, raw = json, groupId = groupId,
messageId = messageId,
textStoryExpires = json.optDouble("exp", 0.0))
}
// A sticker is renderable but is NOT text: it draws as a glyph with
@@ -498,7 +589,9 @@ data class Content(
if (kind == Kind.TEXT) {
val link = json.optJSONObject("lnk")?.let { LinkPreview.fromJson(it) }
return Content(caption, kind = kind, raw = json, groupId = groupId, link = link,
messageId = messageId, about = about)
messageId = messageId, about = about,
mentions = mentions,
repliesToStory = aboutAStory && about != null)
}
val ref = json.optJSONObject("att")
@@ -0,0 +1,171 @@
package io.flamenet.e2e
import org.json.JSONObject
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
/**
* Stories and mentions, against envelopes the **iOS** build produces.
*
* 🔴 Every string below is the shape iOS assembles, not this engine's own
* output. Two implementations that each only test against themselves agree
* right up to the day somebody sends a real message — and the symptom is a
* mention that never rings, or a story that arrives as "[unsupported
* attachment]", with nothing in either log to say why.
*/
class StoryAndMentionInteropTest {
// MARK: text stories
private val iosTextStory =
"""{"fnc":1,"kind":"story","mid":"story-1","caption":"only words","exp":1800000000.0}"""
@Test
fun `a text story from iOS parses as a story rather than an attachment`() {
val content = Content.parse(iosTextStory)
assertEquals("only words", content.text)
assertEquals(1800000000.0, content.textStoryExpires)
assertEquals(null, content.attachment)
assertEquals("story-1", content.messageId)
}
@Test
fun `this engine assembles a text story the same way`() {
val wire = Content.textStory("hello", 1800000000.0, "m-1")
// ⚠️ Asserted on the WIRE STRING, not on a JSONObject built from it.
// Round-tripping through JSONObject reorders the keys and puts `fnc`
// somewhere other than first — which is exactly the hazard that makes
// both engines hand-assemble these strings. An earlier version of this
// test did that and failed, correctly.
assertTrue(wire.startsWith("{\"fnc\":1"), wire)
assertTrue(wire.contains("\"kind\":\"story\""), wire)
assertTrue(wire.contains("\"caption\":\"hello\""), wire)
assertEquals("hello", Content.parse(wire).text)
}
/**
* ⚠️ Kotlin prints a Double in scientific notation, so this engine writes
* `"exp":1.8E9` where iOS writes `1800000000.0`. Both are valid JSON and
* both sides read either — but a story's expiry decides whether it shows
* at all, so the shape is pinned here rather than assumed. (This is not
* new: picture stories have carried `exp` this way all along.)
*/
@Test
fun `an expiry written in scientific notation reads back`() {
val wire = Content.textStory("x", 1800000000.0, "m")
assertTrue(wire.contains("\"exp\":1.8E9"), wire)
assertEquals(1800000000.0, Content.parse(wire).textStoryExpires)
}
/** 🔴 A picture story must be completely unaffected. */
@Test
fun `a picture story still parses as an attachment`() {
val ref = Attachment.Ref(
kind = Attachment.STORY, id = "a".repeat(64),
key = ByteArray(32) { 0x11 }, digest = ByteArray(32) { 0x22 },
bytes = 10, mime = "image/jpeg", width = 4, height = 3, expires = 1800000000.0,
)
val content = Content.parse(Content.attachment(ref, "look"))
assertNotNull(content.attachment)
assertEquals(null, content.textStoryExpires)
}
// MARK: mentions
@Test
fun `mentions from iOS arrive as ids`() {
val ios = """{"fnc":1,"kind":"text","caption":"morning @Ada","gid":"g1","men":[3,7]}"""
val content = Content.parse(ios)
assertEquals(listOf(3, 7), content.mentions)
assertEquals("g1", content.groupId)
}
@Test
fun `this engine emits mentions the same way`() {
val json = Content.groupText("hi @Ada", "g1", mentions = listOf(7, 3))
assertTrue(json.contains("\"men\":[3,7]"), json)
assertEquals(listOf(3, 7), Content.parse(json).mentions)
}
/** ⚠️ A message that merely spells a name carries no mention. */
@Test
fun `spelling a name is not a mention`() {
val json = Content.groupText("hello @Ada", "g1")
assertFalse(json.contains("\"men\""))
assertTrue(Content.parse(json).mentions.isEmpty())
}
// MARK: story reactions
@Test
fun `a story reaction from iOS is not mistaken for a message reaction`() {
val ios = """{"fnc":1,"kind":"react","caption":"❤️","re":{"mid":"s-1","author":42,"preview":""},"st":1}"""
val content = Content.parse(ios)
assertTrue(content.reactsToStory)
assertEquals("❤️", content.reaction)
assertEquals("s-1", content.about?.messageId)
}
/**
* 🔴 The separation that matters. Both are `kind: "react"`; without the
* flag a story reaction folds onto a message with that id — and if one
* ever matched, onto entirely the wrong thing.
*/
@Test
fun `an ordinary message reaction is not read as a story one`() {
val json = Content.reaction("👍", Content.Reference("m-1", 5, ""))
val content = Content.parse(json)
assertFalse(content.reactsToStory)
assertFalse(json.contains("\"st\""))
}
@Test
fun `this engine emits a story reaction the same way`() {
val json = Content.storyReaction("😂", "s-2", 9L)
assertTrue(json.contains("\"st\":1"), json)
val content = Content.parse(json)
assertTrue(content.reactsToStory)
assertEquals("😂", content.reaction)
}
// MARK: story replies
@Test
fun `a story reply from iOS keeps what it is about`() {
val ios = """{"fnc":1,"kind":"text","mid":"m-9","caption":"lovely","re":{"mid":"s-3","author":1,"preview":"at the beach"},"st":1}"""
val content = Content.parse(ios)
assertTrue(content.repliesToStory)
assertEquals("lovely", content.text)
assertEquals("at the beach", content.about?.preview)
}
@Test
fun `an ordinary reply is not a story reply`() {
val json = Content.text("sure", messageId = "m-1",
about = Content.Reference("other", 2, "earlier"))
assertFalse(Content.parse(json).repliesToStory)
}
@Test
fun `this engine emits a story reply the same way`() {
val json = Content.storyReply("nice", "s-4", 3L, "sunset", "m-2")
val content = Content.parse(json)
assertTrue(content.repliesToStory)
assertEquals("sunset", content.about?.preview)
assertEquals("nice", content.text)
}
/** 🔴 fnc stays first, whatever else was added. */
@Test
fun `every envelope this engine builds starts with fnc`() {
listOf(
Content.textStory("x", 1.0, "m"),
Content.storyReaction("👍", "s", 1L),
Content.storyReply("hi", "s", 1L, "p", "m"),
Content.groupText("hi", "g", mentions = listOf(1)),
).forEach { assertTrue(it.startsWith("{\"fnc\":1"), it) }
}
}