Reply to a story, as a sealed direct message

Android half of ios#30, spec §6.20. A reply is an ordinary direct
message to the author carrying `sre.sid`, landing in the existing
conversation. Stories gain no comment thread and no viewer list.

The reference carries the id and nothing else. A story's author had the
story by definition, so the only way they no longer hold it is that their
own timer removed it — sending a copy back would resurrect exactly that,
at a moment they did not choose.

🔴 `Content.text` returns the raw body when it has nothing to wrap, so
`storyReplyId` had to join that condition. Left out, a reply with no
message id and no quote would have gone out as plain text and arrived
with the reference silently dropped — on exactly the path a story reply
normally takes. There is a test for it.

The disclosure is shown before the first reply this account sends, names
what is revealed, and says where the reply goes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-02 17:46:24 -04:00
co-authored by Claude Opus 4.8
parent 03c7855a88
commit 819fab3161
6 changed files with 250 additions and 12 deletions
@@ -102,6 +102,18 @@ data class Content(
* rather than refusing the story.
*/
val storyBackground: Int? = null,
/**
* The story this message answers (§6.20), by id alone.
*
* 🔴 **No copy of the story travels with it** — no caption, no thumbnail,
* no blob. This is where §6.20 diverges from §6.9's `re`, and the reason
* is expiry: the author had the story by definition, so the only way they
* no longer hold it is that their own timer removed it. Sending a copy
* back would resurrect exactly that, at a moment they did not choose —
* the trap §6.19 documents for pins, through a different door.
*/
val storyReplyId: String? = null,
) {
/**
@@ -392,18 +404,23 @@ data class Content(
link: LinkPreview? = null,
forwarded: Boolean = false,
lifetimeSeconds: Int? = null,
/** The story this answers (§6.20). */
storyReplyId: String? = null,
): String =
// ⚠️ Both of these belong in this condition, not just in the
// envelope. A plain message with nothing else to carry goes out as
// raw bytes with no envelope at all — so a forward that was not
// ⚠️ Every one of these belongs in this condition, not just in
// the envelope. A plain message with nothing else to carry goes out
// as raw bytes with no envelope at all — so a forward that was not
// counted here would lose its flag and arrive looking like the
// sender's own words, and a timed message would lose its timer and
// live for ever.
// sender's own words, a timed message would lose its timer and live
// for ever, and a story reply would arrive as an ordinary message
// with the reference silently dropped.
if (messageId == null && about == null && link == null && !forwarded
&& (lifetimeSeconds ?: 0) <= 0) body
&& (lifetimeSeconds ?: 0) <= 0 && storyReplyId == null) body
else assemble("text", caption = body, messageId = messageId, about = about,
link = link, forwarded = forwarded,
lifetimeSeconds = lifetimeSeconds)
lifetimeSeconds = lifetimeSeconds,
storyReplyId = storyReplyId)
/**
* A story that is only its caption (§6.21).
@@ -534,6 +551,8 @@ data class Content(
lifetimeSeconds: Int? = null,
background: Int? = null,
storyReplyId: String? = null,
): String {
val parts = mutableListOf("\"fnc\":1", "\"kind\":${quote(kind)}")
// ⚠️ `1`, not `true` — §6.10's shape for every flag on this
@@ -551,6 +570,8 @@ data class Content(
}
messageId?.let { parts += "\"mid\":${quote(it)}" }
about?.let { parts += "\"re\":${it.toJson()}" }
// §6.20 — the story being answered. An id and nothing else.
storyReplyId?.let { parts += "\"sre\":{\"sid\":${quote(it)}}" }
caption?.let { parts += "\"caption\":${quote(it)}" }
expires?.let { parts += "\"exp\":$it" }
groupId?.let { parts += "\"gid\":${quote(it)}" }
@@ -689,6 +710,9 @@ data class Content(
val groupId = json.optString("gid").takeIf { it.isNotEmpty() }
val messageId = json.optString("mid").takeIf { it.isNotEmpty() }
val about = json.optJSONObject("re")?.let(Reference::fromJson)
// §6.20 — id only. Anything else in `sre` is ignored by design.
val storyReplyId = json.optJSONObject("sre")
?.optString("sid")?.takeIf { it.isNotEmpty() }
val caption = json.optString("caption")
// A reaction is not a message and must never become a bubble.
@@ -771,7 +795,8 @@ 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, mentions = mentions(json))
messageId = messageId, about = about, mentions = mentions(json),
storyReplyId = storyReplyId)
}
val ref = json.optJSONObject("att")
@@ -0,0 +1,85 @@
package io.flamenet.e2e
import org.json.JSONObject
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* Story replies as sealed direct messages (spec §6.20, ios#30).
*
* A story keeps no comment thread and no viewer list. A reply is an ordinary
* direct message to the author carrying the story's id — and nothing else.
*/
class StoryReplyTest {
@Test
fun `a reply carries the story id`() {
val wire = Content.text("where is this?", messageId = "m1", storyReplyId = "story-7")
val content = Content.parse(wire)
assertEquals("story-7", content.storyReplyId)
assertEquals("where is this?", content.text)
}
/**
* 🔴 **The whole point of the section.** A reply that carried the caption
* or a thumbnail would put an expired story back on the author's screen —
* the failure §6.19 documents for pins, arriving by a different door.
*/
@Test
fun `the story reference carries nothing but the id`() {
val wire = Content.text("nice", messageId = "m1", storyReplyId = "story-7")
val sre = JSONObject(wire).getJSONObject("sre")
assertEquals(setOf("sid"), sre.keys().asSequence().toSet())
}
/**
* 🔴 **The raw-bytes shortcut is the trap.** `text()` returns the body
* unwrapped when there is nothing to carry. Without `storyReplyId` in that
* condition, a reply with no message id and no quote goes out as plain
* text and arrives with the reference silently dropped — on exactly the
* path a story reply normally takes.
*/
@Test
fun `a reply with nothing else attached is still an envelope`() {
val wire = Content.text("nice", storyReplyId = "story-7")
assertTrue(wire.startsWith("{\"fnc\":1"), wire)
assertEquals("story-7", Content.parse(wire).storyReplyId)
}
@Test
fun `an ordinary message carries no story reference`() {
val wire = Content.text("hello", messageId = "m1")
assertTrue(!wire.contains("sre"), wire)
assertNull(Content.parse(wire).storyReplyId)
}
/** ⚠️ `fnc` must stay first: the receive side recognises it by prefix. */
@Test
fun `the marker stays first`() {
assertTrue(Content.text("hi", storyReplyId = "s1").startsWith("{\"fnc\":1"))
}
/** A newer peer may add fields to `sre`; the message must still read. */
@Test
fun `an unknown story reference shape does not break the message`() {
val wire = """{"fnc":1,"kind":"text","caption":"hi","sre":{"sid":"s1","extra":9}}"""
val content = Content.parse(wire)
assertEquals("hi", content.text)
assertEquals("s1", content.storyReplyId)
}
/** An `sre` with no usable id is absent, not an empty string. */
@Test
fun `an empty story id reads as no reference`() {
val wire = """{"fnc":1,"kind":"text","caption":"hi","sre":{"sid":""}}"""
assertNull(Content.parse(wire).storyReplyId)
}
/** A story reply is a renderable message, not control traffic. */
@Test
fun `a story reply is renderable`() {
assertTrue(Content.parse(Content.text("hi", storyReplyId = "s1")).isRenderable)
}
}