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:
Paul Hitt
2026-09-03 19:08:44 -04:00
co-authored by Claude Opus 4.8
parent 3acaf429e5
commit 3dd44ebc9a
2 changed files with 129 additions and 2 deletions
@@ -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
}