Long-press message selection + delete (message.delete frame, outbox removal, all-device sync)
This commit is contained in:
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user