diff --git a/PARITY.md b/PARITY.md index e2f2d1e..4508aa6 100644 --- a/PARITY.md +++ b/PARITY.md @@ -103,6 +103,8 @@ From `E2E/E2EInbox.swift`, which is the real feature list. ||||||| parent of fa9fdf4 (Search inside messages, across every conversation) | Reactions | 🟡 codec done, no UI | | Search inside messages | ✅ scanned, never indexed — no plaintext index at rest | +||||||| parent of 9d33e60 (Recognise a pinned message (§6.19)) +| Pinned message (§6.19) | 🟡 codec done, in progress — an inbound pin is recognised and not drawn as an empty bubble | | Photos | ✅ send via the system picker, render inline | | Voice notes (record + play) | ✅ AAC/MP4, tap to record, tap to send | | Stories, and seen-state | ✅ 24h, sent per-contact, expired bytes deleted | diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Content.kt b/engine/src/main/kotlin/io/flamenet/e2e/Content.kt index 5ec00a0..e445ed3 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/Content.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/Content.kt @@ -79,6 +79,14 @@ data class Content( * off, which is the rule the whole section rests on. */ val mentions: List = emptyList(), + + /** + * 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, ) { /** @@ -160,7 +168,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. @@ -318,6 +326,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 @@ -334,7 +351,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, CallSignal.OFFER, CallSignal.ANSWER, CallSignal.END, ) @@ -449,6 +466,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) } @@ -654,6 +686,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) + } +}