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:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user