Recognise a pinned message (§6.19) #46

Merged
paulhitt merged 1 commits from feat/pin-a-message into main 2026-09-02 20:07:00 +00:00
3 changed files with 130 additions and 2 deletions
+2
View File
@@ -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 |
@@ -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)
}
}