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:
co-authored by
Claude Opus 4.8
parent
968a88789a
commit
a727475447
@@ -100,6 +100,13 @@ data class Content(
|
|||||||
* there, or it does nothing.
|
* there, or it does nothing.
|
||||||
*/
|
*/
|
||||||
val editOf: String? = null,
|
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.
|
* arriving as a duplicate, which is worse than the typo.
|
||||||
*/
|
*/
|
||||||
val isRenderable: Boolean
|
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.
|
* Control traffic: consumed by a layer, never shown.
|
||||||
@@ -339,6 +346,15 @@ data class Content(
|
|||||||
*/
|
*/
|
||||||
const val EDIT = "edit"
|
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
|
* ⚠️ Consumed, never shown. Each of these is a real message the web
|
||||||
* client sends today, and rendering one puts raw JSON in somebody's
|
* 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.
|
* draw (shown as a placeholder) — never raw JSON in a bubble.
|
||||||
*/
|
*/
|
||||||
val KNOWN = CONTROL + setOf(
|
val KNOWN = CONTROL + setOf(
|
||||||
TEXT, REACT, STICKER, EDIT,
|
TEXT, REACT, STICKER, PIN, EDIT,
|
||||||
Attachment.IMAGE, Attachment.AUDIO, Attachment.STORY, Attachment.FILE,
|
Attachment.IMAGE, Attachment.AUDIO, Attachment.STORY, Attachment.FILE,
|
||||||
CallSignal.OFFER, CallSignal.ANSWER, CallSignal.END,
|
CallSignal.OFFER, CallSignal.ANSWER, CallSignal.END,
|
||||||
)
|
)
|
||||||
@@ -516,6 +532,21 @@ data class Content(
|
|||||||
assemble(Kind.EDIT, caption = newText, groupId = groupId,
|
assemble(Kind.EDIT, caption = newText, groupId = groupId,
|
||||||
about = Reference(of, author = 0, preview = ""))
|
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 {
|
fun callSignal(signal: CallSignal): String {
|
||||||
val call = JSONObject().put("id", signal.callId)
|
val call = JSONObject().put("id", signal.callId)
|
||||||
signal.sdp?.let { call.put("sdp", it) }
|
signal.sdp?.let { call.put("sdp", it) }
|
||||||
@@ -731,6 +762,14 @@ data class Content(
|
|||||||
about = about, editOf = about.messageId)
|
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
|
// Control traffic. Returned with its envelope so the caller can act
|
||||||
// on it, and never renderable.
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user