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

@@ -150,6 +150,14 @@ export async function deleteChat(chatId: number, forAll: boolean): Promise<void>
await http.delete(`/chats/${chatId}`, { params: { for_all: forAll } });
}
export async function clearChat(chatId: number): Promise<void> {
await http.post(`/chats/${chatId}/clear`);
}
export async function deleteMessage(messageId: number, forAll = false): Promise<void> {
await http.delete(`/messages/${messageId}`, { params: { for_all: forAll } });
}
export async function discoverChats(query?: string): Promise<DiscoverChat[]> {
const { data } = await http.get<DiscoverChat[]>("/chats/discover", {
params: query?.trim() ? { query: query.trim() } : undefined

View 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;
}
}

View File

@@ -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

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;
}

View File

@@ -12,14 +12,12 @@ interface RealtimeEnvelope {
export function useRealtime() {
const accessToken = useAuthStore((s) => s.accessToken);
const me = useAuthStore((s) => s.me);
const prependMessage = useChatStore((s) => s.prependMessage);
const confirmMessageByClientId = useChatStore((s) => s.confirmMessageByClientId);
const setMessageDeliveryStatus = useChatStore((s) => s.setMessageDeliveryStatus);
const loadChats = useChatStore((s) => s.loadChats);
const chats = useChatStore((s) => s.chats);
const activeChatId = useChatStore((s) => s.activeChatId);
const meId = useAuthStore((s) => s.me?.id ?? null);
const typingByChat = useRef<Record<number, Set<number>>>({});
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimeoutRef = useRef<number | null>(null);
const reconnectAttemptsRef = useRef(0);
const manualCloseRef = useRef(false);
const wsUrl = useMemo(() => {
return accessToken ? buildWsUrl(accessToken) : null;
@@ -27,69 +25,129 @@ export function useRealtime() {
useEffect(() => {
if (!wsUrl) {
manualCloseRef.current = true;
if (reconnectTimeoutRef.current !== null) {
window.clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null;
}
wsRef.current?.close();
wsRef.current = null;
return;
}
const ws = new WebSocket(wsUrl);
manualCloseRef.current = false;
ws.onmessage = (messageEvent) => {
const event: RealtimeEnvelope = JSON.parse(messageEvent.data);
if (event.event === "receive_message") {
const chatId = Number(event.payload.chat_id);
const message = event.payload.message as Message;
const clientMessageId = event.payload.client_message_id as string | undefined;
if (clientMessageId && message.sender_id === me?.id) {
confirmMessageByClientId(chatId, clientMessageId, message);
} else {
prependMessage(chatId, message);
}
if (message.sender_id !== me?.id) {
ws.send(JSON.stringify({ event: "message_delivered", payload: { chat_id: chatId, message_id: message.id } }));
if (chatId === activeChatId) {
ws.send(JSON.stringify({ event: "message_read", payload: { chat_id: chatId, message_id: message.id } }));
}
}
if (!chats.some((chat) => chat.id === chatId)) {
void loadChats();
}
}
if (event.event === "typing_start") {
const chatId = Number(event.payload.chat_id);
const userId = Number(event.payload.user_id);
if (userId === me?.id) {
const connect = () => {
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
ws.onopen = () => {
reconnectAttemptsRef.current = 0;
};
ws.onmessage = (messageEvent) => {
let event: RealtimeEnvelope;
try {
event = JSON.parse(messageEvent.data) as RealtimeEnvelope;
} catch {
return;
}
if (!typingByChat.current[chatId]) {
typingByChat.current[chatId] = new Set<number>();
const chatStore = useChatStore.getState();
const authStore = useAuthStore.getState();
if (event.event === "receive_message") {
const chatId = Number(event.payload.chat_id);
const message = event.payload.message as Message;
const clientMessageId = event.payload.client_message_id as string | undefined;
if (!Number.isFinite(chatId) || !message?.id) {
return;
}
if (clientMessageId && message.sender_id === authStore.me?.id) {
chatStore.confirmMessageByClientId(chatId, clientMessageId, message);
} else {
chatStore.prependMessage(chatId, message);
}
if (message.sender_id !== authStore.me?.id) {
ws.send(JSON.stringify({ event: "message_delivered", payload: { chat_id: chatId, message_id: message.id } }));
if (chatId === chatStore.activeChatId) {
ws.send(JSON.stringify({ event: "message_read", payload: { chat_id: chatId, message_id: message.id } }));
}
}
if (!chatStore.chats.some((chat) => chat.id === chatId)) {
void chatStore.loadChats();
}
}
typingByChat.current[chatId].add(userId);
useChatStore.getState().setTypingUsers(chatId, [...typingByChat.current[chatId]]);
}
if (event.event === "typing_stop") {
const chatId = Number(event.payload.chat_id);
const userId = Number(event.payload.user_id);
typingByChat.current[chatId]?.delete(userId);
useChatStore.getState().setTypingUsers(chatId, [...(typingByChat.current[chatId] ?? [])]);
}
if (event.event === "message_delivered") {
const chatId = Number(event.payload.chat_id);
const messageId = Number(event.payload.message_id);
const userId = Number(event.payload.user_id);
if (userId !== me?.id) {
setMessageDeliveryStatus(chatId, messageId, "delivered");
if (event.event === "typing_start") {
const chatId = Number(event.payload.chat_id);
const userId = Number(event.payload.user_id);
if (!Number.isFinite(chatId) || !Number.isFinite(userId) || userId === authStore.me?.id) {
return;
}
if (!typingByChat.current[chatId]) {
typingByChat.current[chatId] = new Set<number>();
}
typingByChat.current[chatId].add(userId);
chatStore.setTypingUsers(chatId, [...typingByChat.current[chatId]]);
}
}
if (event.event === "message_read") {
const chatId = Number(event.payload.chat_id);
const messageId = Number(event.payload.message_id);
const userId = Number(event.payload.user_id);
if (userId !== me?.id) {
setMessageDeliveryStatus(chatId, messageId, "read");
if (event.event === "typing_stop") {
const chatId = Number(event.payload.chat_id);
const userId = Number(event.payload.user_id);
if (!Number.isFinite(chatId) || !Number.isFinite(userId)) {
return;
}
typingByChat.current[chatId]?.delete(userId);
chatStore.setTypingUsers(chatId, [...(typingByChat.current[chatId] ?? [])]);
}
}
if (event.event === "message_delivered") {
const chatId = Number(event.payload.chat_id);
const messageId = Number(event.payload.message_id);
const userId = Number(event.payload.user_id);
if (!Number.isFinite(chatId) || !Number.isFinite(messageId) || !Number.isFinite(userId)) {
return;
}
if (userId !== authStore.me?.id) {
chatStore.setMessageDeliveryStatus(chatId, messageId, "delivered");
}
}
if (event.event === "message_read") {
const chatId = Number(event.payload.chat_id);
const messageId = Number(event.payload.message_id);
const userId = Number(event.payload.user_id);
if (!Number.isFinite(chatId) || !Number.isFinite(messageId) || !Number.isFinite(userId)) {
return;
}
if (userId !== authStore.me?.id) {
chatStore.setMessageDeliveryStatus(chatId, messageId, "read");
}
}
};
ws.onclose = () => {
if (manualCloseRef.current) {
return;
}
reconnectAttemptsRef.current += 1;
const delay = Math.min(15000, 1000 * 2 ** Math.min(reconnectAttemptsRef.current, 4));
reconnectTimeoutRef.current = window.setTimeout(connect, delay);
};
ws.onerror = () => {
ws.close();
};
};
return () => ws.close();
}, [wsUrl, prependMessage, confirmMessageByClientId, setMessageDeliveryStatus, loadChats, chats, me?.id, activeChatId]);
connect();
return () => {
manualCloseRef.current = true;
if (reconnectTimeoutRef.current !== null) {
window.clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null;
}
wsRef.current?.close();
wsRef.current = null;
typingByChat.current = {};
useChatStore.setState({ typingByChat: {} });
};
}, [wsUrl, meId]);
return null;
}

View File

@@ -1,10 +1,13 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { App } from "./app/App";
import { AppErrorBoundary } from "./components/AppErrorBoundary";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
<AppErrorBoundary>
<App />
</AppErrorBoundary>
</React.StrictMode>
);

View File

@@ -22,6 +22,8 @@ interface ChatState {
confirmMessageByClientId: (chatId: number, clientMessageId: string, message: Message) => void;
removeOptimisticMessage: (chatId: number, clientMessageId: string) => void;
setMessageDeliveryStatus: (chatId: number, messageId: number, status: DeliveryStatus) => void;
removeMessage: (chatId: number, messageId: number) => void;
clearChatMessages: (chatId: number) => void;
setTypingUsers: (chatId: number, userIds: number[]) => void;
setReplyToMessage: (chatId: number, message: Message | null) => void;
updateChatPinnedMessage: (chatId: number, pinnedMessageId: number | null) => void;
@@ -137,6 +139,22 @@ export const useChatStore = create<ChatState>((set, get) => ({
messagesByChat: { ...state.messagesByChat, [chatId]: next }
}));
},
removeMessage: (chatId, messageId) => {
const old = get().messagesByChat[chatId] ?? [];
set((state) => ({
messagesByChat: {
...state.messagesByChat,
[chatId]: old.filter((m) => m.id !== messageId)
}
}));
},
clearChatMessages: (chatId) =>
set((state) => ({
messagesByChat: {
...state.messagesByChat,
[chatId]: []
}
})),
setTypingUsers: (chatId, userIds) =>
set((state) => ({ typingByChat: { ...state.typingByChat, [chatId]: userIds } })),
setReplyToMessage: (chatId, message) =>