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
@@ -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)
}
}