diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt index b4d27b7..1ac5131 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt @@ -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.** diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/KitIcons.kt b/app/src/main/kotlin/io/flamenet/messenger/app/KitIcons.kt index fa2304a..1f37dab 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/KitIcons.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/KitIcons.kt @@ -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 } 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 4fa3f78..a286432 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt @@ -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 { @@ -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 } diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/ScreenshotTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/ScreenshotTest.kt index 7279b79..5020160 100644 --- a/app/src/test/kotlin/io/flamenet/messenger/app/ScreenshotTest.kt +++ b/app/src/test/kotlin/io/flamenet/messenger/app/ScreenshotTest.kt @@ -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), ), )