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
0dec029917
commit
3b7e69c0eb
@@ -76,6 +76,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. */
|
||||
@@ -107,6 +125,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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -121,6 +141,10 @@ class Session(
|
||||
at: Long = System.currentTimeMillis(),
|
||||
delivery: Delivery? = null,
|
||||
link: Content.LinkPreview? = null,
|
||||
/** The sender's own id for this message (§6.9); null for none. */
|
||||
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)
|
||||
@@ -132,7 +156,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
|
||||
@@ -647,9 +676,15 @@ class Session(
|
||||
.getOrNull()
|
||||
|
||||
fun sendGroup(group: RelayClient.Group, text: String): Conversations.Sent {
|
||||
val sent = conversations.sendToGroup(group, text, userId)
|
||||
// 🔴 One id for the whole fan-out, minted here so the SAME one reaches
|
||||
// both the send and the outbox entry below. Building the envelope twice
|
||||
// with two ids means a queued retry arrives as a different message from
|
||||
// the one everybody else already has.
|
||||
val messageId = java.util.UUID.randomUUID().toString()
|
||||
val sent = conversations.sendToGroup(group, text, userId, messageId)
|
||||
val at = System.currentTimeMillis()
|
||||
record(Vault.threadForGroup(group.id), text, mine = true, author = userId, at = at,
|
||||
messageId = messageId,
|
||||
delivery = if (sent.reachedSomeone) Delivery.DELIVERED else Delivery.PENDING)
|
||||
// 🔴 **The group half of the outbox promise.** Members with no
|
||||
// registered device are filed in the failure map as "user:<id>" by the
|
||||
@@ -657,7 +692,7 @@ class Session(
|
||||
// the group thread to restate. A member whose devices took it is not
|
||||
// queued — resending to them would duplicate the message.
|
||||
val body = io.flamenet.e2e.Content.groupText(
|
||||
text, group.id, groupName = vault.groupName(group.id))
|
||||
text, group.id, messageId = messageId, groupName = vault.groupName(group.id))
|
||||
sent.failed.keys.filter { it.startsWith("user:") }
|
||||
.mapNotNull { it.removePrefix("user:").toLongOrNull() }
|
||||
.forEach { member ->
|
||||
@@ -800,7 +835,9 @@ class Session(
|
||||
record(thread, message.content.text, mine = false,
|
||||
attachment = message.content.attachment,
|
||||
sticker = message.content.sticker?.let { Sticker(it.pack, it.id) },
|
||||
author = message.fromUser)
|
||||
author = message.fromUser,
|
||||
messageId = message.content.messageId,
|
||||
about = message.content.about)
|
||||
touched += thread
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user