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

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