From 36146657326e96b84076ba5bd8dd7ccf2807df40 Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Wed, 2 Sep 2026 08:50:04 -0400 Subject: [PATCH] =?UTF-8?q?Recognise=20a=20pinned=20message=20(=C2=A76.19)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codec only โ€” the app does not pin yet โ€” but without this branch an inbound pin falls through parse to the attachment path and comes back renderable, so every pin the iPhone app sets would draw an empty bubble in the conversation. isRenderable excludes it for the same reason it excludes a reaction, and it is NOT in Kind.CONTROL: a build that swallowed it as generic control would drop every pin while looking like it worked. ๐Ÿ”ด The envelope carries 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. Refs paulhitt/flamenet-messenger-ios#50 Co-Authored-By: Claude Opus 4.8 --- PARITY.md | 2 + .../main/kotlin/io/flamenet/e2e/Content.kt | 44 +++++++++- .../test/kotlin/io/flamenet/e2e/PinTest.kt | 86 +++++++++++++++++++ 3 files changed, 130 insertions(+), 2 deletions(-) create mode 100644 engine/src/test/kotlin/io/flamenet/e2e/PinTest.kt 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) + } +} -- 2.54.0