Let failed messages be discarded

This commit is contained in:
iris-ai committed 2026-09-15 19:03:44 -04:00
1 parent 3d1b1e304d
commit 33b130b6bb
4 files changed
+55 -1

No files matched your search

@@ -65,6 +65,11 @@ internal fun markPendingAccepted(queued: List<QueuedMessage>, id: String): List<
if (message.local && message.id == id) message.copy(serverAccepted = true) else message
}
internal fun discardPendingMessage(
queued: List<QueuedMessage>,
id: String,
): List<QueuedMessage> = queued.filterNot { it.local && it.id == id }
/** Restores sends for which this phone has not yet seen a durable server event. */
internal fun loadPendingMessages(context: Context, key: String): List<QueuedMessage> {
val encoded =
@@ -373,6 +373,7 @@ fun SessionScreen(
val pendingKey =
remember(settings, address) { "${settings.host}:${settings.port}/${address.cachePath}" }
var queued by remember(pendingKey) { mutableStateOf(loadPendingMessages(context, pendingKey)) }
var discardingMessage by remember(pendingKey) { mutableStateOf<QueuedMessage?>(null) }
/**
* Changes the visible outbox and keeps only its not-yet-durable part across a screen reopen.
@@ -1569,6 +1570,12 @@ fun SessionScreen(
onTakeBack =
if (waiting.local) null
else ({ takeBack(waiting.id) }),
onDiscard =
if (waiting.local && waiting.refusal != null) {
{ discardingMessage = waiting }
} else {
null
},
)
}
}
@@ -2084,6 +2091,29 @@ fun SessionScreen(
},
)
}
discardingMessage?.let { message ->
AlertDialog(
onDismissRequest = { discardingMessage = null },
title = { Text("Discard unsent message?") },
text = {
Text(
"The server did not confirm this message. Discarding it removes this phone's " +
"only copy and cannot be undone."
)
},
confirmButton = {
TextButton(
onClick = {
replaceQueued(discardPendingMessage(queued, message.id))
discardingMessage = null
}
) {
Text("Discard", color = MaterialTheme.colorScheme.error)
}
},
dismissButton = { TextButton(onClick = { discardingMessage = null }) { Text("Keep") } },
)
}
if (settingsOpen) {
// Measured when the dialog opens rather than kept up to date: what the reader is being told
// is what pressing the button now would discard, and null until the walk of the directory
@@ -2202,6 +2232,7 @@ private fun UserChunkRow(
* before the session reads it. A local one is not, because its request may still be in flight.
* [refusal] is either that take-back refusal or the send's network failure. It is drawn here rather
* than with the screen's other errors because this is the message the failure belongs to.
* [onDiscard] is confirmed by the screen because a failed local bubble is the phone's only copy.
*
* A settled message longer than [USER_SPLIT_CHARS] is drawn as [UserChunkRow] slices instead -- one
* `Text` holding a pasted log is a hundred-thousand-pixel layout in the frame the row scrolls into.
@@ -2216,6 +2247,7 @@ private fun UserBubble(
pending: Boolean = false,
refusal: String? = null,
onTakeBack: (() -> Unit)? = null,
onDiscard: (() -> Unit)? = null,
) {
Box(Modifier.fillMaxWidth()) {
Card(
@@ -2268,6 +2300,11 @@ private fun UserBubble(
color = MaterialTheme.colorScheme.error,
)
}
onDiscard?.let {
TextButton(onClick = it, modifier = Modifier.align(Alignment.End)) {
Text("Discard", color = MaterialTheme.colorScheme.error)
}
}
}
}
}
@@ -48,6 +48,16 @@ class PendingMessagesTest {
assertTrue(queued.single().serverAccepted)
}
@Test
fun discarding_a_failed_send_removes_only_that_local_copy() {
val server = QueuedMessage("server-1", "already accepted", emptyList())
val queued = listOf(local(), local("keep this one").copy(id = "local-2"), server)
val discarded = discardPendingMessage(queued, "local-1")
assertEquals(listOf("local-2", "server-1"), discarded.map { it.id })
}
@Test
fun identical_messages_are_reconciled_one_at_a_time() {
val queued = listOf(local(), local().copy(id = "local-2"))