Optimize messages list refresh and chat serialization

This commit is contained in:
lizerk 2026-04-10 20:54:09 +03:00
parent 6042b98aa9
commit 66141197ad
2 changed files with 186 additions and 88 deletions

View File

@ -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):
"""Вложение сообщения"""

View File

@ -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<File | null>(null);
const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
const [creating, setCreating] = useState(false);
const refreshAllChatsInFlightRef = useRef(false);
const refreshChatInFlightRef = useRef<Set<number>>(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<number, Chat>();
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();