diff --git a/backend/communications/api/viewsets.py b/backend/communications/api/viewsets.py index 24eab330..b7beb286 100644 --- a/backend/communications/api/viewsets.py +++ b/backend/communications/api/viewsets.py @@ -60,6 +60,18 @@ logger = logging.getLogger(__name__) User = get_user_model() +def _parse_bool_query_param(value, default=True): + if value is None: + return default + + normalized = str(value).strip().lower() + if normalized in {'1', 'true', 'yes', 'on'}: + return True + if normalized in {'0', 'false', 'no', 'off'}: + return False + return default + + class ChatViewSet(viewsets.ModelViewSet): """ ViewSet для управления чатами @@ -563,7 +575,12 @@ class ChatViewSet(viewsets.ModelViewSet): @action(detail=True, methods=['get']) def messages(self, request, pk=None): - """Загрузка сообщений чата (пагинация по времени)""" + """Загрузка сообщений чата (пагинация по времени). + + Query params: + - mark_read=false: не обновлять last_read_message при after_id/after_ts. + Нужен для тихой синхронизации клиента после разрыва WebSocket. + """ chat = self.get_object() # Проверка доступа @@ -582,6 +599,10 @@ class ChatViewSet(viewsets.ModelViewSet): limit = min(int(request.query_params.get('limit', 50)), 100) except (ValueError, TypeError): limit = 50 + mark_read = _parse_bool_query_param( + request.query_params.get('mark_read'), + default=True, + ) queryset = chat.messages.filter(is_deleted=False).select_related( 'author', 'reply_to', 'reply_to__author', 'poll' @@ -633,7 +654,7 @@ class ChatViewSet(viewsets.ModelViewSet): # Автоотметка при загрузке НОВЫХ сообщений (after_id/after_ts) # При scroll вверх (before_id) - не отмечаем - if (after_id or after_ts) and messages: + if mark_read and (after_id or after_ts) and messages: self._auto_mark_read(chat, request.user, messages) return Response({ diff --git a/backend/notifications/api/views.py b/backend/notifications/api/views.py index 5d0f5906..c35b787a 100644 --- a/backend/notifications/api/views.py +++ b/backend/notifications/api/views.py @@ -16,6 +16,10 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from ..models import Notification, UserChannelPreferences +from ..realtime import ( + send_notification_read_event, + send_notifications_read_all_event, +) from .serializers import ( ChannelPreferencesSerializer, CountResponseSerializer, @@ -188,8 +192,12 @@ def mark_as_read(request, notification_id): id=notification_id, recipient=request.user ) + was_unread = notification.unread notification.mark_as_read() + if was_unread: + send_notification_read_event(request.user.id, notification_id) + return Response({ 'status': 'success', 'notification_id': notification_id @@ -252,8 +260,16 @@ def mark_all_as_read(request): if verb: queryset = queryset.filter(verb=verb) + notification_ids = list(queryset.values_list('id', flat=True)) count = queryset.mark_all_as_read() + if notification_ids: + send_notifications_read_all_event( + request.user.id, + notification_ids, + category=verb, + ) + return Response({ 'status': 'success', 'count': count @@ -299,8 +315,16 @@ def mark_category_as_read(request): verb__in=verbs, ) + notification_ids = list(queryset.values_list('id', flat=True)) count = queryset.mark_all_as_read() + if notification_ids: + send_notifications_read_all_event( + request.user.id, + notification_ids, + category=request.data.get('category'), + ) + return Response({ 'status': 'success', 'verbs': verbs, diff --git a/backend/notifications/realtime.py b/backend/notifications/realtime.py new file mode 100644 index 00000000..fbfc7e7c --- /dev/null +++ b/backend/notifications/realtime.py @@ -0,0 +1,74 @@ +from asgiref.sync import async_to_sync +from channels.layers import get_channel_layer + +from .models import Notification + + +def get_unread_notifications_count(user_id): + return Notification.objects.filter( + recipient_id=user_id, + unread=True, + deleted=False, + ).count() + + +def _send_user_event(user_id, event): + channel_layer = get_channel_layer() + if channel_layer is None: + return + + async_to_sync(channel_layer.group_send)(f"user_{user_id}", event) + + +def send_notification_read_event(user_id, notification_id, unread_count=None): + count = ( + get_unread_notifications_count(user_id) + if unread_count is None + else unread_count + ) + + _send_user_event( + user_id, + { + "type": "notification_read", + "notification_id": notification_id, + "unread_count": count, + }, + ) + _send_user_event( + user_id, + { + "type": "notification_count_update", + "count": count, + }, + ) + + +def send_notifications_read_all_event( + user_id, + notification_ids, + unread_count=None, + category=None, +): + count = ( + get_unread_notifications_count(user_id) + if unread_count is None + else unread_count + ) + + _send_user_event( + user_id, + { + "type": "notifications_read_all", + "notification_ids": notification_ids, + "category": category, + "unread_count": count, + }, + ) + _send_user_event( + user_id, + { + "type": "notification_count_update", + "count": count, + }, + ) diff --git a/backend/realtime/consumers.py b/backend/realtime/consumers.py index d34e79d0..b6802ad8 100644 --- a/backend/realtime/consumers.py +++ b/backend/realtime/consumers.py @@ -215,6 +215,27 @@ class UserConsumer(ChatConsumerMixin, AsyncJsonWebsocketConsumer): {"type": "unread_count", "count": event.get("count", 0)} ) + async def notification_read(self, event): + """Синхронизация прочтения одного уведомления между сессиями.""" + await self.send_json( + { + "type": "notification_read", + "notification_id": event.get("notification_id"), + "unread_count": event.get("unread_count"), + } + ) + + async def notifications_read_all(self, event): + """Синхронизация массового прочтения уведомлений между сессиями.""" + await self.send_json( + { + "type": "notifications_read_all", + "notification_ids": event.get("notification_ids", []), + "category": event.get("category"), + "unread_count": event.get("unread_count"), + } + ) + # ==================== Обработка событий закупок ==================== async def poll_update(self, event): diff --git a/backend/tests/api/v1/notifications/test_notifications_api.py b/backend/tests/api/v1/notifications/test_notifications_api.py new file mode 100644 index 00000000..2da9bba0 --- /dev/null +++ b/backend/tests/api/v1/notifications/test_notifications_api.py @@ -0,0 +1,108 @@ +from unittest.mock import patch + +import pytest + +from notifications.models import Notification + + +pytestmark = pytest.mark.django_db + + +class TestNotificationRealtimeEvents: + def test_mark_as_read_sends_realtime_event(self, api_client, user_factory): + user = user_factory() + notification = Notification.objects.create( + recipient=user, + verb="request_updated", + description="Заявка обновлена", + ) + + api_client.force_authenticate(user=user) + + with patch( + "notifications.api.views.send_notification_read_event" + ) as send_event: + response = api_client.post( + f"/api/v1/notifications/{notification.id}/read/" + ) + + assert response.status_code == 200 + notification.refresh_from_db() + assert notification.unread is False + send_event.assert_called_once_with(user.id, notification.id) + + def test_mark_all_as_read_sends_realtime_event_with_ids( + self, api_client, user_factory + ): + user = user_factory() + first = Notification.objects.create( + recipient=user, + verb="request_new", + description="Новая заявка", + ) + second = Notification.objects.create( + recipient=user, + verb="request_updated", + description="Заявка обновлена", + ) + + api_client.force_authenticate(user=user) + + with patch( + "notifications.api.views.send_notifications_read_all_event" + ) as send_event: + response = api_client.post("/api/v1/notifications/read-all/") + + assert response.status_code == 200 + assert ( + Notification.objects.filter( + recipient=user, + unread=True, + deleted=False, + ).count() + == 0 + ) + send_event.assert_called_once() + args, kwargs = send_event.call_args + assert args[0] == user.id + assert set(args[1]) == {first.id, second.id} + assert kwargs == {"category": None} + + def test_mark_category_as_read_sends_category_realtime_event( + self, api_client, user_factory + ): + user = user_factory() + matching = Notification.objects.create( + recipient=user, + verb="request_updated", + description="Заявка обновлена", + ) + other = Notification.objects.create( + recipient=user, + verb="document_uploaded", + description="Документ загружен", + ) + + api_client.force_authenticate(user=user) + + with patch( + "notifications.api.views.send_notifications_read_all_event" + ) as send_event: + response = api_client.post( + "/api/v1/notifications/category/read/", + { + "verbs": ["request_updated"], + "category": "Заявки", + }, + format="json", + ) + + assert response.status_code == 200 + matching.refresh_from_db() + other.refresh_from_db() + assert matching.unread is False + assert other.unread is True + send_event.assert_called_once() + args, kwargs = send_event.call_args + assert args == (user.id, [matching.id]) + assert kwargs == {"category": "Заявки"} diff --git a/backend/tests/realtime/test_websocket_events.py b/backend/tests/realtime/test_websocket_events.py index f8edfd03..0287d3f4 100644 --- a/backend/tests/realtime/test_websocket_events.py +++ b/backend/tests/realtime/test_websocket_events.py @@ -544,3 +544,70 @@ class TestChatMarkedReadEvent: import asyncio with pytest.raises(asyncio.TimeoutError): await comm2.receive_json_from(timeout=2) + await comm1.disconnect() + await comm2.disconnect() + +class TestNotificationStateEvents: + """Тесты синхронизации состояния уведомлений через user channel.""" + + @pytest.mark.asyncio + async def test_notification_read_sync_between_sessions(self, ws_communicator, user): + comm1 = await ws_communicator(user=user) + comm2 = await ws_communicator(user=user) + + connected1, _ = await comm1.connect() + connected2, _ = await comm2.connect() + assert connected1 and connected2 + + channel_layer = get_channel_layer() + await channel_layer.group_send( + f'user_{user.id}', + { + 'type': 'notification_read', + 'notification_id': 321, + 'unread_count': 4, + } + ) + + response1 = await comm1.receive_json_from(timeout=5) + response2 = await comm2.receive_json_from(timeout=5) + + assert response1['type'] == 'notification_read' + assert response1['notification_id'] == 321 + assert response1['unread_count'] == 4 + assert response2['type'] == 'notification_read' + + await comm1.disconnect() + await comm2.disconnect() + + @pytest.mark.asyncio + async def test_notifications_read_all_sync_between_sessions(self, ws_communicator, user): + comm1 = await ws_communicator(user=user) + comm2 = await ws_communicator(user=user) + + connected1, _ = await comm1.connect() + connected2, _ = await comm2.connect() + assert connected1 and connected2 + + channel_layer = get_channel_layer() + await channel_layer.group_send( + f'user_{user.id}', + { + 'type': 'notifications_read_all', + 'notification_ids': [11, 12, 13], + 'category': 'Заявки', + 'unread_count': 0, + } + ) + + response1 = await comm1.receive_json_from(timeout=5) + response2 = await comm2.receive_json_from(timeout=5) + + assert response1['type'] == 'notifications_read_all' + assert response1['notification_ids'] == [11, 12, 13] + assert response1['category'] == 'Заявки' + assert response1['unread_count'] == 0 + assert response2['type'] == 'notifications_read_all' + + await comm1.disconnect() + await comm2.disconnect() diff --git a/frontend/src/app/messages/[chatId]/page.tsx b/frontend/src/app/messages/[chatId]/page.tsx index 5901b82e..0b478ad9 100644 --- a/frontend/src/app/messages/[chatId]/page.tsx +++ b/frontend/src/app/messages/[chatId]/page.tsx @@ -1,217 +1,24 @@ "use client"; import React, { useEffect, useLayoutEffect, useMemo, useRef, useState, useCallback } from "react"; -import Link from "next/link"; import { useParams } from "next/navigation"; -import { MessageCircle, Send, ArrowLeft, Paperclip, X, FileText, ChevronRight, ChevronDown, Reply, Pencil, Trash2, Smile, Pin, Bell, BellOff } from "lucide-react"; +import { MessageCircle, ChevronDown } from "lucide-react"; import { AppShell } from "../../../components/AppShell"; +import ChatDialogHeader from "@/components/messages/ChatDialogHeader"; +import ChatMediaPreviewModal from "@/components/messages/ChatMediaPreviewModal"; +import ChatMessageItem, { MediaPreview } from "@/components/messages/ChatMessageItem"; +import MessageActionsMenu from "@/components/messages/MessageActionsMenu"; +import MessageComposer from "@/components/messages/MessageComposer"; +import ReactionPickerModal from "@/components/messages/ReactionPickerModal"; import { apiClient } from "@/lib/api"; -import type { Chat, Message, MessageAttachment } from "@/types/api"; +import type { Chat, Message } from "@/types/api"; import { useUser } from "@/contexts/UserContext"; +import { useChatFallbackSync } from "@/hooks/useChatFallbackSync"; +import { useSilentChatReloadGuard } from "@/hooks/useSilentChatReloadGuard"; import { useWebSocket } from "@/hooks/useWebSocket"; +import { uniqueMessagesById } from "@/lib/messages/chatUtils"; +import { formatDayDivider, getMessagePreviewText, getReplyToId } from "@/lib/messages/messageUtils"; import ScrollableMessageList, { ScrollableMessageListInner } from "@/components/ScrollableMessageList"; -import { resolveMediaUrl } from "@/lib/url"; - -function getUserFullName(lastName?: string, firstName?: string): string { - return `${lastName || ""} ${firstName || ""}`.trim(); -} - -function getInterlocutorFromParticipants(chat: Chat, currentUserId?: number) { - const participants = (chat.participants || []).filter( - (p): p is Exclude => typeof p === "object" && p !== null - ); - return participants.find((p) => p.id !== currentUserId); -} - -function getInterlocutorFromParticipantDetails(chat: Chat, currentUserId?: number) { - return (chat.participant_details || []).find((p) => p.id !== currentUserId); -} - -function getChatTitle(chat: Chat, currentUserId?: number): string { - const chatKind = chat.chat_type || chat.type; - const rawName = (chat.name || "").trim(); - - if (chatKind === "direct" || chatKind === "private" || !rawName || rawName.toLowerCase() === "диалог") { - if (chat.interlocutor?.name?.trim()) { - return chat.interlocutor.name.trim(); - } - - const detailsOther = getInterlocutorFromParticipantDetails(chat, currentUserId); - if (detailsOther?.name?.trim()) { - return detailsOther.name.trim(); - } - - const other = getInterlocutorFromParticipants(chat, currentUserId); - if (other && typeof other === "object") { - const name = getUserFullName(other.last_name, other.first_name); - if (name) return name; - if (other.email) return other.email; - } - } - - return rawName || "Диалог"; -} - -function getChatAvatar(chat: Chat, currentUserId?: number): string { - const chatKind = chat.chat_type || chat.type; - if (chatKind === "direct" || chatKind === "private" || (chat.name || "").trim().toLowerCase() === "диалог") { - if (chat.interlocutor?.avatar) return chat.interlocutor.avatar; - - const detailsOther = getInterlocutorFromParticipantDetails(chat, currentUserId); - if (detailsOther?.avatar) return detailsOther.avatar; - - const other = getInterlocutorFromParticipants(chat, currentUserId); - if (other?.avatar) return other.avatar; - } - return chat.avatar || ""; -} - -function getChatInitials(chat: Chat, currentUserId?: number): string { - const title = getChatTitle(chat, currentUserId); - return ( - title - .split(" ") - .filter(Boolean) - .slice(0, 2) - .map((p) => p[0]?.toUpperCase() || "") - .join("") || "Ч" - ); -} - -function getMessageInitials(message: Message): string { - const name = (message.author_name || "").trim(); - if (!name) return "С"; - const parts = name.split(" ").filter(Boolean); - if (parts.length === 1) { - return parts[0].slice(0, 2).toUpperCase(); - } - return `${parts[0][0] || ""}${parts[1][0] || ""}`.toUpperCase(); -} - -function formatTime(date?: string): string { - if (!date) return ""; - const d = new Date(date); - if (Number.isNaN(d.getTime())) return ""; - return d.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" }); -} - -function getMessageDate(message: Message): Date | null { - if (message.created_at) { - const d = new Date(message.created_at); - return Number.isNaN(d.getTime()) ? null : d; - } - - if (message.created_ts) { - const d = new Date(message.created_ts); - return Number.isNaN(d.getTime()) ? null : d; - } - - return null; -} - -function formatDayDivider(date: Date): string { - return date.toLocaleDateString("ru-RU", { - day: "2-digit", - month: "long", - year: "numeric", - }); -} - -function messageTime(message: Message): string { - const d = getMessageDate(message); - if (!d) return ""; - - const now = new Date(); - const isToday = d.toDateString() === now.toDateString(); - if (isToday) { - return d.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" }); - } - - return d.toLocaleString("ru-RU", { - day: "2-digit", - month: "2-digit", - hour: "2-digit", - minute: "2-digit", - }); -} - -function isSameDay(a: Date | null, b: Date | null): boolean { - if (!a || !b) return false; - return ( - a.getFullYear() === b.getFullYear() && - a.getMonth() === b.getMonth() && - a.getDate() === b.getDate() - ); -} - -function formatFileSize(size?: number): string { - if (!size || size <= 0) return ""; - const kb = size / 1024; - if (kb < 1024) return `${Math.round(kb)} КБ`; - const mb = kb / 1024; - return `${mb.toFixed(1)} МБ`; -} - -function isImageAttachment(att: MessageAttachment): boolean { - const mime = (att.mime_type || "").toLowerCase(); - const type = (att.file_type || "").toLowerCase(); - const name = (att.file_name || "").toLowerCase(); - const byExt = /\.(png|jpe?g|gif|webp|bmp|svg|avif)$/i.test(name); - return mime.startsWith("image/") || type === "image" || byExt; -} - -function isVideoAttachment(att: MessageAttachment): boolean { - const mime = (att.mime_type || "").toLowerCase(); - const type = (att.file_type || "").toLowerCase(); - const name = (att.file_name || "").toLowerCase(); - const byExt = /\.(mp4|webm|mov|m4v|avi|mkv|3gp|mpeg|mpg)$/i.test(name); - return mime.startsWith("video/") || type === "video" || byExt; -} - -function isAudioAttachment(att: MessageAttachment): boolean { - const mime = (att.mime_type || "").toLowerCase(); - const type = (att.file_type || "").toLowerCase(); - return mime.startsWith("audio/") || type === "audio"; -} - -function normalizePossiblyEncodedUrl(value: string): string { - const input = (value || "").trim(); - if (!input) return ""; - - try { - // В API URL может приходить уже закодированным (%D0...). - // Декодируем один раз и кодируем обратно, чтобы избежать %25D0... (double-encoding). - return encodeURI(decodeURI(input)); - } catch { - return encodeURI(input); - } -} - -function uniqueMessagesById(items: Message[]): Message[] { - const map = new Map(); - items.forEach((msg) => { - map.set(msg.id, msg); - }); - return Array.from(map.values()); -} - -function getMessagePreviewText(message: Message): string { - if (message.content?.trim()) return message.content.trim(); - if (message.attachments?.length) return "[Вложение]"; - return "[Сообщение]"; -} - -function getReplyToId(message: Message): number | null { - if (typeof message.reply_to_id === "number") return message.reply_to_id; - if (typeof message.reply_to === "number") return message.reply_to; - if (message.reply_to && typeof message.reply_to === "object" && typeof message.reply_to.id === "number") { - return message.reply_to.id; - } - if (message.reply_to_message && typeof message.reply_to_message.id === "number") { - return message.reply_to_message.id; - } - return null; -} type ReplyTarget = { id: number; @@ -219,6 +26,11 @@ type ReplyTarget = { preview: string; }; +type MessageActionsAnchor = { + x: number; + y: number; +}; + const RECENT_REACTIONS_KEY = "eusrr_recent_reactions"; const MAX_RECENT_REACTIONS = 5; const ALL_REACTIONS = [ @@ -245,6 +57,7 @@ export default function MessageDialogPage() { const params = useParams<{ chatId: string }>(); const chatId = Number(params.chatId); const { user, loading: userLoading } = useUser(); + const { handleReconnectExhausted, resetReloadGuard } = useSilentChatReloadGuard(chatId || null); const [chat, setChat] = useState(null); const [messages, setMessages] = useState([]); @@ -263,7 +76,7 @@ export default function MessageDialogPage() { const [attachedFiles, setAttachedFiles] = useState([]); const [brokenMedia, setBrokenMedia] = useState>({}); const [useOriginalImage, setUseOriginalImage] = useState>({}); - const [mediaPreview, setMediaPreview] = useState<{ type: "image" | "video"; src: string; name: string } | null>(null); + const [mediaPreview, setMediaPreview] = useState(null); const [floatingDate, setFloatingDate] = useState(null); const [showFloatingDate, setShowFloatingDate] = useState(false); const [showScrollToBottom, setShowScrollToBottom] = useState(false); @@ -285,6 +98,7 @@ export default function MessageDialogPage() { const anchorOlderLoadAttemptsRef = useRef(0); const [anchorRetryTick, setAnchorRetryTick] = useState(0); const hasMoreNewerRef = useRef(false); + const fallbackSyncInFlightRef = useRef(false); // Локальное состояние для настроек чата const [isPinned, setIsPinned] = useState(false); @@ -335,11 +149,20 @@ export default function MessageDialogPage() { ); // WebSocket для real-time обновлений - const { isConnected, sendTyping, handlers, reconnectAttempts } = useWebSocket({ + const { isConnected, sendTyping, handlers } = useWebSocket({ chatId: chatId || null, - autoConnect: true + autoConnect: true, + onReconnectExhausted: handleReconnectExhausted, }); + useEffect(() => { + if (!isConnected) { + return; + } + + resetReloadGuard(); + }, [isConnected, resetReloadGuard]); + // Проверка, находится ли пользователь внизу списка const isNearBottom = useCallback(() => { const component = messagesViewportRef.current; @@ -359,6 +182,62 @@ export default function MessageDialogPage() { component.scrollToBottom(smooth ? 'smooth' : 'auto'); }, []); + const syncLatestMessages = useCallback(async ( + options: { limit?: number; scrollOnSync?: boolean } = {} + ) => { + if ( + !chatId || + Number.isNaN(chatId) || + messagesLoading || + messages.length === 0 || + fallbackSyncInFlightRef.current + ) { + return; + } + + const newestMessage = messages[messages.length - 1]; + const shouldMarkRead = isNearBottom(); + + fallbackSyncInFlightRef.current = true; + + try { + const response = await apiClient.getChatMessages(chatId, { + limit: options.limit ?? 20, + after_id: newestMessage.id, + mark_read: shouldMarkRead, + }); + + const newerMessages = response.messages || []; + if (newerMessages.length > 0) { + setMessages((prev) => uniqueMessagesById([...prev, ...newerMessages])); + + if (shouldMarkRead) { + const lastLoadedMessage = newerMessages[newerMessages.length - 1]; + setChat((prev) => + prev + ? { + ...prev, + last_read_message_id: Math.max(prev.last_read_message_id ?? 0, lastLoadedMessage.id), + } + : prev + ); + } + + if (shouldMarkRead && options.scrollOnSync) { + requestAnimationFrame(() => { + scrollToBottom(false); + }); + } + } + + setHasMoreNewer(Boolean(response.has_more)); + } catch (e) { + console.error("Ошибка тихой синхронизации сообщений:", e); + } finally { + fallbackSyncInFlightRef.current = false; + } + }, [chatId, isNearBottom, messages, messagesLoading, scrollToBottom]); + // Обновление reactions_summary для конкретного сообщения const updateMessageReactionsSummary = useCallback(( messageId: number, @@ -478,10 +357,23 @@ export default function MessageDialogPage() { updateMessageReactionsSummary(data.message_id, data.reactions_summary); } } + else if (data.type === 'marked_read' && data.chat_id === chatId && typeof data.last_read_message_id === 'number') { + setChat((prev) => + prev + ? { + ...prev, + last_read_message_id: Math.max(prev.last_read_message_id ?? 0, data.last_read_message_id), + } + : prev + ); + } }; handlers.current.onConnect = () => { - // WebSocket подключен + void syncLatestMessages({ + limit: 30, + scrollOnSync: isNearBottom(), + }); }; handlers.current.onDisconnect = () => { @@ -491,7 +383,7 @@ export default function MessageDialogPage() { handlers.current.onError = (error) => { console.error('❌ WebSocket error:', error); }; - }, [user?.id, chatId, isNearBottom, scrollToBottom, updateMessageReactionsSummary]); + }, [handlers, user?.id, chatId, isNearBottom, scrollToBottom, syncLatestMessages, updateMessageReactionsSummary]); // Очистка таймаута при размонтировании useEffect(() => { @@ -594,7 +486,7 @@ export default function MessageDialogPage() { setError(null); const loadedChat = await apiClient.getChat(chatId); setChat(loadedChat); - } catch (e: any) { + } catch { setChat(null); setError("Не удалось загрузить чат. Проверьте подключение и попробуйте снова."); } finally { @@ -603,7 +495,7 @@ export default function MessageDialogPage() { } loadChats(); - }, [chatId, userLoading, user?.id]); + }, [chatId, userLoading, user, user?.id]); useEffect(() => { async function loadMessages() { @@ -666,12 +558,13 @@ export default function MessageDialogPage() { anchorScrollAttemptsRef.current = 0; anchorOlderLoadAttemptsRef.current = 0; setAnchorRetryTick(0); - } catch (e: any) { - if (String(e?.message || "").includes("403")) { + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : String(error); + if (errorMessage.includes("403")) { setError("Нет доступа к этому чату"); setMessages([]); } else { - console.error("Ошибка загрузки сообщений:", e); + console.error("Ошибка загрузки сообщений:", error); } setHasMoreOlder(false); setHasMoreNewer(false); @@ -688,7 +581,7 @@ export default function MessageDialogPage() { } loadMessages(); - }, [chatId, userLoading, user?.id]); + }, [chatId, userLoading, user, user?.id]); // Скролл к якорю или вниз после загрузки сообщений // Anchor scroll используется при переходе по прямой ссылке на сообщение (например, из уведомления) @@ -761,7 +654,7 @@ export default function MessageDialogPage() { // ScrollableMessageList уже корректирует позицию автоматически через getSnapshotBeforeUpdate // Дублирующая корректировка вызывала "подергивание" при загрузке старых сообщений - const loadOlderMessages = async () => { + const loadOlderMessages = useCallback(async () => { if (!chatId || Number.isNaN(chatId) || loadingOlder || messagesLoading || messages.length === 0) { return; } @@ -795,25 +688,39 @@ export default function MessageDialogPage() { } finally { setLoadingOlder(false); } - }; + }, [allowOneOlderProbe, chatId, hasMoreOlder, loadingOlder, messages, messagesLoading]); - const loadNewerMessages = async () => { + const loadNewerMessages = useCallback(async () => { if (!chatId || Number.isNaN(chatId) || loadingNewer || messagesLoading || !hasMoreNewer || messages.length === 0) { return; } const newestMessage = messages[messages.length - 1]; + const shouldMarkRead = isNearBottom(); try { setLoadingNewer(true); const response = await apiClient.getChatMessages(chatId, { limit: 10, // Ограничено для минимальной погрешности автоотметки (было 40) after_id: newestMessage.id, + mark_read: shouldMarkRead, }); const newerMessages = response.messages || []; if (newerMessages.length > 0) { setMessages((prev) => uniqueMessagesById([...prev, ...newerMessages])); + + if (shouldMarkRead) { + const lastLoadedMessage = newerMessages[newerMessages.length - 1]; + setChat((prev) => + prev + ? { + ...prev, + last_read_message_id: Math.max(prev.last_read_message_id ?? 0, lastLoadedMessage.id), + } + : prev + ); + } // НЕ делаем автоскролл - ScrollableMessageList сам решит: // - Если был внизу (sticky) → автоматически проскроллит вниз @@ -827,7 +734,18 @@ export default function MessageDialogPage() { } finally { setLoadingNewer(false); } - }; + }, [chatId, hasMoreNewer, isNearBottom, loadingNewer, messages, messagesLoading]); + + useChatFallbackSync({ + chatId: chatId || null, + isConnected, + userLoading, + hasUser: Boolean(user), + messagesLoading, + hasMessages: messages.length > 0, + isNearBottom, + syncLatestMessages, + }); useEffect(() => { const component = messagesViewportRef.current; @@ -884,7 +802,7 @@ export default function MessageDialogPage() { viewport.addEventListener("scroll", onScroll, { passive: true }); return () => viewport.removeEventListener("scroll", onScroll); - }, [chatId, hasMoreOlder, hasMoreNewer, loadingOlder, loadingNewer, messagesLoading, messages]); + }, [chatId, hasMoreOlder, hasMoreNewer, isNearBottom, loadNewerMessages, loadOlderMessages, loadingOlder, loadingNewer, messagesLoading, messages]); const handleSend = async () => { const text = messageText.trim(); @@ -1026,6 +944,33 @@ export default function MessageDialogPage() { } }; + const handleToggleMessageActions = useCallback((messageId: number, anchor: MessageActionsAnchor) => { + setExpandedReplyActionForId((prev) => { + if (prev === messageId) { + setActionsMenuAnchor(null); + return null; + } + + setReactionPickerForMessageId(null); + setShowComposerEmojiPicker(false); + setActionsMenuAnchor(anchor); + return messageId; + }); + }, []); + + const handleAttachmentLoad = useCallback((attachmentId: number) => { + setBrokenMedia((prev) => ({ ...prev, [attachmentId]: false })); + messagesViewportRef.current?.updateScrollPosition(); + }, []); + + const handleAttachmentError = useCallback((attachmentId: number) => { + setBrokenMedia((prev) => ({ ...prev, [attachmentId]: true })); + }, []); + + const handleUseOriginalImage = useCallback((attachmentId: number) => { + setUseOriginalImage((prev) => ({ ...prev, [attachmentId]: true })); + }, []); + const handleTogglePin = async () => { if (!chatId) return; @@ -1152,91 +1097,18 @@ export default function MessageDialogPage() { ) : (
- {/* Предупреждение о WebSocket */} - {!isConnected && reconnectAttempts > 2 && ( -
-
-
-

⚠️ Real-time обновления отключены

-

- Backend не поддерживает WebSocket. Запустите через Daphne: -

- - cd backend && ..\\.venv\\Scripts\\daphne -p 9000 eusrr_backend.asgi:application - -
- -
-
- )} -
{chat ? ( <> -
-
-
- {getChatAvatar(chat, user?.id) ? ( - {getChatTitle(chat, - ) : ( - getChatInitials(chat, user?.id) - )} -
- -
-

{getChatTitle(chat, user?.id)}

-
-

{(chat.type || chat.chat_type) === "group" ? "Групповой чат" : "Диалог"}

- -
- -
- {/* Кнопка закрепления чата */} - - - {/* Кнопка управления уведомлениями */} - - - {/* Кнопка возврата к списку */} - - - -
-
+
{/* Плавающая дата */} @@ -1266,269 +1138,29 @@ export default function MessageDialogPage() { {loadingOlder ? (

Подгружаем старые сообщения...

) : null} - {messages.map((message, index) => { - const currentDate = getMessageDate(message); - const prevDate = index > 0 ? getMessageDate(messages[index - 1]) : null; - const showDayDivider = index === 0 || !isSameDay(currentDate, prevDate); + {messages.map((message) => { const replyToId = getReplyToId(message); const repliedMessage = replyToId ? messagesById.get(replyToId) : null; - const isReplyMenuOpen = expandedReplyActionForId === message.id; - - const isMine = - user?.id && - (message.author_id === user.id || message.author?.id === user.id || message.sender?.id === user.id); - const canManage = canManageMessage(message); - const canReply = !message.is_deleted; - const hasActions = canReply || canManage; return ( - -
-
- {!isMine ? ( -
-
- {message.avatar || message.author?.avatar ? ( - {message.author_name - ) : ( - getMessageInitials(message) - )} -
-
- ) : null} - -
-
- {hasActions ? ( -
- -
- ) : null} - - {!isMine ? ( -

- {message.author_name || message.author?.last_name || message.sender?.last_name || "Сотрудник"} -

- ) : null} - - {replyToId ? ( -
-

{repliedMessage?.author_name || "Ответ на сообщение"}

-

{repliedMessage ? getMessagePreviewText(repliedMessage) : `Сообщение #${replyToId}`}

-
- ) : null} - - {message.is_deleted ? ( -

Сообщение удалено

- ) : message.content ? ( -

{message.content}

- ) : null} - - {message.attachments && message.attachments.length > 0 ? ( -
- {message.attachments.map((att) => ( -
- {isImageAttachment(att) ? ( - brokenMedia[att.id] ? ( - - - Предпросмотр недоступен — открыть файл - - ) : ( - - ) - ) : isVideoAttachment(att) ? ( - brokenMedia[att.id] ? ( - - - - Видео не поддерживается в браузере — открыть файл - - - ) : ( - - ) - ) : isAudioAttachment(att) ? ( - brokenMedia[att.id] ? ( - - - Аудио не поддерживается — открыть файл - - ) : ( - - ) - ) : ( - - - {att.file_name} - {formatFileSize(att.file_size)} - - )} -
- ))} -
- ) : null} - -

- {messageTime(message)} -

- - {Object.keys(message.reactions_summary || {}).length > 0 ? ( -
- {Object.entries(message.reactions_summary || {}).map(([emoji, meta]) => { - const mine = hasMyReaction(message, emoji); - return ( - - ); - })} -
- ) : null} -
- -
-
-
-
+ ); })}
@@ -1556,7 +1188,6 @@ export default function MessageDialogPage() { )}
- {/* Индикатор "печатает..." */} {isTyping && (
@@ -1568,155 +1199,38 @@ export default function MessageDialogPage() {
)} - {editingMessageId ? ( -
-
-

Режим редактирования

-

Измените текст сообщения и отправьте

-
- -
- ) : null} - - {replyTo ? ( -
-
-

Ответ: {replyTo.author}

-

{replyTo.preview}

-
- -
- ) : null} - - {attachedFiles.length > 0 ? ( -
- {attachedFiles.map((file, index) => ( - - {file.name} - - - ))} -
- ) : null} - - {canSendMessages ? ( -
- - - -
- - - {showComposerEmojiPicker ? ( -
-
- {ALL_REACTIONS.map((emoji) => ( - - ))} -
-
- ) : null} - -