The transcript records a message id and a reply reference
🔴 The wire always carried both and Session.Line never stored them. Content.groupText has taken a messageId since §6.9 shipped and parse 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. All three were "codec done" and none worked. A reply from the iPhone app arrived as a bare message with the quote silently removed, which is worse than the missing id: the reader had no way to know anything had been dropped. 🔴 Also fixes a real bug in sendGroup. It built the envelope twice — once to send, 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. One id, minted by the caller, used by both. Both fields 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". Refs paulhitt/flamenet-messenger-ios#58, #10 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
3acaf429e5
commit
3dd44ebc9a
@@ -97,6 +97,24 @@ class Session(
|
|||||||
val delivery: Delivery? = null,
|
val delivery: Delivery? = null,
|
||||||
/** A sender-generated preview of a link in this message (§6.12). */
|
/** A sender-generated preview of a link in this message (§6.12). */
|
||||||
val link: Content.LinkPreview? = null,
|
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. */
|
/** Where an outbound message got to. */
|
||||||
@@ -135,6 +153,8 @@ class Session(
|
|||||||
delivery = m.optString("delivery").takeIf { it.isNotBlank() }
|
delivery = m.optString("delivery").takeIf { it.isNotBlank() }
|
||||||
?.let { runCatching { Delivery.valueOf(it) }.getOrNull() },
|
?.let { runCatching { Delivery.valueOf(it) }.getOrNull() },
|
||||||
link = m.optJSONObject("lnk")?.let { Content.LinkPreview.fromJson(it) },
|
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,
|
link: Content.LinkPreview? = null,
|
||||||
/** What the other side will call this message when it refers back. */
|
/** What the other side will call this message when it refers back. */
|
||||||
messageId: String? = null,
|
messageId: String? = null,
|
||||||
|
/** What this message quotes, when it is a reply (§6.9). */
|
||||||
|
about: Content.Reference? = null,
|
||||||
) {
|
) {
|
||||||
vault.append(threadId, JSONObject()
|
vault.append(threadId, JSONObject()
|
||||||
.put("author", author)
|
.put("author", author)
|
||||||
@@ -163,7 +185,12 @@ class Session(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.also { if (attachment != null) it.put("kind", attachment.kind).put("att", attachment.toJson()) }
|
.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
|
// 🔴 A reply arriving cancels the reminder, and it is done HERE because
|
||||||
// this is the one place every inbound message passes through. Hanging it
|
// 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
|
// when they edit this message, and what we use when we react
|
||||||
// to it — an id invented here would match nothing on their
|
// to it — an id invented here would match nothing on their
|
||||||
// side.
|
// side.
|
||||||
messageId = message.content.messageId)
|
messageId = message.content.messageId,
|
||||||
|
about = message.content.about)
|
||||||
touched += thread
|
touched += thread
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user