From bfe9fff4304d28854fab95b1626914eaee593aed Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Thu, 3 Sep 2026 14:15:11 -0400 Subject: [PATCH] Understand stories and mentions from iOS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../main/kotlin/io/flamenet/e2e/Content.kt | 99 +++++++++- .../e2e/StoryAndMentionInteropTest.kt | 171 ++++++++++++++++++ 2 files changed, 267 insertions(+), 3 deletions(-) create mode 100644 engine/src/test/kotlin/io/flamenet/e2e/StoryAndMentionInteropTest.kt diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Content.kt b/engine/src/main/kotlin/io/flamenet/e2e/Content.kt index 0644683..bb1dc49 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/Content.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/Content.kt @@ -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 = 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 = 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>? = null, + mentions: List = 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") diff --git a/engine/src/test/kotlin/io/flamenet/e2e/StoryAndMentionInteropTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/StoryAndMentionInteropTest.kt new file mode 100644 index 0000000..349fb5d --- /dev/null +++ b/engine/src/test/kotlin/io/flamenet/e2e/StoryAndMentionInteropTest.kt @@ -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) } + } +}