From 1799ada15b235c20f42168fab95de5540d7cd10d Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Sat, 29 Aug 2026 08:07:21 -0400 Subject: [PATCH] Delete individual messages via long-press; leave-group already shipped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Long-press any bubble for Copy and Delete for me (Unsend on your own), in 1:1 and group threads. Vault.deleteLine rewrites the thread minus the line, keyed by timestamp+side+text — messages carry no stable id here. Leaving a group was already wired. Co-Authored-By: Claude Opus 4.8 --- .../io/flamenet/messenger/app/ChatScreens.kt | 57 ++++++++++++++----- .../messenger/app/MessengerViewModel.kt | 12 ++++ .../io/flamenet/messenger/app/Session.kt | 4 ++ .../src/main/kotlin/io/flamenet/e2e/Vault.kt | 19 +++++++ 4 files changed, 77 insertions(+), 15 deletions(-) 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 9be03d3..0cc8668 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt @@ -5,7 +5,13 @@ import android.content.pm.PackageManager import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -1263,6 +1269,7 @@ private fun PresenceDot(status: String) { } /** One conversation. */ +@OptIn(ExperimentalFoundationApi::class) @Composable fun ColumnScope.ChatScreen( model: MessengerViewModel, @@ -1377,21 +1384,41 @@ fun ColumnScope.ChatScreen( chat.group == null -> chat.peerId else -> line.author.takeIf { it != 0L } } - Bubble( - line = line, - model = model, - authorName = when { - line.mine || chat.group == null -> chat.title - authorId == null -> chat.title - else -> buddies.firstOrNull { it.userId == authorId } - ?.let { it.displayName.ifBlank { it.handle } } - ?: "user $authorId" - }, - // 🔴 Only for a message you did not send. Your own bubble has - // no avatar in the kit — you know who you are, and a picture on - // both sides halves the width a thread has to say anything in. - picture = authorId?.let { avatars[it] }, - ) + var menuOpen by remember(line.at, line.mine) { mutableStateOf(false) } + val clipboard = LocalClipboardManager.current + Box(modifier = Modifier.combinedClickable( + onClick = {}, + onLongClick = { menuOpen = true }, + )) { + Bubble( + line = line, + model = model, + authorName = when { + line.mine || chat.group == null -> chat.title + authorId == null -> chat.title + else -> buddies.firstOrNull { it.userId == authorId } + ?.let { it.displayName.ifBlank { it.handle } } + ?: "user $authorId" + }, + // 🔴 Only for a message you did not send. Your own bubble + // has no avatar in the kit — you know who you are, and a + // picture on both sides halves the width a thread has. + picture = authorId?.let { avatars[it] }, + ) + DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { + if (line.text.isNotBlank()) { + DropdownMenuItem( + text = { Text("Copy") }, + onClick = { clipboard.setText(AnnotatedString(line.text)); menuOpen = false }, + ) + } + DropdownMenuItem( + text = { Text(if (line.mine) "Unsend" else "Delete for me") }, + onClick = { model.deleteMessage(line); menuOpen = false }, + modifier = Modifier.testTag("delete-message"), + ) + } + } } } diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt index 5677e32..341ce01 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt @@ -780,6 +780,18 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { } } + /** Delete one message from this device (delete for me). */ + fun deleteMessage(line: Session.Line) = viewModelScope.launch { + val current = session ?: return@launch + val threadId = signedIn()?.openChat?.threadId ?: return@launch + withContext(Dispatchers.IO) { + current.deleteMessage(threadId, line.at, line.mine, line.text) + } + update { + it.copy(openChat = it.openChat?.copy(lines = current.history(threadId))) + } + } + // -- Presence ------------------------------------------------------------ fun setPresence(status: String) = viewModelScope.launch { 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 9f4dc4f..27d6965 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt @@ -131,6 +131,10 @@ class Session( /** Clear a thread. Keeps the session, so the conversation still works. */ fun clearHistory(threadId: String) = vault.forgetHistory(threadId) + /** Delete one message from this device (delete for me). */ + fun deleteMessage(threadId: String, at: Long, mine: Boolean, text: String) = + vault.deleteLine(threadId, at, mine, text) + /** Send an image. Returns what happened, same as a text send. */ fun sendImage(peerUserId: Long, bytes: ByteArray, mime: String, width: Int, height: Int): io.flamenet.client.Conversations.Sent { diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt index 00cd118..00d7d77 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt @@ -741,6 +741,25 @@ class Vault(private val storage: Storage, private val key: SecretKey) { */ fun forgetHistory(threadId: String) = storage.delete(historyName(threadId)) + /** + * Delete one line from a thread's history (delete for me). + * + * Keyed by timestamp + side, the same identity the delivery-state + * rewriter uses — messages carry no stable id here. Text is included so + * two messages sent in the same millisecond can't take each other down. + */ + fun deleteLine(threadId: String, at: Long, mine: Boolean, text: String) { + val existing = history(threadId) + val kept = org.json.JSONArray() + for (i in 0 until existing.length()) { + val m = existing.getJSONObject(i) + if (m.optLong("at") == at && m.optBoolean("mine") == mine && + m.optString("text") == text) continue + kept.put(m) + } + replaceHistory(threadId, kept) + } + /** * Forget everything, identity included. *