feat(core): clear saved chat and add message deletion scopes
Some checks failed
CI / test (push) Failing after 26s

backend:

- add message_hidden table for per-user message hiding

- support DELETE /messages/{id}?for_all=true|false

- implement delete-for-me vs delete-for-all logic by chat type/permissions

- add POST /chats/{chat_id}/clear and route saved chat deletion to clear

web:

- saved messages action changed from delete to clear

- message context menu now supports delete modal: for me / for everyone

- add local store helpers removeMessage/clearChatMessages

- include realtime stability improvements and app error boundary
This commit is contained in:
2026-03-08 01:13:20 +03:00
parent a42f97962b
commit 7f15edcb4e
15 changed files with 486 additions and 77 deletions

View File

@@ -1,6 +1,6 @@
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { forwardMessage, pinMessage } from "../api/chats";
import { deleteMessage, forwardMessage, pinMessage } from "../api/chats";
import { useAuthStore } from "../store/authStore";
import { useChatStore } from "../store/chatStore";
import { formatTime } from "../utils/format";
@@ -19,11 +19,14 @@ export function MessageList() {
const chats = useChatStore((s) => s.chats);
const setReplyToMessage = useChatStore((s) => s.setReplyToMessage);
const updateChatPinnedMessage = useChatStore((s) => s.updateChatPinnedMessage);
const removeMessage = useChatStore((s) => s.removeMessage);
const [ctx, setCtx] = useState<ContextMenuState>(null);
const [forwardMessageId, setForwardMessageId] = useState<number | null>(null);
const [forwardQuery, setForwardQuery] = useState("");
const [forwardError, setForwardError] = useState<string | null>(null);
const [isForwarding, setIsForwarding] = useState(false);
const [deleteMessageId, setDeleteMessageId] = useState<number | null>(null);
const [deleteError, setDeleteError] = useState<string | null>(null);
const messages = useMemo(() => {
if (!activeChatId) {
@@ -44,6 +47,19 @@ export function MessageList() {
});
}, [chats, forwardQuery]);
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Escape") {
return;
}
setCtx(null);
setForwardMessageId(null);
setDeleteMessageId(null);
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, []);
if (!activeChatId) {
return <div className="flex h-full items-center justify-center text-slate-300/80">Select a chat</div>;
}
@@ -72,6 +88,20 @@ export function MessageList() {
setCtx(null);
}
async function handleDelete(forAll: boolean) {
if (!deleteMessageId) {
return;
}
try {
await deleteMessage(deleteMessageId, forAll);
removeMessage(chatId, deleteMessageId);
setDeleteMessageId(null);
setDeleteError(null);
} catch {
setDeleteError("Failed to delete message");
}
}
return (
<div className="flex h-full flex-col" onClick={() => setCtx(null)}>
{activeChat?.pinned_message_id ? (
@@ -148,6 +178,16 @@ export function MessageList() {
>
Forward
</button>
<button
className="block w-full rounded px-2 py-1.5 text-left text-sm text-red-300 hover:bg-slate-800"
onClick={() => {
setDeleteMessageId(ctx.messageId);
setDeleteError(null);
setCtx(null);
}}
>
Delete
</button>
<button className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-slate-800" onClick={() => void handlePin(ctx.messageId)}>
Pin / Unpin
</button>
@@ -187,6 +227,29 @@ export function MessageList() {
</div>
</div>
) : null}
{deleteMessageId ? (
<div className="absolute inset-0 z-50 flex items-end justify-center bg-slate-950/55 p-3" onClick={() => setDeleteMessageId(null)}>
<div className="w-full rounded-xl border border-slate-700/80 bg-slate-900 p-3" onClick={(e) => e.stopPropagation()}>
<p className="mb-2 text-sm font-semibold">Delete message</p>
<p className="mb-3 text-xs text-slate-400">Choose how to delete this message.</p>
<div className="space-y-2">
<button className="w-full rounded bg-red-500 px-3 py-2 text-sm font-semibold text-white" onClick={() => void handleDelete(false)}>
Delete for me
</button>
{canDeleteForEveryone(messagesMap.get(deleteMessageId), activeChat, me?.id) ? (
<button className="w-full rounded bg-red-600 px-3 py-2 text-sm font-semibold text-white" onClick={() => void handleDelete(true)}>
Delete for everyone
</button>
) : null}
<button className="w-full rounded bg-slate-700 px-3 py-2 text-sm" onClick={() => setDeleteMessageId(null)}>
Cancel
</button>
</div>
{deleteError ? <p className="mt-2 text-xs text-red-400">{deleteError}</p> : null}
</div>
</div>
) : null}
</div>
);
}
@@ -231,3 +294,14 @@ function chatLabel(chat: { display_title?: string | null; title: string | null;
if (chat.type === "group") return "Group";
return "Channel";
}
function canDeleteForEveryone(
message: { sender_id: number } | undefined,
chat: { type: "private" | "group" | "channel"; is_saved?: boolean } | undefined,
meId: number | undefined
): boolean {
if (!message || !chat || !meId) return false;
if (chat.is_saved) return false;
if (chat.type === "private") return true;
return message.sender_id === meId;
}