diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt index d23b02b..3b64bc3 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt @@ -97,6 +97,24 @@ class Session( val delivery: Delivery? = null, /** A sender-generated preview of a link in this message (§6.12). */ val link: Content.LinkPreview? = null, + /** + * The sender's own id for this message (§6.9). + * + * 🔴 **The wire always carried one and this type never stored it.** + * `Content.groupText` has taken a `messageId` since §6.9 shipped and + * the parser reads it back — the transcript was where it stopped. So + * nothing here could name an earlier message: no replies to resolve, no + * reactions to fold, no edits to apply. + */ + val messageId: String? = null, + /** + * What this message quotes, when it is a reply (§6.9). + * + * Also never stored, so a reply from the iPhone app arrived as a bare + * message with the quote silently removed — and the reader had no way + * to know anything had been dropped. + */ + val about: Content.Reference? = null, ) /** Where an outbound message got to. */ @@ -135,6 +153,8 @@ class Session( delivery = m.optString("delivery").takeIf { it.isNotBlank() } ?.let { runCatching { Delivery.valueOf(it) }.getOrNull() }, link = m.optJSONObject("lnk")?.let { Content.LinkPreview.fromJson(it) }, + messageId = m.optString("mid").takeIf { it.isNotEmpty() }, + about = m.optJSONObject("re")?.let { Content.Reference.fromJson(it) }, ) } } @@ -151,6 +171,8 @@ class Session( link: Content.LinkPreview? = null, /** What the other side will call this message when it refers back. */ messageId: String? = null, + /** What this message quotes, when it is a reply (§6.9). */ + about: Content.Reference? = null, ) { vault.append(threadId, JSONObject() .put("author", author) @@ -163,7 +185,12 @@ class Session( } } .also { if (attachment != null) it.put("kind", attachment.kind).put("att", attachment.toJson()) } - .also { if (link != null) it.put("lnk", link.toJson()) }) + .also { if (link != null) it.put("lnk", link.toJson()) } + // ⚠️ Written only when present. A null on every message would grow + // the transcript for nothing and make "no id" indistinguishable + // from "an id of nothing". + .also { if (messageId != null) it.put("mid", messageId) } + .also { if (about != null) it.put("re", about.toJson()) }) // 🔴 A reply arriving cancels the reminder, and it is done HERE because // this is the one place every inbound message passes through. Hanging it @@ -980,7 +1007,8 @@ class Session( // when they edit this message, and what we use when we react // to it — an id invented here would match nothing on their // side. - messageId = message.content.messageId) + messageId = message.content.messageId, + about = message.content.about) touched += thread } diff --git a/engine/src/test/kotlin/io/flamenet/e2e/GroupMessageIdentityTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/GroupMessageIdentityTest.kt new file mode 100644 index 0000000..1046370 --- /dev/null +++ b/engine/src/test/kotlin/io/flamenet/e2e/GroupMessageIdentityTest.kt @@ -0,0 +1,99 @@ +package io.flamenet.e2e + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import org.json.JSONObject + +/** + * Group messages carrying an id of their own (§6.9). + * + * 🔴 **The wire always carried one and the transcript never stored it.** + * `Content.groupText` has taken a `messageId` since §6.9 shipped and `parse` + * reads it back — `Session.Line` was where it stopped. So nothing in a group + * could name an earlier message: no reply to resolve, no reaction to fold, no + * edit to apply. All three were "codec done" and none of them worked. + */ +class GroupMessageIdentityTest { + + @Test + fun `a group message round trips its id`() { + val content = Content.parse(Content.groupText("meet at 6", "g1", messageId = "abc")) + assertEquals("abc", content.messageId) + assertEquals("g1", content.groupId) + } + + /** + * 🔴 One id for the whole fan-out. A group message is ONE message: a reply + * or a reaction from any member has to name the same thing, so + * per-recipient ids would make that impossible. + */ + fun bodiesFor(memberCount: Int, messageId: String) = + (1..memberCount).map { Content.groupText("meet at 6", "g1", messageId = messageId) } + + @Test + fun `every copy of a group message carries the same id`() { + val ids = bodiesFor(3, "shared").map { Content.parse(it).messageId } + assertEquals(setOf("shared"), ids.toSet(), + "members received different ids for one message") + } + + /** + * 🔴 **The queued retry has to be the same message.** `sendGroup` builds + * the envelope twice — once to send and once for the outbox — so an id + * minted inside either would differ between them, and a member who was + * offline would eventually receive a *different* message from everybody + * else, which nothing could reconcile with a reply or a reaction. + */ + @Test + fun `the queued copy carries the same id as the sent one`() { + val messageId = "shared" + val sent = Content.groupText("meet at 6", "g1", messageId = messageId) + val queued = Content.groupText("meet at 6", "g1", messageId = messageId) + assertEquals(Content.parse(sent).messageId, Content.parse(queued).messageId) + } + + @Test + fun `a group reply round trips its quote`() { + val about = Content.Reference("earlier", 9L, "what they said") + val content = Content.parse( + Content.groupText("yes", "g1", messageId = "m", about = about)) + assertEquals("earlier", content.about?.messageId) + assertEquals("what they said", content.about?.preview) + } + + /** + * ⚠️ A reference survives the transcript's JSON round trip, which is the + * part that was missing — the codec was always fine. + */ + @Test + fun `a reference survives being stored and read back`() { + val about = Content.Reference("earlier", 9L, "what they said") + val stored = JSONObject().put("re", about.toJson()) + val read = Content.Reference.fromJson(stored.getJSONObject("re")) + + assertEquals("earlier", read.messageId) + assertEquals(9L, read.author) + assertEquals("what they said", read.preview) + } + + /** + * ⚠️ A message stored before ids existed reads back with none, rather than + * with an empty string that would then look like an id and match nothing. + */ + @Test + fun `a message stored before ids existed has none`() { + val stored = JSONObject().put("text", "hi").put("mine", false).put("at", 1L) + assertNull(stored.optString("mid").takeIf { it.isNotEmpty() }) + assertNull(stored.optJSONObject("re")) + } + + @Test + fun `a message stored with an id reads it back`() { + val stored = JSONObject().put("text", "hi").put("mine", false).put("at", 1L) + .put("mid", "abc") + assertNotNull(stored.optString("mid").takeIf { it.isNotEmpty() }) + assertEquals("abc", stored.optString("mid")) + } +}