Long-press message selection + delete (message.delete frame, outbox removal, all-device sync)

This commit is contained in:
ARIA committed 2026-08-20 16:51:19 +02:00
1 parent 60ec2b44a7
commit 6f4bf3cb7f
11 files changed
+417 -18

No files matched your search

@@ -444,6 +444,26 @@ class ChatStore {
if (changed) _lanes.value = map
}
/**
* Remove messages by id from every lane (a `message.deleted` frame). The
* server is authoritative: the frame carries no lane, and a message id is
* unique, so scanning all lanes is both safe and idempotent (a no-op when the
* id is absent, e.g. a local-only id another device never had).
*/
fun removeMessages(messageIds: Set<String>) {
if (messageIds.isEmpty()) return
val map = _lanes.value.toMutableMap()
var changed = false
for ((lane, list) in map) {
val updated = list.filterNot { it.id in messageIds }
if (updated != list) {
map[lane] = updated
changed = true
}
}
if (changed) _lanes.value = map
}
/** M7: mark all pending user messages as failed (gateway error frame). */
fun failPending() {
val map = _lanes.value.toMutableMap()
@@ -3,6 +3,7 @@ package iris.protocol
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
@@ -41,6 +42,10 @@ const val TYPE_TYPING = "typing"
const val TYPE_MESSAGE_START = "message.start"
const val TYPE_MESSAGE_UPDATE = "message.update"
const val TYPE_MESSAGE_STOP = "message.stop"
// Message deletion (app requests; broadcast to all devices)
const val TYPE_MESSAGE_DELETE = "message.delete"
const val TYPE_MESSAGE_DELETED = "message.deleted"
const val TYPE_TOOL_START = "tool.start"
const val TYPE_TOOL_PROGRESS = "tool.progress"
const val TYPE_TOOL_END = "tool.end"
@@ -385,6 +390,14 @@ data class HistoryPayload(
@SerialName("oldest_message_id") val oldestMessageId: String? = null,
)
// ── message deletion ──────────────────────────────────────────────────────
/** The message(s) removed from a chat/thread (response + broadcast). */
@Serializable
data class MessageDeletedPayload(
@SerialName("message_ids") val messageIds: List<String> = emptyList(),
)
// ── M5: push / notifications ────────────────────────────────────────────
/** Notification kinds (mirror of protocol.NOTIF_*). */
@@ -550,6 +563,24 @@ fun historyFrame(
},
)
/** Request deletion of the given message(s) in a chat/thread. The server
* removes them from the outbox and broadcasts `message.deleted`. */
fun messageDeleteFrame(
id: Int,
chatId: String,
messageIds: List<String>,
threadId: String? = null,
): Frame =
Frame(
id = id,
type = TYPE_MESSAGE_DELETE,
chatId = chatId,
threadId = threadId,
payload = buildJsonObject {
put("message_ids", JsonArray(messageIds.map { JsonPrimitive(it) }))
},
)
// ── M4 frame builders ────────────────────────────────────────────────────
fun mediaUploadStartFrame(
@@ -19,6 +19,7 @@ import iris.protocol.HIGH_PRIORITY_NOTIF_KINDS
import iris.protocol.HistoryMessage
import iris.protocol.HistoryPayload
import iris.protocol.MediaOfferPayload
import iris.protocol.MessageDeletedPayload
import iris.protocol.MessagePayload
import iris.protocol.MessageStopPayload
import iris.protocol.NotificationPayload
@@ -38,6 +39,8 @@ import iris.protocol.TYPE_CHANNEL_RENAMED
import iris.protocol.TYPE_COMMENTARY
import iris.protocol.TYPE_MEDIA_OFFER
import iris.protocol.TYPE_MESSAGE
import iris.protocol.TYPE_MESSAGE_DELETE
import iris.protocol.TYPE_MESSAGE_DELETED
import iris.protocol.TYPE_MESSAGE_START
import iris.protocol.TYPE_MESSAGE_STOP
import iris.protocol.TYPE_MESSAGE_UPDATE
@@ -56,6 +59,7 @@ import iris.protocol.channelListFrame
import iris.protocol.channelRenameFrame
import iris.protocol.channelSetDefaultFrame
import iris.protocol.historyFrame
import iris.protocol.messageDeleteFrame
import iris.protocol.searchFrame
import iris.protocol.syncFrame
import kotlinx.coroutines.CoroutineScope
@@ -367,6 +371,13 @@ class IrisController(
TYPE_READ_RECEIPT -> {
frame.payloadAs<ReadReceiptPayload>()?.let { chat.markRead(it.messageId) }
}
TYPE_MESSAGE_DELETED -> {
// A message was deleted (by this or another device).
// Idempotent: dropping an unknown id is a no-op.
frame.payloadAs<MessageDeletedPayload>()?.let {
chat.removeMessages(it.messageIds.toSet())
}
}
TYPE_STATUS -> {
frame.payloadAs<StatusPayload>()?.let { _gatewayStatus.value = it.state }
}
@@ -539,6 +550,18 @@ class IrisController(
)
}
/** Delete the given message(s) from the current lane (long-press select →
* delete). The server removes them from the outbox and broadcasts
* `message.deleted`; the local cache drops them on that frame (or
* immediately, below, for snappy UX). */
fun deleteMessages(messageIds: List<String>) {
if (messageIds.isEmpty()) return
val lane = chat.currentLane.value
val (chatId, threadId) = chat.parseLane(lane)
chat.removeMessages(messageIds.toSet())
client.sendFrame(messageDeleteFrame(0, chatId, messageIds, threadId))
}
// ── M4: attachments (pick -> upload -> send) ──────────────────────────
/** Stage a picked file: upload it, then keep it as a pending attachment. */
@@ -2,6 +2,7 @@ package iris.ui.screens
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.focusable
@@ -221,6 +222,34 @@ fun ChatScreen(controller: IrisController) {
// Topic context menu (long-press / right-click): rename / delete a thread.
var renameThread by remember { mutableStateOf<ChannelInfo?>(null) }
var deleteThread by remember { mutableStateOf<ChannelInfo?>(null) }
// Message selection (long-press / right-click a bubble → multi-select →
// delete). The composer is replaced by a selection toolbar while active.
var selectionMode by remember { mutableStateOf(false) }
var selectedIds by remember { mutableStateOf<Set<String>>(emptySet()) }
var showDeleteConfirm by remember { mutableStateOf(false) }
fun enterSelection(id: String) {
selectionMode = true
selectedIds = setOf(id)
}
fun toggleSelect(id: String) {
selectedIds = if (id in selectedIds) selectedIds - id else selectedIds + id
if (selectedIds.isEmpty()) selectionMode = false
}
fun exitSelection() {
selectionMode = false
selectedIds = emptySet()
}
// Selection is scoped to the lane the messages were viewed in; leaving
// that lane (channel / thread switch) clears any in-progress selection
// so a later delete can't target the wrong lane.
LaunchedEffect(currentLane) {
if (selectionMode) exitSelection()
}
Column(modifier = Modifier.fillMaxSize()) {
// Header (M7: avatar + two-line title pill, reference look). Wide
// panes keep everything on one row; narrow phones move the utility
@@ -355,12 +384,27 @@ fun ChatScreen(controller: IrisController) {
when (row) {
is ChatRow.Day -> DaySeparator(row.label)
is ChatRow.Item -> when (val item = row.item) {
is MessageItem -> MessageBubble(
msg = item,
maxWidth = bubbleMaxWidth,
reasoningAutoCollapse = reasoningAutoCollapse,
onRetry = { controller.retrySend(item.id) },
)
is MessageItem -> {
// Only finalized messages are selectable: a
// streaming bubble has no final id yet and a
// pending echo isn't on the server to delete.
val selectable = !item.streaming && !item.pending
MessageBubble(
msg = item,
maxWidth = bubbleMaxWidth,
reasoningAutoCollapse = reasoningAutoCollapse,
onRetry = { controller.retrySend(item.id) },
selectionMode = selectionMode && selectable,
selected = selectable && item.id in selectedIds,
onToggleSelect = { if (selectable) toggleSelect(item.id) },
onLongPress = {
if (selectable) {
if (selectionMode) toggleSelect(item.id)
else enterSelection(item.id)
}
},
)
}
is ToolItem -> if (toolDetail != ToolDetail.NOTHING) ToolCard(item, toolDetail)
}
}
@@ -444,6 +488,15 @@ fun ChatScreen(controller: IrisController) {
}
}
// Selection toolbar (replaces the composer while messages are selected).
if (selectionMode) {
SelectionToolbar(
count = selectedIds.size,
onCancel = { exitSelection() },
onDelete = { showDeleteConfirm = true },
)
}
// Composer (M7: rounded pill + accent circular send button). Sending is gated
// on a live socket: sendMessage is a no-op while disconnected, so an
// ungated send would show a pending bubble that never resolves.
@@ -452,7 +505,7 @@ fun ChatScreen(controller: IrisController) {
isConnected && (input.isNotBlank() || attachments.any { it.mediaRef != null && it.error == null })
val layoutDensity = LocalDensity.current.density
var textHeightPx by remember { mutableFloatStateOf(0f) }
Row(
if (!selectionMode) Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp)
@@ -575,6 +628,30 @@ fun ChatScreen(controller: IrisController) {
},
)
}
// Message selection: confirm deleting the selected message(s).
if (showDeleteConfirm) {
val n = selectedIds.size
AlertDialog(
onDismissRequest = { showDeleteConfirm = false },
title = { Text(if (n == 1) "Delete message?" else "Delete $n messages?") },
text = {
Text(
if (n == 1) "This message will be deleted for all devices."
else "These messages will be deleted for all devices.",
)
},
confirmButton = {
TextButton(onClick = {
showDeleteConfirm = false
controller.deleteMessages(selectedIds.toList())
exitSelection()
}) { Text("Delete") }
},
dismissButton = {
TextButton(onClick = { showDeleteConfirm = false }) { Text("Cancel") }
},
)
}
}
}
@@ -1418,8 +1495,18 @@ private fun NotificationBanner(
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun MessageBubble(msg: MessageItem, maxWidth: Dp, reasoningAutoCollapse: Boolean, onRetry: () -> Unit) {
private fun MessageBubble(
msg: MessageItem,
maxWidth: Dp,
reasoningAutoCollapse: Boolean,
onRetry: () -> Unit,
selectionMode: Boolean = false,
selected: Boolean = false,
onToggleSelect: () -> Unit = {},
onLongPress: () -> Unit = {},
) {
val isUser = msg.role == ROLE_USER
val isCommentary = msg.isCommentary
val bubbleColor = when {
@@ -1437,15 +1524,31 @@ private fun MessageBubble(msg: MessageItem, maxWidth: Dp, reasoningAutoCollapse:
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start,
) {
// Selection check sits on the side away from the bubble (user bubbles
// are right-aligned → check on the left; assistant on the left → right).
if (isUser && selectionMode) {
SelectionCheck(selected)
Spacer(modifier = Modifier.width(6.dp))
}
Column(
modifier = Modifier
.widthIn(max = maxWidth)
.clip(RoundedCornerShape(14.dp))
.background(bubbleColor)
.background(if (selected) IrisColors.chipSelected else bubbleColor)
.then(
if (isUser && msg.status == MsgStatus.Failed) {
Modifier.clickable { onRetry() }
} else Modifier
// Long-press / right-click enters (or toggles within) message
// selection; a plain tap toggles while selecting, or retries a
// failed user send otherwise. Attached always so the long-press
// affordance exists even outside selection mode.
Modifier
.combinedClickable(
onClick = {
if (selectionMode) onToggleSelect()
else if (isUser && msg.status == MsgStatus.Failed) onRetry()
},
onLongClick = { onLongPress() },
)
.rightClick { onLongPress() }
)
.padding(horizontal = 12.dp, vertical = 8.dp),
) {
@@ -1532,6 +1635,63 @@ private fun MessageBubble(msg: MessageItem, maxWidth: Dp, reasoningAutoCollapse:
}
}
}
if (!isUser && selectionMode) {
Spacer(modifier = Modifier.width(6.dp))
SelectionCheck(selected)
}
}
}
/** Circular selection indicator shown beside a bubble in selection mode. */
@Composable
private fun SelectionCheck(selected: Boolean) {
Box(
modifier = Modifier
.size(22.dp)
.clip(CircleShape)
.background(if (selected) IrisColors.primary else Color.Transparent)
.border(
width = if (selected) 0.dp else 1.5.dp,
color = IrisColors.textDim,
shape = CircleShape,
),
contentAlignment = Alignment.Center,
) {
if (selected) Text("✓", color = Color.White, fontSize = 12.sp)
}
}
/** Toolbar shown in place of the composer while messages are selected. */
@Composable
private fun SelectionToolbar(count: Int, onCancel: () -> Unit, onDelete: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp)
.clip(RoundedCornerShape(28.dp))
.background(IrisColors.surface)
.padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
HeaderIconButton(onClick = onCancel) {
Text("✕", fontSize = 16.sp)
}
Spacer(modifier = Modifier.width(8.dp))
Text(
if (count == 1) "1 selected" else "$count selected",
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.weight(1f),
)
Box(
modifier = Modifier
.clip(RoundedCornerShape(20.dp))
.background(if (count > 0) IrisColors.primary else IrisColors.primary.copy(alpha = 0.35f))
.clickable(enabled = count > 0) { onDelete() }
.padding(horizontal = 16.dp, vertical = 8.dp),
contentAlignment = Alignment.Center,
) {
Text("🗑 Delete", color = Color.White, fontSize = 14.sp)
}
}
}