diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Content.kt b/engine/src/main/kotlin/io/flamenet/e2e/Content.kt index 1c4173f..929ca09 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/Content.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/Content.kt @@ -100,6 +100,13 @@ data class Content( * there, or it does nothing. */ val editOf: String? = null, + /** + * Set iff this envelope pins or unpins a message (§6.19): the id being + * pinned, or the empty string to take the pin down. + * + * 🔴 An id and never a copy of the message. + */ + val pins: String? = null, ) { /** @@ -181,7 +188,7 @@ data class Content( * arriving as a duplicate, which is worse than the typo. */ val isRenderable: Boolean - get() = reaction == null && call == null && editOf == null && !isControl + get() = reaction == null && call == null && pins == null && editOf == null && !isControl /** * Control traffic: consumed by a layer, never shown. @@ -339,6 +346,15 @@ data class Content( */ const val EDIT = "edit" + /** + * A pinned message, or a pin being taken down (§6.19). + * + * 🔴 **Not in [CONTROL].** Control kinds are swallowed whole; this one + * has to be acted on, and a build that merely swallowed it would drop + * every pin while looking like it worked. + */ + const val PIN = "pin" + /** * ⚠️ Consumed, never shown. Each of these is a real message the web * client sends today, and rendering one puts raw JSON in somebody's @@ -355,7 +371,7 @@ data class Content( * draw (shown as a placeholder) — never raw JSON in a bubble. */ val KNOWN = CONTROL + setOf( - TEXT, REACT, STICKER, EDIT, + TEXT, REACT, STICKER, PIN, EDIT, Attachment.IMAGE, Attachment.AUDIO, Attachment.STORY, Attachment.FILE, CallSignal.OFFER, CallSignal.ANSWER, CallSignal.END, ) @@ -516,6 +532,21 @@ data class Content( assemble(Kind.EDIT, caption = newText, groupId = groupId, about = Reference(of, author = 0, preview = "")) + /** + * A pin, or a pin being taken down (§6.19). + * + * ⚠️ An empty [messageId] unpins. Removal has to be a message rather + * than an absence: the other device has already drawn the pin, and + * there is no other way to reach it. + * + * 🔴 An id and never a copy of the message. A pin carrying the text + * would keep a message readable after its own timer had removed it, so + * pinning would quietly defeat disappearing messages. + */ + fun pin(messageId: String, groupId: String? = null): String = + assemble(Kind.PIN, groupId = groupId, + about = Reference(messageId, author = 0, preview = "")) + fun callSignal(signal: CallSignal): String { val call = JSONObject().put("id", signal.callId) signal.sdp?.let { call.put("sdp", it) } @@ -731,6 +762,14 @@ data class Content( about = about, editOf = about.messageId) } + // §6.19 — a pin changes a banner and is never a bubble. Checked + // before the control swallow, which would consume it correctly and + // act on nothing. + if (kind == Kind.PIN && about != null) { + return Content("", kind = kind, raw = json, groupId = groupId, + about = about, pins = about.messageId) + } + // Control traffic. Returned with its envelope so the caller can act // on it, and never renderable. // diff --git a/engine/src/test/kotlin/io/flamenet/e2e/PinTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/PinTest.kt new file mode 100644 index 0000000..bcef44f --- /dev/null +++ b/engine/src/test/kotlin/io/flamenet/e2e/PinTest.kt @@ -0,0 +1,86 @@ +package io.flamenet.e2e + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Pinning a message in a conversation (§6.19). + * + * ⚠️ **The codec half.** The Android app does not pin yet, but it must already + * understand an envelope the iPhone app sends — and in particular must not draw + * one as a message, which is what an unrecognised pin becomes. + * + * 🔴 A pin is an id and never a copy of the message. A pin carrying the text + * would keep a message readable after its own timer had removed it, so pinning + * would quietly defeat disappearing messages. + */ +class PinTest { + + @Test + fun `a pin names the message it pins`() { + val content = Content.parse(Content.pin("abc")) + assertEquals("abc", content.pins) + assertEquals(Content.Kind.PIN, content.kind) + } + + /** An empty id takes the pin down. */ + @Test + fun `an unpin round trips`() { + assertEquals("", Content.parse(Content.pin("")).pins) + } + + /** + * 🔴 The bug this prevents. An unrecognised pin falls through `parse` to + * the attachment path and comes back renderable — an empty bubble in + * somebody's conversation every time the other side pins something. + */ + @Test + fun `a pin is not a message`() { + val content = Content.parse(Content.pin("abc")) + assertFalse(content.isRenderable, "a pin would be drawn as a bubble") + assertTrue(content.text.isEmpty()) + assertNull(content.attachment) + } + + /** + * ⚠️ Not swallowed as generic control either. A build that consumed it + * silently would drop every pin while looking like it worked. + */ + @Test + fun `a pin is a known kind, not unknown control`() { + assertTrue(Content.Kind.PIN in Content.Kind.KNOWN) + assertTrue(Content.Kind.PIN !in Content.Kind.CONTROL, + "a pin swallowed as control would drop silently") + } + + /** 🔴 The envelope carries no copy of the message — only its id. */ + @Test + fun `the envelope carries no copy of the message`() { + val wire = Content.pin("abc") + assertFalse(wire.contains("caption"), "a pin carried message text: $wire") + assertEquals("", Content.parse(wire).about?.preview) + } + + /** Group routing survives, for when a group transcript can name a message. */ + @Test + fun `a group pin keeps its routing`() { + val content = Content.parse(Content.pin("abc", groupId = "g1")) + assertEquals("g1", content.groupId) + assertEquals("abc", content.pins) + } + + /** ⚠️ `fnc` first, like every other envelope. */ + @Test + fun `the envelope starts with the marker`() { + assertTrue(Content.pin("abc").startsWith("{\"fnc\":1,")) + } + + /** A pin with no target is not a pin. */ + @Test + fun `a pin with no reference is not treated as one`() { + assertNull(Content.parse("""{"fnc":1,"kind":"pin"}""").pins) + } +}