diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Content.kt b/engine/src/main/kotlin/io/flamenet/e2e/Content.kt index bb1dc49..7cbc2df 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/Content.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/Content.kt @@ -91,6 +91,15 @@ data class Content( /** This message replies to a story rather than to a bubble. */ val repliesToStory: Boolean = false, + /** + * Set iff this envelope replaces the text of an earlier message (ยง6.15): + * the `mid` of the message being edited. [text] then carries the new + * wording. + * + * ๐Ÿ”ด An edit is never a bubble of its own. It changes one that is already + * there, or it does nothing. + */ + val editOf: String? = null, ) { /** @@ -165,9 +174,14 @@ data class Content( * * Reactions fold onto an existing bubble, call signals drive the call UI, * and control messages are consumed silently. Everything else is a message. + * + * ๐Ÿ”ด **An edit is not a message either (ยง6.15).** It carries the new + * wording in [text], so without this it falls straight through and draws as + * a second bubble beside the one it was meant to replace โ€” a correction + * arriving as a duplicate, which is worse than the typo. */ val isRenderable: Boolean - get() = reaction == null && call == null && !isControl + get() = reaction == null && call == null && editOf == null && !isControl /** * Control traffic: consumed by a layer, never shown. @@ -277,6 +291,15 @@ data class Content( */ const val DISAPPEAR = "ttl" + /** + * A replacement for the text of an earlier message (ยง6.15). + * + * ๐Ÿ”ด **Not in [CONTROL].** Control kinds are swallowed whole; this one + * has to be acted on, and a build that merely swallowed it would drop + * corrections silently while looking like it worked. + */ + const val EDIT = "edit" + /** * โš ๏ธ Consumed, never shown. Each of these is a real message the web * client sends today, and rendering one puts raw JSON in somebody's @@ -292,7 +315,7 @@ data class Content( * draw (shown as a placeholder) โ€” never raw JSON in a bubble. */ val KNOWN = CONTROL + setOf( - TEXT, REACT, STICKER, + TEXT, REACT, STICKER, EDIT, Attachment.IMAGE, Attachment.AUDIO, Attachment.STORY, Attachment.FILE, CallSignal.OFFER, CallSignal.ANSWER, CallSignal.END, ) @@ -421,6 +444,23 @@ data class Content( return "{" + parts.joinToString(",") + "}" } + /** + * An edit of an earlier message (ยง6.15). + * + * โš ๏ธ Reuses `re` to name the target, exactly as a reaction does โ€” a new + * field would be a second way of saying "this is about that message" + * and would need adding to two clients, an engine and a spec before + * anything worked. + * + * ๐Ÿ”ด **`re.author` is not evidence and the receiver must not read it as + * evidence.** Anyone can put any user id there. The receiver decides + * whether an edit is allowed from the copy of the message it already + * holds, never from what the envelope claims about itself. + */ + fun edit(newText: String, of: String, groupId: String? = null): String = + assemble(Kind.EDIT, caption = newText, groupId = groupId, + about = Reference(of, author = 0, preview = "")) + fun callSignal(signal: CallSignal): String { val call = JSONObject().put("id", signal.callId) signal.sdp?.let { call.put("sdp", it) } @@ -561,6 +601,15 @@ data class Content( } } + // ยง6.15 โ€” a replacement for the text of an earlier message. + // Checked BEFORE the control swallow, which would consume it + // correctly and act on nothing, and before TEXT, which would render + // the new wording as a second bubble beside the original. + if (kind == Kind.EDIT && about != null) { + return Content(caption, kind = kind, raw = json, groupId = groupId, + about = about, editOf = 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/EditEnvelopeTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/EditEnvelopeTest.kt new file mode 100644 index 0000000..2013b8a --- /dev/null +++ b/engine/src/test/kotlin/io/flamenet/e2e/EditEnvelopeTest.kt @@ -0,0 +1,151 @@ +package io.flamenet.e2e + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue +import org.json.JSONObject + +/** + * Replacing the wording of a message already sent (ยง6.15). + * + * ๐Ÿ”ด **Without this branch an edit renders as a duplicate message.** An unknown + * kind with a caption and no attachment falls through to the last case in + * `parse`, which returns the caption as text โ€” so an iPhone correcting a typo + * would appear on Android as a second bubble beside the original, with no + * indication that either replaced the other. That is the bug this fixes today, + * before any of the applying half exists. + * + * โš ๏ธ **This is the codec only.** `Session.Line` carries no `mid`, so nothing in + * an Android transcript can be named by an edit and the app swallows them for + * now โ€” the same place reactions have been sitting (PARITY.md: "codec done, no + * UI"). One transcript change unlocks replies, reactions and edits together. + */ +class EditEnvelopeTest { + + @Test + fun `an edit names the message it replaces and carries the new wording`() { + val wire = Content.edit("meet at 9", of = "abc") + val content = Content.parse(wire) + + assertEquals("abc", content.editOf) + assertEquals("meet at 9", content.text) + assertEquals(Content.Kind.EDIT, content.kind) + } + + /** + * ๐Ÿ”ด The bug this exists to prevent. Before the branch, an edit fell + * through to the attachment path and came back as renderable text โ€” a + * second bubble the sender never sent. + */ + @Test + fun `an edit is not a renderable message`() { + val content = Content.parse(Content.edit("meet at 9", of = "abc")) + assertNull(content.attachment, "an edit came back carrying an attachment") + assertNull(content.reaction) + assertNull(content.sticker) + assertTrue(content.editOf != null, + "an edit that is not recognisable as one will be drawn as a message") + } + + /** + * โš ๏ธ It must not be swallowed as generic control traffic either. A build + * that consumed it silently would look like it worked while dropping every + * correction โ€” which is worse than the duplicate bubble, because nobody + * would notice. + */ + @Test + fun `an edit is a known kind, not unknown control`() { + assertTrue(Content.Kind.EDIT in Content.Kind.KNOWN) + assertTrue(Content.Kind.EDIT !in Content.Kind.CONTROL, + "an edit swallowed as control would drop corrections silently") + } + + /** Group routing survives, for when a group thread can name a message. */ + @Test + fun `a group edit keeps its routing`() { + val content = Content.parse(Content.edit("meet at 9", of = "abc", groupId = "g1")) + assertEquals("g1", content.groupId) + assertEquals("abc", content.editOf) + } + + /** + * ๐Ÿ”ด `re.author` is a claim, not evidence โ€” anyone can put any user id + * there. The codec must not present it as authorship, and the encoder must + * not fill it in with something that looks like one. + */ + @Test + fun `the envelope makes no claim about who wrote the original`() { + val json = JSONObject(Content.edit("meet at 9", of = "abc")) + assertEquals(0L, json.getJSONObject("re").getLong("author") as Long, + "the encoder asserted an author, which a receiver might believe") + + // And a forged one parses identically, because nothing downstream reads it. + val forged = Content.edit("meet at 9", of = "abc") + .replace("\"author\":0", "\"author\":99") + assertEquals("abc", Content.parse(forged).editOf) + } + + /** โš ๏ธ `fnc` first, like every other envelope โ€” the receive side's marker + * check matches the serialized prefix. */ + @Test + fun `the envelope starts with the marker`() { + assertTrue(Content.edit("hi", of = "abc").startsWith("{\"fnc\":1,")) + } + + /** + * An edit with no target is not an edit. It must fall out of the branch + * rather than become one naming nothing. + */ + @Test + fun `an edit with no target is not treated as an edit`() { + val malformed = """{"fnc":1,"kind":"edit","caption":"meet at 9"}""" + assertNull(Content.parse(malformed).editOf) + } + + /** + * โš ๏ธ Both clients must agree byte-for-byte on the shape, since one encodes + * what the other parses. This is the shape iOS emits. + */ + @Test + fun `the wire shape matches what iOS sends`() { + val json = JSONObject(Content.edit("meet at 9", of = "abc")) + assertEquals(1, json.getInt("fnc")) + assertEquals("edit", json.getString("kind")) + assertEquals("abc", json.getJSONObject("re").getString("mid")) + assertEquals("meet at 9", json.getString("caption")) + } +} + +/** + * ๐Ÿ”ด The single decision that keeps an edit out of the transcript. + * + * `Session` filters inbound envelopes on `isRenderable`, so this property is + * the whole of what stops a correction being filed as a new message. It is + * tested apart from the codec because it is the part that would still be wrong + * if the parse branch were right. + */ +class EditIsNotRenderableTest { + + @Test + fun `an edit is not filed as a message`() { + val content = Content.parse(Content.edit("meet at 9", of = "abc")) + assertTrue(!content.isRenderable, + "a correction would be filed as a second bubble beside the original") + } + + /** โš ๏ธ And ordinary text still is, or this fix has broken conversation. */ + @Test + fun `ordinary text is still a message`() { + val content = Content.parse(Content.text("meet at 6")) + assertTrue(content.isRenderable) + } + + /** A reaction and a call signal stay excluded for their own reasons. */ + @Test + fun `the other non-messages are still excluded`() { + val reaction = Content.parse( + Content.reaction("๐Ÿ‘", about = Content.Reference("abc", 7L, ""))) + assertTrue(!reaction.isRenderable) + } +}