From 7f15edcb4e21f9bae8342cc1037914cf3fb65bb0 Mon Sep 17 00:00:00 2001 From: benya Date: Sun, 8 Mar 2026 01:13:20 +0300 Subject: [PATCH] feat(core): clear saved chat and add message deletion scopes 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 --- alembic/versions/0007_message_hidden_table.py | 41 ++++ app/chats/router.py | 10 + app/chats/service.py | 26 ++- app/database/models.py | 2 +- app/messages/models.py | 10 + app/messages/repository.py | 42 ++++- app/messages/router.py | 14 +- app/messages/service.py | 48 ++++- web/src/api/chats.ts | 8 + web/src/components/AppErrorBoundary.tsx | 44 +++++ web/src/components/ChatList.tsx | 39 +++- web/src/components/MessageList.tsx | 78 +++++++- web/src/hooks/useRealtime.ts | 178 ++++++++++++------ web/src/main.tsx | 5 +- web/src/store/chatStore.ts | 18 ++ 15 files changed, 486 insertions(+), 77 deletions(-) create mode 100644 alembic/versions/0007_message_hidden_table.py create mode 100644 web/src/components/AppErrorBoundary.tsx diff --git a/alembic/versions/0007_message_hidden_table.py b/alembic/versions/0007_message_hidden_table.py new file mode 100644 index 0000000..2c8e09a --- /dev/null +++ b/alembic/versions/0007_message_hidden_table.py @@ -0,0 +1,41 @@ +"""add message hidden table for per-user delete + +Revision ID: 0007_message_hidden_table +Revises: 0006_user_name_bio_profile +Create Date: 2026-03-08 03:35:00.000000 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "0007_message_hidden_table" +down_revision: Union[str, Sequence[str], None] = "0006_user_name_bio_profile" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "message_hidden", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("message_id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.ForeignKeyConstraint(["message_id"], ["messages.id"], name=op.f("fk_message_hidden_message_id_messages"), ondelete="CASCADE"), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], name=op.f("fk_message_hidden_user_id_users"), ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id", name=op.f("pk_message_hidden")), + sa.UniqueConstraint("message_id", "user_id", name="uq_message_hidden_message_user"), + ) + op.create_index(op.f("ix_message_hidden_id"), "message_hidden", ["id"], unique=False) + op.create_index(op.f("ix_message_hidden_message_id"), "message_hidden", ["message_id"], unique=False) + op.create_index(op.f("ix_message_hidden_user_id"), "message_hidden", ["user_id"], unique=False) + + +def downgrade() -> None: + op.drop_index(op.f("ix_message_hidden_user_id"), table_name="message_hidden") + op.drop_index(op.f("ix_message_hidden_message_id"), table_name="message_hidden") + op.drop_index(op.f("ix_message_hidden_id"), table_name="message_hidden") + op.drop_table("message_hidden") diff --git a/app/chats/router.py b/app/chats/router.py index eed41c6..ec75edb 100644 --- a/app/chats/router.py +++ b/app/chats/router.py @@ -17,6 +17,7 @@ from app.chats.schemas import ( from app.chats.service import ( add_chat_member_for_user, create_chat_for_user, + clear_chat_for_user, delete_chat_for_user, discover_public_chats_for_user, ensure_saved_messages_chat, @@ -182,6 +183,15 @@ async def delete_chat( await delete_chat_for_user(db, chat_id=chat_id, user_id=current_user.id, payload=ChatDeleteRequest(for_all=for_all)) +@router.post("/{chat_id}/clear", status_code=status.HTTP_204_NO_CONTENT) +async def clear_chat( + chat_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> None: + await clear_chat_for_user(db, chat_id=chat_id, user_id=current_user.id) + + @router.post("/{chat_id}/pin", response_model=ChatRead) async def pin_chat_message( chat_id: int, diff --git a/app/chats/service.py b/app/chats/service.py index 8225f71..627d02a 100644 --- a/app/chats/service.py +++ b/app/chats/service.py @@ -5,7 +5,13 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.chats import repository from app.chats.models import Chat, ChatMember, ChatMemberRole, ChatType from app.chats.schemas import ChatCreateRequest, ChatDeleteRequest, ChatDiscoverRead, ChatPinMessageRequest, ChatRead, ChatTitleUpdateRequest -from app.messages.repository import get_message_by_id +from app.messages.repository import ( + delete_messages_in_chat, + get_hidden_message, + get_message_by_id, + hide_message_for_user, + list_chat_message_ids, +) from app.users.repository import get_user_by_id @@ -349,6 +355,9 @@ async def join_public_chat_for_user(db: AsyncSession, *, chat_id: int, user_id: async def delete_chat_for_user(db: AsyncSession, *, chat_id: int, user_id: int, payload: ChatDeleteRequest) -> None: chat, membership = await _get_chat_and_membership(db, chat_id=chat_id, user_id=user_id) + if chat.is_saved: + await clear_chat_for_user(db, chat_id=chat_id, user_id=user_id) + return delete_for_all = (payload.for_all and not chat.is_saved) or chat.type == ChatType.CHANNEL if delete_for_all: if chat.type in {ChatType.GROUP, ChatType.CHANNEL} and membership.role not in {ChatMemberRole.OWNER, ChatMemberRole.ADMIN}: @@ -358,3 +367,18 @@ async def delete_chat_for_user(db: AsyncSession, *, chat_id: int, user_id: int, return await repository.delete_chat_member(db, membership) await db.commit() + + +async def clear_chat_for_user(db: AsyncSession, *, chat_id: int, user_id: int) -> None: + chat, _membership = await _get_chat_and_membership(db, chat_id=chat_id, user_id=user_id) + if chat.is_saved: + await delete_messages_in_chat(db, chat_id=chat_id) + await db.commit() + return + message_ids = await list_chat_message_ids(db, chat_id=chat_id) + for message_id in message_ids: + already_hidden = await get_hidden_message(db, message_id=message_id, user_id=user_id) + if already_hidden: + continue + await hide_message_for_user(db, message_id=message_id, user_id=user_id) + await db.commit() diff --git a/app/database/models.py b/app/database/models.py index a385adc..ea9403d 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -2,7 +2,7 @@ from app.auth.models import EmailVerificationToken, PasswordResetToken from app.chats.models import Chat, ChatMember from app.email.models import EmailLog from app.media.models import Attachment -from app.messages.models import Message, MessageIdempotencyKey, MessageReceipt +from app.messages.models import Message, MessageHidden, MessageIdempotencyKey, MessageReceipt from app.notifications.models import NotificationLog from app.users.models import User diff --git a/app/messages/models.py b/app/messages/models.py index 62ab673..5438d88 100644 --- a/app/messages/models.py +++ b/app/messages/models.py @@ -83,3 +83,13 @@ class MessageReceipt(Base): onupdate=func.now(), nullable=False, ) + + +class MessageHidden(Base): + __tablename__ = "message_hidden" + __table_args__ = (UniqueConstraint("message_id", "user_id", name="uq_message_hidden_message_user"),) + + id: Mapped[int] = mapped_column(primary_key=True, index=True) + message_id: Mapped[int] = mapped_column(ForeignKey("messages.id", ondelete="CASCADE"), nullable=False, index=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) diff --git a/app/messages/repository.py b/app/messages/repository.py index 472cb97..e7c4ab9 100644 --- a/app/messages/repository.py +++ b/app/messages/repository.py @@ -1,8 +1,8 @@ -from sqlalchemy import select +from sqlalchemy import delete, select from sqlalchemy.ext.asyncio import AsyncSession from app.chats.models import ChatMember -from app.messages.models import Message, MessageIdempotencyKey, MessageReceipt, MessageType +from app.messages.models import Message, MessageHidden, MessageIdempotencyKey, MessageReceipt, MessageType async def create_message( @@ -76,10 +76,18 @@ async def list_chat_messages( db: AsyncSession, chat_id: int, *, + user_id: int, limit: int = 50, before_id: int | None = None, ) -> list[Message]: - query = select(Message).where(Message.chat_id == chat_id) + query = ( + select(Message) + .outerjoin( + MessageHidden, + (MessageHidden.message_id == Message.id) & (MessageHidden.user_id == user_id), + ) + .where(Message.chat_id == chat_id, MessageHidden.id.is_(None)) + ) if before_id is not None: query = query.where(Message.id < before_id) result = await db.execute(query.order_by(Message.id.desc()).limit(limit)) @@ -97,10 +105,15 @@ async def search_messages( stmt = ( select(Message) .join(ChatMember, ChatMember.chat_id == Message.chat_id) + .outerjoin( + MessageHidden, + (MessageHidden.message_id == Message.id) & (MessageHidden.user_id == user_id), + ) .where( ChatMember.user_id == user_id, Message.text.is_not(None), Message.text.ilike(f"%{query.strip()}%"), + MessageHidden.id.is_(None), ) .order_by(Message.id.desc()) .limit(limit) @@ -115,6 +128,29 @@ async def delete_message(db: AsyncSession, message: Message) -> None: await db.delete(message) +async def hide_message_for_user(db: AsyncSession, *, message_id: int, user_id: int) -> MessageHidden: + hidden = MessageHidden(message_id=message_id, user_id=user_id) + db.add(hidden) + await db.flush() + return hidden + + +async def get_hidden_message(db: AsyncSession, *, message_id: int, user_id: int) -> MessageHidden | None: + result = await db.execute( + select(MessageHidden).where(MessageHidden.message_id == message_id, MessageHidden.user_id == user_id).limit(1) + ) + return result.scalar_one_or_none() + + +async def list_chat_message_ids(db: AsyncSession, *, chat_id: int) -> list[int]: + result = await db.execute(select(Message.id).where(Message.chat_id == chat_id)) + return list(result.scalars().all()) + + +async def delete_messages_in_chat(db: AsyncSession, *, chat_id: int) -> None: + await db.execute(delete(Message).where(Message.chat_id == chat_id)) + + async def get_message_receipt(db: AsyncSession, *, chat_id: int, user_id: int) -> MessageReceipt | None: result = await db.execute( select(MessageReceipt).where( diff --git a/app/messages/router.py b/app/messages/router.py index 8382c9c..341a2dc 100644 --- a/app/messages/router.py +++ b/app/messages/router.py @@ -4,7 +4,15 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.auth.service import get_current_user from app.database.session import get_db from app.messages.schemas import MessageCreateRequest, MessageForwardRequest, MessageRead, MessageStatusUpdateRequest, MessageUpdateRequest -from app.messages.service import create_chat_message, delete_message, forward_message, get_messages, search_messages, update_message +from app.messages.service import ( + create_chat_message, + delete_message, + delete_message_for_all, + forward_message, + get_messages, + search_messages, + update_message, +) from app.realtime.schemas import MessageStatusPayload from app.realtime.service import realtime_gateway from app.users.models import User @@ -62,9 +70,13 @@ async def edit_message( @router.delete("/{message_id}", status_code=status.HTTP_204_NO_CONTENT) async def remove_message( message_id: int, + for_all: bool = False, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ) -> None: + if for_all: + await delete_message_for_all(db, message_id=message_id, user_id=current_user.id) + return await delete_message(db, message_id=message_id, user_id=current_user.id) diff --git a/app/messages/service.py b/app/messages/service.py index 0bec0d0..bc21693 100644 --- a/app/messages/service.py +++ b/app/messages/service.py @@ -2,6 +2,8 @@ from fastapi import HTTPException, status from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession +from app.chats import repository as chats_repository +from app.chats.models import ChatMemberRole, ChatType from app.chats.service import ensure_chat_membership from app.messages import repository from app.messages.models import Message @@ -79,7 +81,7 @@ async def get_messages( ) -> list[Message]: await ensure_chat_membership(db, chat_id=chat_id, user_id=user_id) safe_limit = max(1, min(limit, 100)) - return await repository.list_chat_messages(db, chat_id, limit=safe_limit, before_id=before_id) + return await repository.list_chat_messages(db, chat_id, user_id=user_id, limit=safe_limit, before_id=before_id) async def search_messages( @@ -129,8 +131,48 @@ async def delete_message(db: AsyncSession, *, message_id: int, user_id: int) -> if not message: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Message not found") await ensure_chat_membership(db, chat_id=message.chat_id, user_id=user_id) - if message.sender_id != user_id: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You can delete only your own messages") + chat = await chats_repository.get_chat_by_id(db, message.chat_id) + if not chat: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Chat not found") + membership = await chats_repository.get_chat_member(db, chat_id=message.chat_id, user_id=user_id) + if not membership: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You are not a member of this chat") + # Telegram-like default: delete only for current user. + hidden = await repository.get_hidden_message(db, message_id=message.id, user_id=user_id) + if not hidden: + try: + await repository.hide_message_for_user(db, message_id=message.id, user_id=user_id) + except IntegrityError: + await db.rollback() + return + await db.commit() + + +async def delete_message_for_all(db: AsyncSession, *, message_id: int, user_id: int) -> None: + message = await repository.get_message_by_id(db, message_id) + if not message: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Message not found") + await ensure_chat_membership(db, chat_id=message.chat_id, user_id=user_id) + chat = await chats_repository.get_chat_by_id(db, message.chat_id) + if not chat: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Chat not found") + membership = await chats_repository.get_chat_member(db, chat_id=message.chat_id, user_id=user_id) + if not membership: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You are not a member of this chat") + if chat.is_saved: + await delete_message(db, message_id=message_id, user_id=user_id) + return + + can_delete_for_all = False + if chat.type == ChatType.PRIVATE: + can_delete_for_all = True + elif message.sender_id == user_id: + can_delete_for_all = True + elif chat.type in {ChatType.GROUP, ChatType.CHANNEL} and membership.role in {ChatMemberRole.OWNER, ChatMemberRole.ADMIN}: + can_delete_for_all = True + + if not can_delete_for_all: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions for delete-for-all") await repository.delete_message(db, message) await db.commit() diff --git a/web/src/api/chats.ts b/web/src/api/chats.ts index fc7085a..bc73706 100644 --- a/web/src/api/chats.ts +++ b/web/src/api/chats.ts @@ -150,6 +150,14 @@ export async function deleteChat(chatId: number, forAll: boolean): Promise await http.delete(`/chats/${chatId}`, { params: { for_all: forAll } }); } +export async function clearChat(chatId: number): Promise { + await http.post(`/chats/${chatId}/clear`); +} + +export async function deleteMessage(messageId: number, forAll = false): Promise { + await http.delete(`/messages/${messageId}`, { params: { for_all: forAll } }); +} + export async function discoverChats(query?: string): Promise { const { data } = await http.get("/chats/discover", { params: query?.trim() ? { query: query.trim() } : undefined diff --git a/web/src/components/AppErrorBoundary.tsx b/web/src/components/AppErrorBoundary.tsx new file mode 100644 index 0000000..5d6761c --- /dev/null +++ b/web/src/components/AppErrorBoundary.tsx @@ -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 { + 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 ( +
+
+

Something went wrong

+

+ The app hit an unexpected UI error. Reload to continue. +

+ +
+
+ ); + } + return this.props.children; + } +} diff --git a/web/src/components/ChatList.tsx b/web/src/components/ChatList.tsx index ba4d53e..2c01e5d 100644 --- a/web/src/components/ChatList.tsx +++ b/web/src/components/ChatList.tsx @@ -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(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"} , document.body @@ -151,10 +169,15 @@ export function ChatList() { {deleteModalChatId ? (
-

Delete chat: {deleteModalChat ? chatLabel(deleteModalChat) : "selected chat"}

+

+ {deleteModalChat?.is_saved ? "Clear chat" : "Delete chat"}: {deleteModalChat ? chatLabel(deleteModalChat) : "selected chat"} +

{deleteModalChat?.type === "channel" ? (

Channels are removed for all subscribers.

) : null} + {deleteModalChat?.is_saved ? ( +

This will clear all messages in Saved Messages.

+ ) : null} {canDeleteForEveryone ? (
) : null} + + {deleteMessageId ? ( +
setDeleteMessageId(null)}> +
e.stopPropagation()}> +

Delete message

+

Choose how to delete this message.

+
+ + {canDeleteForEveryone(messagesMap.get(deleteMessageId), activeChat, me?.id) ? ( + + ) : null} + +
+ {deleteError ?

{deleteError}

: null} +
+
+ ) : null} ); } @@ -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; +} diff --git a/web/src/hooks/useRealtime.ts b/web/src/hooks/useRealtime.ts index 315795e..a28fa7c 100644 --- a/web/src/hooks/useRealtime.ts +++ b/web/src/hooks/useRealtime.ts @@ -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>>({}); + const wsRef = useRef(null); + const reconnectTimeoutRef = useRef(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(); + 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(); + } + 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; } diff --git a/web/src/main.tsx b/web/src/main.tsx index 73eb82a..ccfc976 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -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( - + + + ); diff --git a/web/src/store/chatStore.ts b/web/src/store/chatStore.ts index c25d861..6060c20 100644 --- a/web/src/store/chatStore.ts +++ b/web/src/store/chatStore.ts @@ -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((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) =>