From 8c2d7f1f653c265823c58787299eb6a8c8cbe4e0 Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Fri, 4 Sep 2026 22:06:57 -0400 Subject: [PATCH] "Unsend" did not unsend. Say what the button does (android#18) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The message menu labelled the delete `if (line.mine) "Unsend" else "Delete for me"`, and both branches call `deleteMessage` โ†’ `Session.deleteMessage` โ†’ `vault.deleteLine`. That is the whole implementation. No envelope leaves the phone; the recipient's copy stays exactly where it is. ๐Ÿ”ด **Worse than an overclaiming page**, which is why this got a test and not just an edit. A page that overclaims can be read sceptically. A button that reports success cannot: the feedback IS the claim, and it arrives at the moment the person is most relieved to believe it. "Delete for me" was already the other half of that ternary, and it was always the accurate half. โš ๏ธ Two tests, not one, because the first is worthless without the second: a source scan that walks the wrong directory finds no files and passes every assertion in it. `the scan actually reaches the chat screen` pins that the walk reaches the file the bug was on. The walk climbs to the module root rather than resolving a relative path, for the same reason. Proved by breaking it, not by watching it pass: label reverted โ†’ FAILED, naming ChatScreens.kt line ~1564 restored โ†’ 2 tests, 0 failures 1190 tests pass across the suite. โš ๏ธ This is ios#57 on the other platform, fixed there in the same pass. A real retract is a new `kind` on rails that already exist โ€” ยง6.15's edit machinery is implemented and tested on both clients. But even then it is a request, not a guarantee: a client can ignore it and the message may already have been read. Whatever the button eventually says must not promise more than "we asked them to remove it." Co-Authored-By: Claude Opus 5 --- .../io/flamenet/messenger/app/ChatScreens.kt | 15 +++- .../messenger/app/DestructiveLabelsTest.kt | 89 +++++++++++++++++++ 2 files changed, 101 insertions(+), 3 deletions(-) create mode 100644 app/src/test/kotlin/io/flamenet/messenger/app/DestructiveLabelsTest.kt 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 81bcd05..214548f 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt @@ -1953,7 +1953,16 @@ fun ColumnScope.ChatScreen( ) } DropdownMenuItem( - text = { Text(if (line.mine) "Unsend" else "Delete for me") }, + // ๐Ÿ”ด **Said "Unsend" on your own messages and did + // nothing of the kind (android#18).** `deleteMessage` + // reaches `vault.deleteLine` and stops there โ€” no + // envelope leaves the phone, and the recipient's copy + // stays exactly where it is. Somebody who sent to the + // wrong person tapped it, watched the bubble vanish, + // and had no way to find out. "Delete for me" was + // already the other half of this ternary, and it was + // always the accurate half. + text = { Text("Delete for me") }, onClick = { model.deleteMessage(line); menuOpen = false }, modifier = Modifier.testTag("delete-message"), ) @@ -3950,8 +3959,8 @@ internal fun memberLabel( * rather not. The cursor sits at the end for the same reason. * * โš ๏ธ Saving nothing is refused rather than treated as a delete. Emptying the - * box is the shape of a slip, and "Unsend" is right there in the same menu for - * anybody who meant it. + * box is the shape of a slip, and "Delete for me" is right there in the same + * menu for anybody who meant it. */ @Composable private fun EditMessageSheet( diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/DestructiveLabelsTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/DestructiveLabelsTest.kt new file mode 100644 index 0000000..9caae43 --- /dev/null +++ b/app/src/test/kotlin/io/flamenet/messenger/app/DestructiveLabelsTest.kt @@ -0,0 +1,89 @@ +package io.flamenet.messenger.app + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertTrue + +/** + * A destructive button must not claim more than it does (android#18). + * + * ๐Ÿ”ด **"Unsend" did not unsend.** The message menu offered it on your own + * messages and called `deleteMessage`, which reaches `vault.deleteLine` and + * stops there. No envelope leaves the phone; the recipient's copy stays exactly + * where it is. Somebody who sent to the wrong person tapped it, watched the + * bubble vanish, and had no way to find out. + * + * โš ๏ธ **Worse than an overclaiming page**, which is why this is a test and not + * a one-line fix left to stay fixed. A page that overclaims can be read + * sceptically. A button that reports success cannot: the feedback IS the claim, + * and it arrives at the exact moment the person is most relieved to believe it. + * + * A source scan, and coarse on purpose. It cannot prove every label is honest. + * It proves this particular word cannot come back on a local-only action + * without somebody deleting a test that explains why โ€” and it is a plain JVM + * test, so it costs no emulator. + */ +class DestructiveLabelsTest { + + private fun uiSources(): List { + // From `app/build/classes/...` back to the module root, whatever the + // working directory happens to be. Resolving against a relative path + // is how this kind of test silently scans nothing. + var dir = File(System.getProperty("user.dir")) + while (!File(dir, "app/src/main/kotlin").isDirectory && dir.parentFile != null) { + dir = dir.parentFile + } + val root = File(dir, "app/src/main/kotlin") + return root.walkTopDown().filter { it.extension == "kt" }.toList() + } + + /** + * โš ๏ธ **Comments are stripped, and that is required rather than tidy.** The + * comment explaining this bug necessarily quotes the word it is about, and + * a guard that flags its own explanation is a guard somebody deletes. + */ + private fun codeLines(body: String): List = + body.lines() + .filterNot { it.trimStart().startsWith("//") || it.trimStart().startsWith("*") } + .map { it.substringBefore("//") } + + /** + * ๐Ÿ”ด **The word promises the recipient's copy is gone.** Nothing in this app + * can do that yet: it would take a signed control envelope naming the `mid`, + * which the recipient acts on โ€” and even then it is a *request*, so whatever + * the button eventually says must not promise more than "we asked them to + * remove it." + * + * When that lands, this test is the thing to update, deliberately, in the + * same change. + */ + @Test + fun `no button says Unsend`() { + val files = uiSources() + assertTrue(files.isNotEmpty(), "scanned no sources โ€” the walk found nothing") + for (file in files) { + codeLines(file.readText()).forEachIndexed { i, line -> + assertTrue( + !line.contains("\"Unsend\""), + "${file.name} line ~${i + 1} offers a button labelled \"Unsend\", but " + + "nothing in this app removes a message from the recipient's device. " + + "See android#18. If a real retract now exists, update this test in " + + "the same change and say which envelope carries it.", + ) + } + } + } + + /** + * โš ๏ธ The walk is the part most likely to rot. A scan that finds no files + * passes every assertion in it, so this pins that it is actually reading + * the screen the bug was on. + */ + @Test + fun `the scan actually reaches the chat screen`() { + assertTrue( + uiSources().any { it.name == "ChatScreens.kt" }, + "the source walk did not reach ChatScreens.kt โ€” this guard is scanning nothing", + ) + } +} -- 2.54.0