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.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"),
)
}
}
}
}
@@ -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 {
@@ -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 {