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:
co-authored by
Claude Opus 4.8
parent
03c7855a88
commit
819fab3161
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user