realtime: emit and handle chat_deleted for full chat removals
All checks were successful
CI / test (push) Successful in 38s
All checks were successful
CI / test (push) Successful in 38s
This commit is contained in:
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ RealtimeEventName = Literal[
|
|||||||
"user_online",
|
"user_online",
|
||||||
"user_offline",
|
"user_offline",
|
||||||
"chat_updated",
|
"chat_updated",
|
||||||
|
"chat_deleted",
|
||||||
"pong",
|
"pong",
|
||||||
"error",
|
"error",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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%
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
Reference in New Issue
Block a user