feat(web): add multi-select batch delete and undo flow
Some checks failed
CI / test (push) Failing after 18s
Some checks failed
CI / test (push) Failing after 18s
- 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
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { deleteMessage, forwardMessage, pinMessage } from "../api/chats";
|
import { deleteMessage, forwardMessage, pinMessage } from "../api/chats";
|
||||||
|
import type { Message } from "../chat/types";
|
||||||
import { useAuthStore } from "../store/authStore";
|
import { useAuthStore } from "../store/authStore";
|
||||||
import { useChatStore } from "../store/chatStore";
|
import { useChatStore } from "../store/chatStore";
|
||||||
import { formatTime } from "../utils/format";
|
import { formatTime } from "../utils/format";
|
||||||
@@ -11,6 +12,13 @@ type ContextMenuState = {
|
|||||||
messageId: number;
|
messageId: number;
|
||||||
} | null;
|
} | null;
|
||||||
|
|
||||||
|
type PendingDeleteState = {
|
||||||
|
chatId: number;
|
||||||
|
messages: Message[];
|
||||||
|
expiresAt: number;
|
||||||
|
timerId: number;
|
||||||
|
} | null;
|
||||||
|
|
||||||
export function MessageList() {
|
export function MessageList() {
|
||||||
const me = useAuthStore((s) => s.me);
|
const me = useAuthStore((s) => s.me);
|
||||||
const activeChatId = useChatStore((s) => s.activeChatId);
|
const activeChatId = useChatStore((s) => s.activeChatId);
|
||||||
@@ -21,6 +29,7 @@ export function MessageList() {
|
|||||||
const setReplyToMessage = useChatStore((s) => s.setReplyToMessage);
|
const setReplyToMessage = useChatStore((s) => s.setReplyToMessage);
|
||||||
const updateChatPinnedMessage = useChatStore((s) => s.updateChatPinnedMessage);
|
const updateChatPinnedMessage = useChatStore((s) => s.updateChatPinnedMessage);
|
||||||
const removeMessage = useChatStore((s) => s.removeMessage);
|
const removeMessage = useChatStore((s) => s.removeMessage);
|
||||||
|
const restoreMessages = useChatStore((s) => s.restoreMessages);
|
||||||
const [ctx, setCtx] = useState<ContextMenuState>(null);
|
const [ctx, setCtx] = useState<ContextMenuState>(null);
|
||||||
const [forwardMessageId, setForwardMessageId] = useState<number | null>(null);
|
const [forwardMessageId, setForwardMessageId] = useState<number | null>(null);
|
||||||
const [forwardQuery, setForwardQuery] = useState("");
|
const [forwardQuery, setForwardQuery] = useState("");
|
||||||
@@ -28,6 +37,9 @@ export function MessageList() {
|
|||||||
const [isForwarding, setIsForwarding] = useState(false);
|
const [isForwarding, setIsForwarding] = useState(false);
|
||||||
const [deleteMessageId, setDeleteMessageId] = useState<number | null>(null);
|
const [deleteMessageId, setDeleteMessageId] = useState<number | null>(null);
|
||||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||||
|
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
|
||||||
|
const [pendingDelete, setPendingDelete] = useState<PendingDeleteState>(null);
|
||||||
|
const [undoTick, setUndoTick] = useState(0);
|
||||||
|
|
||||||
const messages = useMemo(() => {
|
const messages = useMemo(() => {
|
||||||
if (!activeChatId) {
|
if (!activeChatId) {
|
||||||
@@ -49,6 +61,14 @@ export function MessageList() {
|
|||||||
}, [chats, forwardQuery]);
|
}, [chats, forwardQuery]);
|
||||||
const unreadBoundaryCount = activeChatId ? (unreadBoundaryByChat[activeChatId] ?? 0) : 0;
|
const unreadBoundaryCount = activeChatId ? (unreadBoundaryByChat[activeChatId] ?? 0) : 0;
|
||||||
const unreadBoundaryIndex = unreadBoundaryCount > 0 ? Math.max(0, messages.length - unreadBoundaryCount) : -1;
|
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(() => {
|
useEffect(() => {
|
||||||
const onKeyDown = (event: KeyboardEvent) => {
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
@@ -58,11 +78,27 @@ export function MessageList() {
|
|||||||
setCtx(null);
|
setCtx(null);
|
||||||
setForwardMessageId(null);
|
setForwardMessageId(null);
|
||||||
setDeleteMessageId(null);
|
setDeleteMessageId(null);
|
||||||
|
setSelectedIds(new Set());
|
||||||
};
|
};
|
||||||
window.addEventListener("keydown", onKeyDown);
|
window.addEventListener("keydown", onKeyDown);
|
||||||
return () => window.removeEventListener("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) {
|
if (!activeChatId) {
|
||||||
return <div className="flex h-full items-center justify-center text-slate-300/80">Select a chat</div>;
|
return <div className="flex h-full items-center justify-center text-slate-300/80">Select a chat</div>;
|
||||||
}
|
}
|
||||||
@@ -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 (
|
return (
|
||||||
<div className="flex h-full flex-col" onClick={() => setCtx(null)}>
|
<div className="flex h-full flex-col" onClick={() => setCtx(null)}>
|
||||||
{activeChat?.pinned_message_id ? (
|
{activeChat?.pinned_message_id ? (
|
||||||
@@ -112,10 +235,29 @@ export function MessageList() {
|
|||||||
📌 {messagesMap.get(activeChat.pinned_message_id)?.text || "Pinned message"}
|
📌 {messagesMap.get(activeChat.pinned_message_id)?.text || "Pinned message"}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
{selectedIds.size > 0 ? (
|
||||||
|
<div className="flex items-center justify-between border-b border-slate-700/50 bg-slate-900/80 px-3 py-2 text-xs">
|
||||||
|
<span className="font-semibold text-slate-200">{selectedIds.size} selected</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button className="rounded bg-red-500 px-2 py-1 font-semibold text-white" onClick={() => void deleteSelectedForMe()}>
|
||||||
|
Delete for me
|
||||||
|
</button>
|
||||||
|
{canDeleteAllForSelection ? (
|
||||||
|
<button className="rounded bg-red-600 px-2 py-1 font-semibold text-white" onClick={() => void deleteSelectedForEveryone()}>
|
||||||
|
Delete for everyone
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
<button className="rounded bg-slate-700 px-2 py-1 text-slate-200" onClick={() => setSelectedIds(new Set())}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<div className="tg-scrollbar flex-1 overflow-auto px-3 py-4 md:px-6">
|
<div className="tg-scrollbar flex-1 overflow-auto px-3 py-4 md:px-6">
|
||||||
{messages.map((message, messageIndex) => {
|
{messages.map((message, messageIndex) => {
|
||||||
const own = message.sender_id === me?.id;
|
const own = message.sender_id === me?.id;
|
||||||
const replySource = message.reply_to_message_id ? messagesMap.get(message.reply_to_message_id) : null;
|
const replySource = message.reply_to_message_id ? messagesMap.get(message.reply_to_message_id) : null;
|
||||||
|
const isSelected = selectedIds.has(message.id);
|
||||||
return (
|
return (
|
||||||
<div key={`${message.id}-${message.client_message_id ?? ""}`}>
|
<div key={`${message.id}-${message.client_message_id ?? ""}`}>
|
||||||
{unreadBoundaryIndex === messageIndex ? (
|
{unreadBoundaryIndex === messageIndex ? (
|
||||||
@@ -131,13 +273,23 @@ export function MessageList() {
|
|||||||
<div
|
<div
|
||||||
className={`max-w-[86%] rounded-2xl px-3 py-2 shadow-sm md:max-w-[72%] ${
|
className={`max-w-[86%] rounded-2xl px-3 py-2 shadow-sm md:max-w-[72%] ${
|
||||||
own ? "rounded-br-md bg-sky-500/90 text-slate-950" : "rounded-bl-md border border-slate-700/60 bg-slate-900/80 text-slate-100"
|
own ? "rounded-br-md bg-sky-500/90 text-slate-950" : "rounded-bl-md border border-slate-700/60 bg-slate-900/80 text-slate-100"
|
||||||
}`}
|
} ${isSelected ? "ring-2 ring-sky-400/80" : ""}`}
|
||||||
|
onClick={() => {
|
||||||
|
if (selectedIds.size > 0) {
|
||||||
|
toggleSelected(message.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const pos = getSafeContextPosition(e.clientX, e.clientY, 160, 108);
|
const pos = getSafeContextPosition(e.clientX, e.clientY, 160, 108);
|
||||||
setCtx({ x: pos.x, y: pos.y, messageId: message.id });
|
setCtx({ x: pos.x, y: pos.y, messageId: message.id });
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{selectedIds.size > 0 ? (
|
||||||
|
<div className={`mb-1 inline-flex h-4 w-4 items-center justify-center rounded-full border text-[10px] ${isSelected ? "border-sky-300 bg-sky-300 text-slate-900" : "border-slate-400 text-slate-300"}`}>
|
||||||
|
{isSelected ? "✓" : ""}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{message.forwarded_from_message_id ? (
|
{message.forwarded_from_message_id ? (
|
||||||
<div className={`mb-1 rounded-md border-l-2 px-2 py-1 text-[11px] ${own ? "border-slate-900/60 bg-slate-900/10 text-slate-900/75" : "border-sky-400 bg-slate-800/60 text-sky-300"}`}>
|
<div className={`mb-1 rounded-md border-l-2 px-2 py-1 text-[11px] ${own ? "border-slate-900/60 bg-slate-900/10 text-slate-900/75" : "border-sky-400 bg-slate-800/60 text-sky-300"}`}>
|
||||||
↪ Forwarded message
|
↪ Forwarded message
|
||||||
@@ -192,6 +344,15 @@ export function MessageList() {
|
|||||||
>
|
>
|
||||||
Forward
|
Forward
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-slate-800"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedIds(new Set([ctx.messageId]));
|
||||||
|
setCtx(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Select
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
className="block w-full rounded px-2 py-1.5 text-left text-sm text-red-300 hover:bg-slate-800"
|
className="block w-full rounded px-2 py-1.5 text-left text-sm text-red-300 hover:bg-slate-800"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -264,6 +425,20 @@ export function MessageList() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{pendingDelete && pendingDelete.chatId === chatId ? (
|
||||||
|
<div className="pointer-events-none absolute bottom-3 left-0 right-0 z-50 flex justify-center px-3" data-tick={undoTick}>
|
||||||
|
<div className="pointer-events-auto flex w-full max-w-sm items-center justify-between rounded-lg border border-slate-700/80 bg-slate-900/95 px-3 py-2 text-xs shadow-xl">
|
||||||
|
<span className="text-slate-200">
|
||||||
|
Messages deleted
|
||||||
|
{` (${Math.max(0, Math.ceil((pendingDelete.expiresAt - Date.now()) / 1000))}s)`}
|
||||||
|
</span>
|
||||||
|
<button className="rounded bg-sky-500 px-2 py-1 font-semibold text-slate-950" onClick={undoDeleteForMe} type="button">
|
||||||
|
Undo
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ interface ChatState {
|
|||||||
removeOptimisticMessage: (chatId: number, clientMessageId: string) => void;
|
removeOptimisticMessage: (chatId: number, clientMessageId: string) => void;
|
||||||
setMessageDeliveryStatus: (chatId: number, messageId: number, status: DeliveryStatus) => void;
|
setMessageDeliveryStatus: (chatId: number, messageId: number, status: DeliveryStatus) => void;
|
||||||
removeMessage: (chatId: number, messageId: number) => void;
|
removeMessage: (chatId: number, messageId: number) => void;
|
||||||
|
restoreMessages: (chatId: number, messages: Message[]) => void;
|
||||||
clearChatMessages: (chatId: number) => void;
|
clearChatMessages: (chatId: number) => void;
|
||||||
incrementUnread: (chatId: number) => void;
|
incrementUnread: (chatId: number) => void;
|
||||||
clearUnread: (chatId: number) => void;
|
clearUnread: (chatId: number) => void;
|
||||||
@@ -158,6 +159,26 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
restoreMessages: (chatId, messages) => {
|
||||||
|
if (!messages.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const old = get().messagesByChat[chatId] ?? [];
|
||||||
|
const byId = new Map<number, Message>();
|
||||||
|
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) =>
|
clearChatMessages: (chatId) =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
messagesByChat: {
|
messagesByChat: {
|
||||||
|
|||||||
Reference in New Issue
Block a user