realtime: emit and handle chat_deleted for full chat removals
All checks were successful
CI / test (push) Successful in 38s

This commit is contained in:
2026-03-08 19:41:49 +03:00
parent a896568c53
commit 744ded914d
7 changed files with 73 additions and 4 deletions

View File

@@ -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)) 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: 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) 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) await realtime_gateway.publish_chat_updated(chat_id=chat_id)

View File

@@ -20,6 +20,7 @@ RealtimeEventName = Literal[
"user_online", "user_online",
"user_offline", "user_offline",
"chat_updated", "chat_updated",
"chat_deleted",
"pong", "pong",
"error", "error",
] ]

View File

@@ -162,6 +162,13 @@ class RealtimeGateway:
payload={"chat_id": chat_id}, 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: async def disconnect_user(self, user_id: int, *, code: int = 4401, reason: str = "Session revoked") -> None:
user_connections = self._connections.get(user_id, {}) user_connections = self._connections.get(user_id, {})
if not user_connections: if not user_connections:

View File

@@ -39,8 +39,8 @@ Legend:
30. Blacklist - `DONE` 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) 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) 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) 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 now updates realtime subscriptions) 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) 35. Additional - `PARTIAL` (drafts/link preview partial/autoload media basic)
## Current Focus to reach ~80% ## Current Focus to reach ~80%

View File

@@ -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: Validation/runtime error during WS processing:
@@ -236,6 +250,7 @@ Validation/runtime error during WS processing:
- Send periodic `ping` and expect `pong`. - Send periodic `ping` and expect `pong`.
- Reconnect with exponential backoff. - 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_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 - 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. 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 - For browser notifications, mentions (`@username`) should be treated as high-priority and can bypass

View File

@@ -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") { if (event.event === "pong") {
lastPongAtRef.current = Date.now(); lastPongAtRef.current = Date.now();
} }

View File

@@ -97,6 +97,7 @@ interface ChatState {
setEditingMessage: (chatId: number, message: Message | null) => void; setEditingMessage: (chatId: number, message: Message | null) => void;
updateChatPinnedMessage: (chatId: number, pinnedMessageId: number | null) => void; updateChatPinnedMessage: (chatId: number, pinnedMessageId: number | null) => void;
applyPresenceEvent: (chatId: number, userId: number, isOnline: boolean, lastSeenAt?: string) => void; applyPresenceEvent: (chatId: number, userId: number, isOnline: boolean, lastSeenAt?: string) => void;
removeChat: (chatId: number) => void;
setDraft: (chatId: number, text: string) => void; setDraft: (chatId: number, text: string) => void;
clearDraft: (chatId: number) => void; clearDraft: (chatId: number) => void;
setFocusedMessage: (chatId: number, messageId: number | null) => void; setFocusedMessage: (chatId: number, messageId: number | null) => void;
@@ -431,6 +432,42 @@ export const useChatStore = create<ChatState>((set, get) => ({
return chat; 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) => setDraft: (chatId, text) =>
set((state) => { set((state) => {
const nextDrafts = { const nextDrafts = {