Merge branch 'fix/unsend-tells-the-truth' into 'main'

"Unsend" did not unsend (android#18)

See merge request paulhitt/flamenet-messenger-android!65
This commit was merged in pull request #92.
This commit is contained in:
Paul Hitt
2026-09-05 02:18:59 +00:00
2 changed files with 101 additions and 3 deletions
@@ -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(
@@ -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<File> {
// 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<String> =
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",
)
}
}