Recognise a pinned message (§6.19)

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 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-02 16:05:41 -04:00
co-authored by Claude Opus 4.8
parent 400c910615
commit 3614665732
3 changed files with 130 additions and 2 deletions
@@ -79,6 +79,14 @@ data class Content(
* off, which is the rule the whole section rests on.
*/
val mentions: List<Long> = 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.
//
@@ -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)
}
}