Every message carries whether it was delivered, and says so

Asked: how do we prevent ghost messages. Fixing the one cause does not, so
this fixes the thing that makes any cause invisible.

A message could only be a ghost because a delivered bubble and an undelivered
one were the same picture. The code had one bit - record it or do not - and
both answers were wrong, and the app shipped one of each:

  1:1 text recorded only on success, so a message sent while the other person
  was signed out vanished out of the box with no bubble and no error. That is
  why it took sending twice to get one to appear.

  Group text recorded unconditionally, so it left a bubble that looked exactly
  like a delivered one and had reached nobody.

Now every send records, carrying the outcome, and a bubble nobody received is
marked. Null stays null for received messages and for anything written before
the field existed - absence of a claim, not a claim of failure - so old
threads do not sprout warnings.

Correcting the previous commit message: it said the lost 1:1 message was
written into the sender's own thread. It was not; nothing was written at all.
The ghost bubble was the group path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-08-27 17:57:58 -04:00
co-authored by Claude Opus 5
parent 80b903e168
commit f711dd279b
4 changed files with 83 additions and 25 deletions
@@ -1414,12 +1414,32 @@ private fun Bubble(
Spacer(Modifier.width(Space.sm))
}
Column(horizontalAlignment = if (line.mine) Alignment.End else Alignment.Start) {
Text(
time,
fontSize = 10.sp,
color = Palette.SecondaryText,
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Space.xs),
modifier = Modifier.padding(horizontal = Kit.bubblePadding, vertical = 2.dp),
)
) {
Text(time, fontSize = 10.sp, color = Palette.SecondaryText)
// 🔴 **A message nobody received must not look like one that was
// received.** That sameness is the whole of what makes a ghost
// message possible; every specific cause behind one is downstream
// of it. Only `false` marks — null means nothing was claimed, which
// is every received message and everything stored before the field
// existed.
if (line.delivered == false) {
Icon(
KitIcon.NotDelivered,
contentDescription = "Not delivered",
tint = Palette.Pink,
modifier = Modifier.size(12.dp).testTag("undelivered"),
)
Text(
"Not delivered",
fontSize = 10.sp,
color = Palette.Pink,
)
}
}
Column(
modifier = Modifier
// 🔴 **One corner is square, and it points at its author.**
@@ -18,6 +18,7 @@ import androidx.compose.material.icons.outlined.GroupAdd
import androidx.compose.material.icons.outlined.Mic
import androidx.compose.material.icons.outlined.MicOff
import androidx.compose.material.icons.outlined.MoreHoriz
import androidx.compose.material.icons.outlined.ErrorOutline
import androidx.compose.material.icons.outlined.NotificationsNone
import androidx.compose.material.icons.outlined.PersonAdd
import androidx.compose.material.icons.outlined.PersonOutline
@@ -91,4 +92,7 @@ object KitIcon {
/** Presence and unread dots, where a filled circle IS the icon. */
val Dot: ImageVector = Icons.Filled.Circle
/** On a bubble that reached nobody. */
val NotDelivered: ImageVector = Icons.Outlined.ErrorOutline
}
@@ -39,6 +39,23 @@ class Session(
val mine: Boolean,
val at: Long,
val attachment: Attachment.Ref? = null,
/**
* Whether any of their devices took this.
*
* 🔴 **The thing that makes a ghost message impossible.** The old code
* had one bit — record it or do not — and both answers are wrong. Not
* recording means what you typed vanishes with no trace, which is what
* happened to a 1:1 message sent while the other person was signed out.
* Recording unconditionally means a bubble that looks exactly like a
* delivered one and was received by nobody, which is what happened to a
* group text. A message can only be a ghost while a delivered bubble
* and an undelivered bubble are the same picture.
*
* ⚠️ Null for received messages and for anything written before this
* field existed — absence of a claim, not a claim of failure. Only a
* `false` draws the warning.
*/
val delivered: Boolean? = null,
)
fun history(threadId: String): List<Line> {
@@ -52,6 +69,10 @@ class Session(
attachment = m.optJSONObject("att")?.let { att ->
Attachment.Ref.fromJson(m.optString("kind", Attachment.IMAGE), att, 0.0)
},
// ⚠️ `if (has)` rather than `optBoolean`, which would turn a
// message stored before this field into a claim of failure and
// decorate every older bubble with a warning.
delivered = if (m.has("delivered")) m.optBoolean("delivered") else null,
)
}
}
@@ -63,10 +84,12 @@ class Session(
attachment: Attachment.Ref? = null,
author: Long = 0,
at: Long = System.currentTimeMillis(),
delivered: Boolean? = null,
) {
vault.append(threadId, JSONObject()
.put("author", author)
.put("text", text).put("mine", mine).put("at", at)
.also { if (delivered != null) it.put("delivered", delivered) }
.also { if (attachment != null) it.put("kind", attachment.kind).put("att", attachment.toJson()) })
}
@@ -78,11 +101,11 @@ class Session(
io.flamenet.client.Conversations.Sent {
val sent = conversations.sendAttachment(
peerUserId, bytes, mime, Attachment.IMAGE, width = width, height = height)
if (sent.reachedSomeone) {
// From the reference we just built, so the sender's own thread
// shows the image without fetching it back from the relay.
record(Vault.threadForUser(peerUserId), "", mine = true, attachment = sent.attachment)
}
// From the reference we just built, so the sender's own thread shows
// the image without fetching it back from the relay. Recorded whatever
// happened, carrying the outcome — see [Line.delivered].
record(Vault.threadForUser(peerUserId), "", mine = true,
attachment = sent.attachment, delivered = sent.reachedSomeone)
return sent
}
@@ -97,9 +120,8 @@ class Session(
io.flamenet.client.Conversations.Sent {
val sent = conversations.sendAttachment(
peerUserId, bytes, mime, Attachment.AUDIO, duration = seconds)
if (sent.reachedSomeone) {
record(Vault.threadForUser(peerUserId), "", mine = true, attachment = sent.attachment)
}
record(Vault.threadForUser(peerUserId), "", mine = true,
attachment = sent.attachment, delivered = sent.reachedSomeone)
return sent
}
@@ -113,16 +135,23 @@ class Session(
}
/**
* Send, and record what was actually sent.
* Send, and record it either way — with what happened.
*
* ⚠️ Recorded only when at least one device took it. Writing the line
* first and hoping produces a thread that shows messages the recipient
* never got, which is worse than an error — the sender believes they said
* something.
* 🔴 **This used to record only on success, and that is how a message
* disappeared.** Sent to somebody signed out, nothing reached a device, so
* nothing was written and nothing was shown: the sender watched their own
* text vanish out of the box with no bubble and no error, and sent it
* again to see whether it would stick. The alternative the group path took
* — record regardless — is the other half of the same bug, a bubble that
* was received by nobody and looks exactly like one that was.
*
* Recording with the outcome is what makes neither possible. See
* [Line.delivered].
*/
fun send(peerUserId: Long, text: String): Conversations.Sent {
val sent = conversations.send(peerUserId, text)
if (sent.reachedSomeone) record(Vault.threadForUser(peerUserId), text, mine = true)
record(Vault.threadForUser(peerUserId), text, mine = true,
delivered = sent.reachedSomeone)
return sent
}
@@ -145,7 +174,8 @@ class Session(
fun sendGroup(group: RelayClient.Group, text: String): Conversations.Sent {
val sent = conversations.sendToGroup(group, text, userId)
record(Vault.threadForGroup(group.id), text, mine = true, author = userId)
record(Vault.threadForGroup(group.id), text, mine = true, author = userId,
delivered = sent.reachedSomeone)
return sent
}
@@ -158,10 +188,9 @@ class Session(
height: Int,
): Conversations.Sent {
val sent = conversations.sendGroupAttachment(group, userId, bytes, mime, width, height)
if (sent.reachedSomeone) {
record(Vault.threadForGroup(group.id), "", mine = true,
attachment = sent.attachment, author = userId)
}
record(Vault.threadForGroup(group.id), "", mine = true,
attachment = sent.attachment, author = userId,
delivered = sent.reachedSomeone)
return sent
}
@@ -127,7 +127,12 @@ class ScreenshotTest {
"A longer one, so the corner that points at its author is "
+ "visible on a bubble that actually wraps.",
mine = false, at = 1_756_300_320_000),
Session.Line("And the same on this side.", mine = true, at = 1_756_300_380_000),
Session.Line("And the same on this side.", mine = true, at = 1_756_300_380_000,
delivered = true),
// The state that used to be invisible: sent while they were signed
// out, so no device took it. It must not look like the bubble above.
Session.Line("Are you around later?", mine = true, at = 1_756_300_440_000,
delivered = false),
),
)