Delete individual messages via long-press; leave-group already shipped

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 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-08-29 08:07:21 -04:00
co-authored by Claude Opus 4.8
parent 68329bcf0f
commit 1799ada15b
4 changed files with 77 additions and 15 deletions
@@ -5,7 +5,13 @@ import android.content.pm.PackageManager
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable 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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -1263,6 +1269,7 @@ private fun PresenceDot(status: String) {
} }
/** One conversation. */ /** One conversation. */
@OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun ColumnScope.ChatScreen( fun ColumnScope.ChatScreen(
model: MessengerViewModel, model: MessengerViewModel,
@@ -1377,6 +1384,12 @@ fun ColumnScope.ChatScreen(
chat.group == null -> chat.peerId chat.group == null -> chat.peerId
else -> line.author.takeIf { it != 0L } else -> line.author.takeIf { it != 0L }
} }
var menuOpen by remember(line.at, line.mine) { mutableStateOf(false) }
val clipboard = LocalClipboardManager.current
Box(modifier = Modifier.combinedClickable(
onClick = {},
onLongClick = { menuOpen = true },
)) {
Bubble( Bubble(
line = line, line = line,
model = model, model = model,
@@ -1387,11 +1400,25 @@ fun ColumnScope.ChatScreen(
?.let { it.displayName.ifBlank { it.handle } } ?.let { it.displayName.ifBlank { it.handle } }
?: "user $authorId" ?: "user $authorId"
}, },
// 🔴 Only for a message you did not send. Your own bubble has // 🔴 Only for a message you did not send. Your own bubble
// no avatar in the kit — you know who you are, and a picture on // has no avatar in the kit — you know who you are, and a
// both sides halves the width a thread has to say anything in. // picture on both sides halves the width a thread has.
picture = authorId?.let { avatars[it] }, 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"),
)
}
}
} }
} }
@@ -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 ------------------------------------------------------------ // -- Presence ------------------------------------------------------------
fun setPresence(status: String) = viewModelScope.launch { fun setPresence(status: String) = viewModelScope.launch {
@@ -131,6 +131,10 @@ class Session(
/** Clear a thread. Keeps the session, so the conversation still works. */ /** Clear a thread. Keeps the session, so the conversation still works. */
fun clearHistory(threadId: String) = vault.forgetHistory(threadId) 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. */ /** Send an image. Returns what happened, same as a text send. */
fun sendImage(peerUserId: Long, bytes: ByteArray, mime: String, width: Int, height: Int): fun sendImage(peerUserId: Long, bytes: ByteArray, mime: String, width: Int, height: Int):
io.flamenet.client.Conversations.Sent { io.flamenet.client.Conversations.Sent {
@@ -741,6 +741,25 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
*/ */
fun forgetHistory(threadId: String) = storage.delete(historyName(threadId)) 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. * Forget everything, identity included.
* *