feat(core): clear saved chat and add message deletion scopes
Some checks failed
CI / test (push) Failing after 26s
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:
44
web/src/components/AppErrorBoundary.tsx
Normal file
44
web/src/components/AppErrorBoundary.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Component, type ErrorInfo, type ReactNode } from "react";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
}
|
||||
|
||||
export class AppErrorBoundary extends Component<Props, State> {
|
||||
state: State = { hasError: false };
|
||||
|
||||
static getDerivedStateFromError(): State {
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo): void {
|
||||
console.error("UI crash captured by AppErrorBoundary", error, info);
|
||||
}
|
||||
|
||||
render(): ReactNode {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center px-4">
|
||||
<div className="w-full max-w-md rounded-2xl border border-slate-700/80 bg-slate-900/95 p-5 text-slate-100 shadow-2xl">
|
||||
<p className="text-base font-semibold">Something went wrong</p>
|
||||
<p className="mt-2 text-sm text-slate-300">
|
||||
The app hit an unexpected UI error. Reload to continue.
|
||||
</p>
|
||||
<button
|
||||
className="mt-4 w-full rounded bg-sky-500 px-3 py-2 text-sm font-semibold text-slate-950 hover:bg-sky-400"
|
||||
onClick={() => window.location.reload()}
|
||||
type="button"
|
||||
>
|
||||
Reload
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { deleteChat } from "../api/chats";
|
||||
import { clearChat, deleteChat } from "../api/chats";
|
||||
import { updateMyProfile } from "../api/users";
|
||||
import { useAuthStore } from "../store/authStore";
|
||||
import { useChatStore } from "../store/chatStore";
|
||||
@@ -27,7 +27,11 @@ export function ChatList() {
|
||||
const [profileError, setProfileError] = useState<string | null>(null);
|
||||
const [profileSaving, setProfileSaving] = useState(false);
|
||||
const deleteModalChat = chats.find((chat) => chat.id === deleteModalChatId) ?? null;
|
||||
const canDeleteForEveryone = Boolean(deleteModalChat && !deleteModalChat.is_saved && deleteModalChat.type === "group");
|
||||
const canDeleteForEveryone = Boolean(
|
||||
deleteModalChat &&
|
||||
!deleteModalChat.is_saved &&
|
||||
(deleteModalChat.type === "group" || deleteModalChat.type === "private")
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
@@ -36,6 +40,20 @@ export function ChatList() {
|
||||
return () => clearTimeout(timer);
|
||||
}, [search, loadChats]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Escape") {
|
||||
return;
|
||||
}
|
||||
setCtxChatId(null);
|
||||
setCtxPos(null);
|
||||
setDeleteModalChatId(null);
|
||||
setProfileOpen(false);
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!me) {
|
||||
return;
|
||||
@@ -141,7 +159,7 @@ export function ChatList() {
|
||||
setDeleteForAll(false);
|
||||
}}
|
||||
>
|
||||
Delete chat
|
||||
{chats.find((c) => c.id === ctxChatId)?.is_saved ? "Clear chat" : "Delete chat"}
|
||||
</button>
|
||||
</div>,
|
||||
document.body
|
||||
@@ -151,10 +169,15 @@ export function ChatList() {
|
||||
{deleteModalChatId ? (
|
||||
<div className="absolute inset-0 z-50 flex items-end justify-center bg-slate-950/55 p-3">
|
||||
<div className="w-full rounded-xl border border-slate-700/80 bg-slate-900 p-3">
|
||||
<p className="mb-2 text-sm font-semibold">Delete chat: {deleteModalChat ? chatLabel(deleteModalChat) : "selected chat"}</p>
|
||||
<p className="mb-2 text-sm font-semibold">
|
||||
{deleteModalChat?.is_saved ? "Clear chat" : "Delete chat"}: {deleteModalChat ? chatLabel(deleteModalChat) : "selected chat"}
|
||||
</p>
|
||||
{deleteModalChat?.type === "channel" ? (
|
||||
<p className="mb-3 text-xs text-slate-400">Channels are removed for all subscribers.</p>
|
||||
) : null}
|
||||
{deleteModalChat?.is_saved ? (
|
||||
<p className="mb-3 text-xs text-slate-400">This will clear all messages in Saved Messages.</p>
|
||||
) : null}
|
||||
{canDeleteForEveryone ? (
|
||||
<label className="mb-3 flex items-center gap-2 text-sm">
|
||||
<input checked={deleteForAll} onChange={(e) => setDeleteForAll(e.target.checked)} type="checkbox" />
|
||||
@@ -165,6 +188,12 @@ export function ChatList() {
|
||||
<button
|
||||
className="flex-1 rounded bg-red-500 px-3 py-2 text-sm font-semibold text-white"
|
||||
onClick={async () => {
|
||||
if (deleteModalChat?.is_saved) {
|
||||
await clearChat(deleteModalChatId);
|
||||
useChatStore.getState().clearChatMessages(deleteModalChatId);
|
||||
setDeleteModalChatId(null);
|
||||
return;
|
||||
}
|
||||
await deleteChat(deleteModalChatId, canDeleteForEveryone && deleteForAll);
|
||||
await loadChats(search.trim() ? search : undefined);
|
||||
if (activeChatId === deleteModalChatId) {
|
||||
@@ -173,7 +202,7 @@ export function ChatList() {
|
||||
setDeleteModalChatId(null);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
{deleteModalChat?.is_saved ? "Clear" : "Delete"}
|
||||
</button>
|
||||
<button className="flex-1 rounded bg-slate-700 px-3 py-2 text-sm" onClick={() => setDeleteModalChatId(null)}>
|
||||
Cancel
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user