From 7003c8e4c3b5520e9e562f731fd424a4764211fc Mon Sep 17 00:00:00 2001 From: benya Date: Sun, 8 Mar 2026 01:50:34 +0300 Subject: [PATCH] feat(web): add multi-select batch delete and undo flow - add message selection mode from context menu - support batch delete for me and conditional batch delete for everyone - add undo snackbar for delete-for-me with delayed backend commit - add restoreMessages helper in chat store for undo rollback --- web/src/components/MessageList.tsx | 177 ++++++++++++++++++++++++++++- web/src/store/chatStore.ts | 21 ++++ 2 files changed, 197 insertions(+), 1 deletion(-) diff --git a/web/src/components/MessageList.tsx b/web/src/components/MessageList.tsx index 8299ed9..5dcd3a4 100644 --- a/web/src/components/MessageList.tsx +++ b/web/src/components/MessageList.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useState } from "react"; import { createPortal } from "react-dom"; import { deleteMessage, forwardMessage, pinMessage } from "../api/chats"; +import type { Message } from "../chat/types"; import { useAuthStore } from "../store/authStore"; import { useChatStore } from "../store/chatStore"; import { formatTime } from "../utils/format"; @@ -11,6 +12,13 @@ type ContextMenuState = { messageId: number; } | null; +type PendingDeleteState = { + chatId: number; + messages: Message[]; + expiresAt: number; + timerId: number; +} | null; + export function MessageList() { const me = useAuthStore((s) => s.me); const activeChatId = useChatStore((s) => s.activeChatId); @@ -21,6 +29,7 @@ export function MessageList() { const setReplyToMessage = useChatStore((s) => s.setReplyToMessage); const updateChatPinnedMessage = useChatStore((s) => s.updateChatPinnedMessage); const removeMessage = useChatStore((s) => s.removeMessage); + const restoreMessages = useChatStore((s) => s.restoreMessages); const [ctx, setCtx] = useState(null); const [forwardMessageId, setForwardMessageId] = useState(null); const [forwardQuery, setForwardQuery] = useState(""); @@ -28,6 +37,9 @@ export function MessageList() { const [isForwarding, setIsForwarding] = useState(false); const [deleteMessageId, setDeleteMessageId] = useState(null); const [deleteError, setDeleteError] = useState(null); + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [pendingDelete, setPendingDelete] = useState(null); + const [undoTick, setUndoTick] = useState(0); const messages = useMemo(() => { if (!activeChatId) { @@ -49,6 +61,14 @@ export function MessageList() { }, [chats, forwardQuery]); const unreadBoundaryCount = activeChatId ? (unreadBoundaryByChat[activeChatId] ?? 0) : 0; const unreadBoundaryIndex = unreadBoundaryCount > 0 ? Math.max(0, messages.length - unreadBoundaryCount) : -1; + const selectedMessages = useMemo( + () => messages.filter((m) => selectedIds.has(m.id)), + [messages, selectedIds] + ); + const canDeleteAllForSelection = useMemo( + () => selectedMessages.length > 0 && selectedMessages.every((m) => canDeleteForEveryone(m, activeChat, me?.id)), + [selectedMessages, activeChat, me?.id] + ); useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { @@ -58,11 +78,27 @@ export function MessageList() { setCtx(null); setForwardMessageId(null); setDeleteMessageId(null); + setSelectedIds(new Set()); }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); }, []); + useEffect(() => { + setSelectedIds(new Set()); + setCtx(null); + setDeleteMessageId(null); + setForwardMessageId(null); + }, [activeChatId]); + + useEffect(() => { + if (!pendingDelete) { + return; + } + const interval = window.setInterval(() => setUndoTick((v) => v + 1), 250); + return () => window.clearInterval(interval); + }, [pendingDelete]); + if (!activeChatId) { return
Select a chat
; } @@ -105,6 +141,93 @@ export function MessageList() { } } + function toggleSelected(messageId: number) { + setSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(messageId)) { + next.delete(messageId); + } else { + next.add(messageId); + } + return next; + }); + } + + async function commitPendingDelete(state: PendingDeleteState) { + if (!state) { + return; + } + await Promise.allSettled(state.messages.map((message) => deleteMessage(message.id, false))); + setPendingDelete((current) => { + if (!current || current.timerId !== state.timerId) { + return current; + } + return null; + }); + } + + async function deleteSelectedForMe() { + if (!selectedMessages.length) { + return; + } + if (pendingDelete) { + window.clearTimeout(pendingDelete.timerId); + await commitPendingDelete(pendingDelete); + } + for (const message of selectedMessages) { + removeMessage(chatId, message.id); + } + const timeoutMs = 6000; + const timerId = window.setTimeout(() => { + void commitPendingDelete({ + chatId, + messages: selectedMessages, + expiresAt: Date.now() + timeoutMs, + timerId + }); + }, timeoutMs); + setPendingDelete({ + chatId, + messages: selectedMessages, + expiresAt: Date.now() + timeoutMs, + timerId + }); + setSelectedIds(new Set()); + } + + async function deleteSelectedForEveryone() { + if (!selectedMessages.length) { + return; + } + const results = await Promise.allSettled( + selectedMessages.map(async (message) => { + await deleteMessage(message.id, true); + return message.id; + }) + ); + for (const result of results) { + if (result.status === "fulfilled") { + removeMessage(chatId, result.value); + } + } + if (results.some((r) => r.status === "rejected")) { + setDeleteError("Some messages could not be deleted for everyone"); + } else { + setDeleteError(null); + } + setSelectedIds(new Set()); + } + + function undoDeleteForMe() { + if (!pendingDelete || pendingDelete.chatId !== chatId) { + setPendingDelete(null); + return; + } + window.clearTimeout(pendingDelete.timerId); + restoreMessages(chatId, pendingDelete.messages); + setPendingDelete(null); + } + return (
setCtx(null)}> {activeChat?.pinned_message_id ? ( @@ -112,10 +235,29 @@ export function MessageList() { 📌 {messagesMap.get(activeChat.pinned_message_id)?.text || "Pinned message"}
) : null} + {selectedIds.size > 0 ? ( +
+ {selectedIds.size} selected +
+ + {canDeleteAllForSelection ? ( + + ) : null} + +
+
+ ) : null}
{messages.map((message, messageIndex) => { const own = message.sender_id === me?.id; const replySource = message.reply_to_message_id ? messagesMap.get(message.reply_to_message_id) : null; + const isSelected = selectedIds.has(message.id); return (
{unreadBoundaryIndex === messageIndex ? ( @@ -131,13 +273,23 @@ export function MessageList() {
{ + if (selectedIds.size > 0) { + toggleSelected(message.id); + } + }} onContextMenu={(e) => { e.preventDefault(); const pos = getSafeContextPosition(e.clientX, e.clientY, 160, 108); setCtx({ x: pos.x, y: pos.y, messageId: message.id }); }} > + {selectedIds.size > 0 ? ( +
+ {isSelected ? "✓" : ""} +
+ ) : null} {message.forwarded_from_message_id ? (
↪ Forwarded message @@ -192,6 +344,15 @@ export function MessageList() { > Forward +
) : null} + + {pendingDelete && pendingDelete.chatId === chatId ? ( +
+
+ + Messages deleted + {` (${Math.max(0, Math.ceil((pendingDelete.expiresAt - Date.now()) / 1000))}s)`} + + +
+
+ ) : null}
); } diff --git a/web/src/store/chatStore.ts b/web/src/store/chatStore.ts index e4bebd9..49ed688 100644 --- a/web/src/store/chatStore.ts +++ b/web/src/store/chatStore.ts @@ -24,6 +24,7 @@ interface ChatState { removeOptimisticMessage: (chatId: number, clientMessageId: string) => void; setMessageDeliveryStatus: (chatId: number, messageId: number, status: DeliveryStatus) => void; removeMessage: (chatId: number, messageId: number) => void; + restoreMessages: (chatId: number, messages: Message[]) => void; clearChatMessages: (chatId: number) => void; incrementUnread: (chatId: number) => void; clearUnread: (chatId: number) => void; @@ -158,6 +159,26 @@ export const useChatStore = create((set, get) => ({ } })); }, + restoreMessages: (chatId, messages) => { + if (!messages.length) { + return; + } + const old = get().messagesByChat[chatId] ?? []; + const byId = new Map(); + for (const message of old) { + byId.set(message.id, message); + } + for (const message of messages) { + byId.set(message.id, message); + } + const merged = [...byId.values()].sort((a, b) => a.id - b.id); + set((state) => ({ + messagesByChat: { + ...state.messagesByChat, + [chatId]: merged + } + })); + }, clearChatMessages: (chatId) => set((state) => ({ messagesByChat: {