From 66141197ade0209d0330946de39379a80d9067d3 Mon Sep 17 00:00:00 2001 From: lizerk Date: Fri, 10 Apr 2026 20:54:09 +0300 Subject: [PATCH] Optimize messages list refresh and chat serialization --- backend/communications/api/serializers.py | 104 +++++++++---- frontend/src/app/messages/page.tsx | 170 ++++++++++++++-------- 2 files changed, 186 insertions(+), 88 deletions(-) diff --git a/backend/communications/api/serializers.py b/backend/communications/api/serializers.py index 2e773110..5d497d4b 100644 --- a/backend/communications/api/serializers.py +++ b/backend/communications/api/serializers.py @@ -22,6 +22,33 @@ from ..serialization import serialize_message User = get_user_model() +def _get_active_memberships(obj, *, limit: int | None = None): + if hasattr(obj, "_prefetched_objects_cache") and "memberships" in obj._prefetched_objects_cache: + memberships = list(obj.memberships.all()) + else: + memberships = list( + obj.memberships.filter(is_active=True).select_related("user") + ) + + if limit is not None: + memberships = memberships[:limit] + + return memberships + + +def _get_last_message(obj): + if hasattr(obj, "_prefetched_last_message"): + prefetched = obj._prefetched_last_message + return prefetched[0] if prefetched else None + + return ( + obj.messages.filter(is_deleted=False) + .select_related("author") + .order_by("-created_at") + .first() + ) + + class ChatLastMessageSerializer(serializers.Serializer): id = serializers.IntegerField() content = serializers.CharField() @@ -82,6 +109,7 @@ class ChatListSerializer(serializers.ModelSerializer): last_message = serializers.SerializerMethodField() unread_count = serializers.SerializerMethodField() + participant_details = serializers.SerializerMethodField() participant_names = serializers.SerializerMethodField() member_ids = serializers.SerializerMethodField() is_pinned = serializers.SerializerMethodField() @@ -113,6 +141,7 @@ class ChatListSerializer(serializers.ModelSerializer): # Messages & status "last_message", "unread_count", + "participant_details", "participant_names", "member_ids", "is_pinned", @@ -135,19 +164,7 @@ class ChatListSerializer(serializers.ModelSerializer): @extend_schema_field(ChatLastMessageSerializer) def get_last_message(self, obj): """Последнее сообщение в чате (использует prefetch если доступен)""" - last_msg = None - if ( - hasattr(obj, '_prefetched_last_message') - and obj._prefetched_last_message - ): - last_msg = obj._prefetched_last_message[0] - else: - last_msg = ( - obj.messages.filter(is_deleted=False) - .select_related('author') - .order_by("-created_at") - .first() - ) + last_msg = _get_last_message(obj) if last_msg: return { "id": last_msg.id, @@ -159,13 +176,24 @@ class ChatListSerializer(serializers.ModelSerializer): } return None + @extend_schema_field(ChatParticipantPreviewSerializer(many=True)) + def get_participant_details(self, obj): + members = _get_active_memberships(obj, limit=20) + return [ + { + "id": membership.user.id, + "name": membership.user.get_full_name(), + "avatar": membership.user.avatar.url if membership.user.avatar else None, + } + for membership in members + ] + @extend_schema_field(serializers.ListField(child=serializers.CharField())) def get_participant_names(self, obj): """Имена участников (для приватных чатов, из prefetch)""" if obj.type == "private": - # memberships уже prefetch'нуты в ChatViewSet.get_queryset - members = list(obj.memberships.all())[:5] - return [m.user.get_full_name() for m in members] + members = _get_active_memberships(obj, limit=5) + return [membership.user.get_full_name() for membership in members] return [] @extend_schema_field( @@ -173,11 +201,7 @@ class ChatListSerializer(serializers.ModelSerializer): ) def get_member_ids(self, obj): """ID участников (из prefetch, для поиска существующего чата)""" - return list( - obj.memberships.filter(is_active=True).values_list( - "user_id", flat=True - ) - ) + return [membership.user_id for membership in _get_active_memberships(obj)] @extend_schema_field(serializers.BooleanField()) def get_is_pinned(self, obj): @@ -213,6 +237,8 @@ class ChatDetailSerializer(serializers.ModelSerializer): is_pinned = serializers.SerializerMethodField() notifications_enabled = serializers.SerializerMethodField() last_read_message_id = serializers.SerializerMethodField() + last_message = serializers.SerializerMethodField() + unread_count = serializers.SerializerMethodField() context_content_type = serializers.PrimaryKeyRelatedField( queryset=ContentType.objects.all(), write_only=True, @@ -260,6 +286,8 @@ class ChatDetailSerializer(serializers.ModelSerializer): "is_pinned", "notifications_enabled", "last_read_message_id", + "last_message", + "unread_count", ] read_only_fields = [ "created_at", @@ -289,18 +317,13 @@ class ChatDetailSerializer(serializers.ModelSerializer): """Детали участников MIGRATION: Используем memberships вместо participants """ - # Получаем активных участников через memberships - active_memberships = obj.memberships.filter( - is_active=True - ).select_related("user")[:20] - return [ { - "id": m.user.id, - "name": m.user.get_full_name(), - "avatar": m.user.avatar.url if m.user.avatar else None, + "id": membership.user.id, + "name": membership.user.get_full_name(), + "avatar": membership.user.avatar.url if membership.user.avatar else None, } - for m in active_memberships + for membership in _get_active_memberships(obj, limit=20) ] @extend_schema_field(ChatUserSettingsSerializer) @@ -331,6 +354,27 @@ class ChatDetailSerializer(serializers.ModelSerializer): return obj.my_read_state[0].last_read_message_id return None + @extend_schema_field(ChatLastMessageSerializer) + def get_last_message(self, obj): + last_msg = _get_last_message(obj) + if not last_msg: + return None + + return { + "id": last_msg.id, + "content": last_msg.content[:100], + "author_name": last_msg.author.get_full_name() + if last_msg.author + else "Unknown", + "created_at": last_msg.created_at.isoformat(), + } + + @extend_schema_field(serializers.IntegerField()) + def get_unread_count(self, obj): + if hasattr(obj, "my_read_state") and obj.my_read_state: + return obj.my_read_state[0].unread_count + return 0 + class MessageAttachmentSerializer(serializers.ModelSerializer): """Вложение сообщения""" diff --git a/frontend/src/app/messages/page.tsx b/frontend/src/app/messages/page.tsx index 8f693016..f5f6fbde 100644 --- a/frontend/src/app/messages/page.tsx +++ b/frontend/src/app/messages/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { AppShell } from "../../components/AppShell"; import { apiClient } from "@/lib/api"; import { resolveMediaUrl } from "@/lib/url"; @@ -12,6 +12,7 @@ import Image from "next/image"; import { useUser } from "@/contexts/UserContext"; import { useRouter } from "next/navigation"; import { Modal } from "@/components/ui"; +import wsManager from "@/lib/websocketManager"; type UnreadFilter = "all" | "unread" | "read"; type PinnedFilter = "all" | "pinned" | "unpinned"; @@ -22,6 +23,17 @@ type CreateChatPayload = { avatar?: File; }; +type RealtimeChatEvent = { + type: string; + chat_id?: number; + notification?: { + verb?: string; + data?: { + chat_id?: number; + }; + }; +}; + function getChatTypeIcon(chat: Chat) { const chatType = chat.chat_type || chat.type; switch (chatType) { @@ -107,69 +119,111 @@ export default function MessagesPage() { const [newChatAvatar, setNewChatAvatar] = useState(null); const [avatarPreview, setAvatarPreview] = useState(null); const [creating, setCreating] = useState(false); + const refreshAllChatsInFlightRef = useRef(false); + const refreshChatInFlightRef = useRef>(new Set()); + const hasCompletedInitialLoadRef = useRef(false); + const wasWsConnectedRef = useRef(false); - useEffect(() => { - async function loadChats() { - try { + const loadChats = useCallback(async (options?: { showLoader?: boolean }) => { + if (refreshAllChatsInFlightRef.current) { + return; + } + + const showLoader = options?.showLoader ?? true; + refreshAllChatsInFlightRef.current = true; + + try { + if (showLoader) { setLoading(true); - setError(null); - const items = await apiClient.getAllChats(); - - // FRONTEND-ONLY: добираем детали чатов, чтобы получить имя/аватар собеседника - // для private/direct в случае, когда list не отдает avatar/participant_details. - const needDetails = items.filter((chat: Chat) => { - const kind = chat.chat_type || chat.type; - if (kind !== "private" && kind !== "direct") return false; - - const hasOtherAvatar = Boolean(getChatAvatar(chat, currentUserId)); - const resolvedTitle = getChatTitle(chat, currentUserId, currentUserForMatch).trim(); - const hasOtherName = Boolean(resolvedTitle && resolvedTitle.toLowerCase() !== "диалог"); - return !(hasOtherAvatar && hasOtherName); - }); - - if (!needDetails.length) { - setChats(items); - return; - } - - const details = await Promise.all( - needDetails.map(async (chat: Chat) => { - try { - return await apiClient.getChat(chat.id); - } catch { - return null; - } - }) - ); - - const detailsMap = new Map(); - details.forEach((d) => { - if (d) detailsMap.set(d.id, d); - }); - - const merged = items.map((chat: Chat) => { - const detail = detailsMap.get(chat.id); - if (!detail) return chat; - return { - ...chat, - participants: detail.participants ?? chat.participants, - participant_details: detail.participant_details ?? chat.participant_details, - avatar: chat.avatar || detail.avatar, - name: chat.name || detail.name, - }; - }); - - setChats(merged); - } catch (e: unknown) { - console.error("Ошибка загрузки чатов:", e); - setError("Не удалось загрузить чаты"); - } finally { + } + setError(null); + const items = await apiClient.getAllChats(); + setChats(items); + hasCompletedInitialLoadRef.current = true; + } catch (e: unknown) { + console.error("Ошибка загрузки чатов:", e); + setError("Не удалось загрузить чаты"); + } finally { + refreshAllChatsInFlightRef.current = false; + if (showLoader) { setLoading(false); } } + }, []); - loadChats(); - }, [currentUserForMatch, currentUserId]); + const refreshChat = useCallback(async (chatId: number) => { + if (!Number.isFinite(chatId) || chatId <= 0) { + return; + } + + if (refreshAllChatsInFlightRef.current || refreshChatInFlightRef.current.has(chatId)) { + return; + } + + refreshChatInFlightRef.current.add(chatId); + + try { + const chat = await apiClient.getChat(chatId); + setChats((prev) => { + const existingIndex = prev.findIndex((item) => item.id === chatId); + if (existingIndex === -1) { + return [chat, ...prev]; + } + + const next = [...prev]; + next[existingIndex] = { + ...prev[existingIndex], + ...chat, + }; + return next; + }); + } catch (e) { + console.warn(`Не удалось точечно обновить чат ${chatId}, перегружаю список`, e); + await loadChats({ showLoader: false }); + } finally { + refreshChatInFlightRef.current.delete(chatId); + } + }, [loadChats]); + + useEffect(() => { + void loadChats(); + }, [loadChats]); + + useEffect(() => { + if (typeof window === "undefined" || !localStorage.getItem("access_token")) { + return; + } + + const unsubscribeMessages = wsManager.subscribe((event) => { + const data = event as RealtimeChatEvent; + + if ((data.type === "list_update" || data.type === "message_edited") && typeof data.chat_id === "number") { + void refreshChat(data.chat_id); + return; + } + + if (data.type !== "notification" || !data.notification) { + return; + } + + if (data.notification.verb === "chat_added_to_chat") { + void loadChats({ showLoader: false }); + return; + } + }); + + const unsubscribeStatus = wsManager.subscribeStatus((status) => { + if (status.isConnected && !wasWsConnectedRef.current && hasCompletedInitialLoadRef.current) { + void loadChats({ showLoader: false }); + } + wasWsConnectedRef.current = status.isConnected; + }); + + return () => { + unsubscribeMessages(); + unsubscribeStatus(); + }; + }, [loadChats, refreshChat]); const filteredChats = useMemo(() => { const q = search.trim().toLowerCase();