diff --git a/app/chats/router.py b/app/chats/router.py index 2bb051f..d0163e7 100644 --- a/app/chats/router.py +++ b/app/chats/router.py @@ -263,7 +263,9 @@ 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)) if chat_before_delete and not chat_before_delete.is_saved: realtime_gateway.remove_chat_subscription(chat_id=chat_id, user_id=current_user.id) - if not delete_for_all: + if delete_for_all: + await realtime_gateway.publish_chat_deleted(chat_id=chat_id) + else: await realtime_gateway.publish_chat_updated(chat_id=chat_id) diff --git a/app/realtime/schemas.py b/app/realtime/schemas.py index 664bc49..10fcfca 100644 --- a/app/realtime/schemas.py +++ b/app/realtime/schemas.py @@ -20,6 +20,7 @@ RealtimeEventName = Literal[ "user_online", "user_offline", "chat_updated", + "chat_deleted", "pong", "error", ] diff --git a/app/realtime/service.py b/app/realtime/service.py index ffda60b..10333bf 100644 --- a/app/realtime/service.py +++ b/app/realtime/service.py @@ -162,6 +162,13 @@ class RealtimeGateway: payload={"chat_id": chat_id}, ) + async def publish_chat_deleted(self, *, chat_id: int) -> None: + await self._publish_chat_event( + chat_id, + event="chat_deleted", + payload={"chat_id": chat_id}, + ) + async def disconnect_user(self, user_id: int, *, code: int = 4401, reason: str = "Session revoked") -> None: user_connections = self._connections.get(user_id, {}) if not user_connections: diff --git a/docs/core-checklist-status.md b/docs/core-checklist-status.md index 67cece9..9f0c0bc 100644 --- a/docs/core-checklist-status.md +++ b/docs/core-checklist-status.md @@ -39,8 +39,8 @@ Legend: 30. Blacklist - `DONE` 31. Privacy - `PARTIAL` (avatar/last-seen/group-invites + PM policy `everyone|contacts|nobody`; policy behavior covered by integration tests, remaining UX/matrix hardening) 32. Security - `PARTIAL` (sessions + revoke + 2FA base + access-session visibility; revoke-all now force-disconnects active realtime sessions; 2FA setup now blocked after enable to prevent secret re-issuance; one-time recovery codes added; UX polish ongoing) -33. Realtime Events - `DONE` (connect/disconnect/send/receive/typing/read/delivered/online/offline + chat/message updates) -34. Sync - `PARTIAL` (cross-device via backend state + realtime; reconciliation improved for loaded chats/messages, chat-info panel hot-refreshes on `chat_updated`, delete/leave now updates realtime subscriptions) +33. Realtime Events - `DONE` (connect/disconnect/send/receive/typing/read/delivered/online/offline + chat/message updates + chat_deleted) +34. Sync - `PARTIAL` (cross-device via backend state + realtime; reconciliation improved for loaded chats/messages, chat-info panel hot-refreshes on `chat_updated`, delete/leave updates realtime subscriptions, full-chat delete emits `chat_deleted`) 35. Additional - `PARTIAL` (drafts/link preview partial/autoload media basic) ## Current Focus to reach ~80% diff --git a/docs/realtime.md b/docs/realtime.md index bd02e8c..fbca941 100644 --- a/docs/realtime.md +++ b/docs/realtime.md @@ -211,7 +211,21 @@ Sent when chat metadata/membership/roles/title changes: } ``` -## 3.8 `error` +## 3.8 `chat_deleted` + +Sent when a chat is removed for all members/subscribers: + +```json +{ + "event": "chat_deleted", + "payload": { + "chat_id": 10 + }, + "timestamp": "2026-03-08T12:00:00Z" +} +``` + +## 3.9 `error` Validation/runtime error during WS processing: @@ -236,6 +250,7 @@ Validation/runtime error during WS processing: - Send periodic `ping` and expect `pong`. - Reconnect with exponential backoff. - On `chat_updated`, refresh chat metadata via REST (`GET /api/v1/chats` or `GET /api/v1/chats/{chat_id}`). +- On `chat_deleted`, remove the chat from local state immediately and clear active-chat selection if needed. - On reconnect/visibility restore, reconcile state by reloading already-opened chats/messages via REST to recover missed `message_deleted`/delivery updates after transient disconnects or backend restarts. - For browser notifications, mentions (`@username`) should be treated as high-priority and can bypass diff --git a/web/src/hooks/useRealtime.ts b/web/src/hooks/useRealtime.ts index 46f79d4..2f184b1 100644 --- a/web/src/hooks/useRealtime.ts +++ b/web/src/hooks/useRealtime.ts @@ -140,6 +140,13 @@ export function useRealtime() { } } } + if (event.event === "chat_deleted") { + const chatId = Number(event.payload.chat_id); + if (Number.isFinite(chatId)) { + chatStore.removeChat(chatId); + scheduleReloadChats(); + } + } if (event.event === "pong") { lastPongAtRef.current = Date.now(); } diff --git a/web/src/store/chatStore.ts b/web/src/store/chatStore.ts index 880c85d..826dc40 100644 --- a/web/src/store/chatStore.ts +++ b/web/src/store/chatStore.ts @@ -97,6 +97,7 @@ interface ChatState { setEditingMessage: (chatId: number, message: Message | null) => void; updateChatPinnedMessage: (chatId: number, pinnedMessageId: number | null) => void; applyPresenceEvent: (chatId: number, userId: number, isOnline: boolean, lastSeenAt?: string) => void; + removeChat: (chatId: number) => void; setDraft: (chatId: number, text: string) => void; clearDraft: (chatId: number) => void; setFocusedMessage: (chatId: number, messageId: number | null) => void; @@ -431,6 +432,42 @@ export const useChatStore = create((set, get) => ({ return chat; }) })), + removeChat: (chatId) => + set((state) => { + const nextMessagesByChat = { ...state.messagesByChat }; + const nextHasMoreByChat = { ...state.hasMoreByChat }; + const nextLoadingMoreByChat = { ...state.loadingMoreByChat }; + const nextTypingByChat = { ...state.typingByChat }; + const nextReplyToByChat = { ...state.replyToByChat }; + const nextEditingByChat = { ...state.editingByChat }; + const nextUnreadBoundaryByChat = { ...state.unreadBoundaryByChat }; + const nextFocusedMessageByChat = { ...state.focusedMessageIdByChat }; + const nextDraftsByChat = { ...state.draftsByChat }; + delete nextMessagesByChat[chatId]; + delete nextHasMoreByChat[chatId]; + delete nextLoadingMoreByChat[chatId]; + delete nextTypingByChat[chatId]; + delete nextReplyToByChat[chatId]; + delete nextEditingByChat[chatId]; + delete nextUnreadBoundaryByChat[chatId]; + delete nextFocusedMessageByChat[chatId]; + delete nextDraftsByChat[chatId]; + saveDraftsToStorage(nextDraftsByChat); + const nextChats = state.chats.filter((chat) => chat.id !== chatId); + return { + chats: nextChats, + activeChatId: state.activeChatId === chatId ? (nextChats[0]?.id ?? null) : state.activeChatId, + messagesByChat: nextMessagesByChat, + hasMoreByChat: nextHasMoreByChat, + loadingMoreByChat: nextLoadingMoreByChat, + typingByChat: nextTypingByChat, + replyToByChat: nextReplyToByChat, + editingByChat: nextEditingByChat, + unreadBoundaryByChat: nextUnreadBoundaryByChat, + focusedMessageIdByChat: nextFocusedMessageByChat, + draftsByChat: nextDraftsByChat, + }; + }), setDraft: (chatId, text) => set((state) => { const nextDrafts = {