diff --git a/backend/communications/api/viewsets.py b/backend/communications/api/viewsets.py index 0e400a6a..736a82d2 100644 --- a/backend/communications/api/viewsets.py +++ b/backend/communications/api/viewsets.py @@ -620,7 +620,14 @@ class ChatViewSet(viewsets.ModelViewSet): queryset = chat.messages.filter(is_deleted=False).select_related( 'author', 'reply_to', 'reply_to__author', 'poll' ).prefetch_related( - 'attachments', 'reactions', 'reactions__user', 'poll__options', 'chat__read_states' + 'attachments', + 'reactions', + 'reactions__user', + 'poll__options', + Prefetch( + 'chat__read_states', + queryset=ChatReadState.objects.select_related('user'), + ), ) # Определяем порядок сортировки в зависимости от типа запроса @@ -719,7 +726,14 @@ class ChatViewSet(viewsets.ModelViewSet): queryset = chat.messages.filter(is_deleted=False).select_related( 'author', 'reply_to', 'reply_to__author', 'poll' ).prefetch_related( - 'attachments', 'reactions', 'reactions__user', 'poll__options', 'chat__read_states' + 'attachments', + 'reactions', + 'reactions__user', + 'poll__options', + Prefetch( + 'chat__read_states', + queryset=ChatReadState.objects.select_related('user'), + ), ) # Если нет around_id, пытаемся получить last_read_message_id @@ -1073,7 +1087,13 @@ class MessageViewSet(viewsets.ModelViewSet): message = Message.objects.select_related( 'author', 'reply_to', 'reply_to__author', 'poll', 'chat' ).prefetch_related( - 'attachments', 'reactions', 'reactions__user', 'chat__read_states' + 'attachments', + 'reactions', + 'reactions__user', + Prefetch( + 'chat__read_states', + queryset=ChatReadState.objects.select_related('user'), + ), ).get(pk=message.id) # Отправляем через WebSocket @@ -1230,7 +1250,14 @@ class MessageViewSet(viewsets.ModelViewSet): instance = Message.objects.select_related( 'author', 'reply_to', 'reply_to__author', 'poll', 'chat' ).prefetch_related( - 'attachments', 'reactions', 'reactions__user', 'poll__options', 'chat__read_states' + 'attachments', + 'reactions', + 'reactions__user', + 'poll__options', + Prefetch( + 'chat__read_states', + queryset=ChatReadState.objects.select_related('user'), + ), ).get(pk=instance.id) # WebSocket уведомление diff --git a/backend/communications/consumers.py b/backend/communications/consumers.py index fb6e82e1..7c9314bd 100644 --- a/backend/communications/consumers.py +++ b/backend/communications/consumers.py @@ -13,10 +13,10 @@ WebSocket Consumer Mixin для чатов. import logging from channels.db import database_sync_to_async -from django.db.models import Q +from django.db.models import Prefetch, Q from django.utils import timezone -from .models import Chat, ChatMembership, Message, MessageReaction +from .models import Chat, ChatMembership, ChatReadState, Message, MessageReaction from .serialization import serialize_message logger = logging.getLogger(__name__) @@ -724,7 +724,14 @@ class ChatConsumerMixin: messages = ( Message.objects.filter(chat_id=chat_id) .select_related("author", "chat") - .prefetch_related("attachments", "reactions", "chat__read_states") + .prefetch_related( + "attachments", + "reactions", + Prefetch( + "chat__read_states", + queryset=ChatReadState.objects.select_related("user"), + ), + ) .order_by("-created_at")[:limit] ) diff --git a/backend/communications/serialization.py b/backend/communications/serialization.py index a3e216db..1464b7f9 100644 --- a/backend/communications/serialization.py +++ b/backend/communications/serialization.py @@ -73,6 +73,45 @@ def _is_message_read(m) -> bool: ).exists() +def _get_message_read_by(m) -> list[dict]: + """Список участников, которые дочитали сообщение.""" + chat = getattr(m, "chat", None) + if ( + not chat + or not getattr(m, "id", None) + or not getattr(m, "author_id", None) + ): + return [] + + prefetched = getattr(chat, "_prefetched_objects_cache", {}) + read_states = prefetched.get("read_states") + if read_states is None: + read_states = chat.read_states.select_related("user").filter( + last_read_message_id__gte=m.id + ) + + readers = [] + for state in read_states: + if state.user_id == m.author_id: + continue + if not state.last_read_message_id or state.last_read_message_id < m.id: + continue + + user = getattr(state, "user", None) + if not user: + continue + + readers.append( + { + "id": user.id, + "name": user.get_full_name() or user.username, + } + ) + + readers.sort(key=lambda item: item["name"].lower()) + return readers + + def serialize_message(m) -> dict: """ Сериализация сообщения с поддержкой всех полей. @@ -109,12 +148,14 @@ def serialize_message(m) -> dict: "is_edited": m.is_edited, "edited_at": m.edited_at.isoformat() if m.edited_at else None, "is_read": _is_message_read(m), + "read_by": _get_message_read_by(m), "is_deleted": m.is_deleted, "is_pinned": m.is_pinned, "is_forwarded": m.is_forwarded, "is_system": m.is_system, "has_attachments": m.has_attachments, } + data["read_count"] = len(data["read_by"]) # Информация о пересылке (используем forward_metadata) if m.is_forwarded: diff --git a/backend/tests/api/v1/communications/test_communications_api.py b/backend/tests/api/v1/communications/test_communications_api.py index 5d301502..ae83337c 100644 --- a/backend/tests/api/v1/communications/test_communications_api.py +++ b/backend/tests/api/v1/communications/test_communications_api.py @@ -3,7 +3,7 @@ """ import pytest -from communications.models import Chat, ChatMembership, ChatUserSettings, Message +from communications.models import Chat, ChatMembership, ChatReadState, ChatUserSettings, Message from django.contrib.auth import get_user_model from django.urls import reverse from employees.models import Department @@ -201,6 +201,30 @@ class TestChatViewSet: assert "messages" in response.data assert len(response.data["messages"]) == 5 + def test_chat_messages_include_read_by_users(self, auth_client, private_chat, user1, user2): + """Payload сообщения содержит список пользователей, которые его прочитали.""" + message = Message.objects.create( + chat=private_chat, + author=user1, + content="Read me", + ) + read_state = ChatReadState.objects.get( + chat=private_chat, + user=user2, + ) + read_state.last_read_message = message + read_state.unread_count = 0 + read_state.save(update_fields=["last_read_message", "unread_count", "updated_at"]) + + url = f"/api/v1/communications/chats/{private_chat.pk}/messages/" + response = auth_client.get(url) + + assert response.status_code == status.HTTP_200_OK + payload = response.data["messages"][0] + assert payload["is_read"] is True + assert payload["read_count"] == 1 + assert payload["read_by"] == [{"id": user2.id, "name": user2.get_full_name()}] + def test_mark_read(self, auth_client, private_chat, user1): """Пометка чата как прочитанного""" url = f"/api/v1/communications/chats/{private_chat.pk}/mark-read/" diff --git a/frontend/src/app/messages/[chatId]/page.tsx b/frontend/src/app/messages/[chatId]/page.tsx index 2c27c271..e1c98dab 100644 --- a/frontend/src/app/messages/[chatId]/page.tsx +++ b/frontend/src/app/messages/[chatId]/page.tsx @@ -8,6 +8,7 @@ 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 MessageReadersModal from "@/components/messages/MessageReadersModal"; import MessageComposer from "@/components/messages/MessageComposer"; import ReactionPickerModal from "@/components/messages/ReactionPickerModal"; import { apiClient } from "@/lib/api"; @@ -16,7 +17,7 @@ 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 { getMessageTimestamp, mergeDisplayMessages, uniqueMessagesById } from "@/lib/messages/chatUtils"; import { formatDayDivider, getMessagePreviewText, getReplyToId } from "@/lib/messages/messageUtils"; import wsManager from "@/lib/websocketManager"; import ScrollableMessageList, { ScrollableMessageListInner } from "@/components/ScrollableMessageList"; @@ -34,6 +35,8 @@ type MessageActionsAnchor = { const RECENT_REACTIONS_KEY = "eusrr_recent_reactions"; const MAX_RECENT_REACTIONS = 5; +const PENDING_MESSAGE_DELAY_MS = 8000; +const MARK_READ_DEBOUNCE_MS = 300; const ALL_REACTIONS = [ "👍", "❤️", "😂", "🔥", "👏", "🎉", "😊", "😉", "😁", "🤝", "🙏", "😮", "😢", "😡", "💯", "✅", "👀", "🤔", "😍", "😎", @@ -62,6 +65,7 @@ export default function MessageDialogPage() { const [chat, setChat] = useState(null); const [messages, setMessages] = useState([]); + const [pendingMessages, setPendingMessages] = useState([]); const [loading, setLoading] = useState(true); const [messagesLoading, setMessagesLoading] = useState(false); const [error, setError] = useState(null); @@ -71,6 +75,7 @@ export default function MessageDialogPage() { const [replyTo, setReplyTo] = useState(null); const [expandedReplyActionForId, setExpandedReplyActionForId] = useState(null); const [actionsMenuAnchor, setActionsMenuAnchor] = useState<{ x: number; y: number } | null>(null); + const [isReadersModalOpen, setIsReadersModalOpen] = useState(false); const [reactionPickerForMessageId, setReactionPickerForMessageId] = useState(null); const [showComposerEmojiPicker, setShowComposerEmojiPicker] = useState(false); const [recentReactions, setRecentReactions] = useState(ALL_REACTIONS.slice(0, MAX_RECENT_REACTIONS)); @@ -100,6 +105,13 @@ export default function MessageDialogPage() { const [anchorRetryTick, setAnchorRetryTick] = useState(0); const hasMoreNewerRef = useRef(false); const fallbackSyncInFlightRef = useRef(false); + const pendingTimersRef = useRef>(new Map()); + const pendingMessagesRef = useRef([]); + const localMessageSequenceRef = useRef(0); + const markReadTimerRef = useRef(null); + const markReadInFlightRef = useRef(false); + const queuedMarkReadIdRef = useRef(null); + const lastConfirmedMarkReadIdRef = useRef(0); // Локальное состояние для настроек чата const [isPinned, setIsPinned] = useState(false); @@ -118,7 +130,30 @@ export default function MessageDialogPage() { } }, [chat]); - const messagesById = useMemo(() => new Map(messages.map((m) => [m.id, m])), [messages]); + const displayMessages = useMemo(() => mergeDisplayMessages(messages, pendingMessages), [messages, pendingMessages]); + const messagesById = useMemo(() => new Map(displayMessages.map((m) => [m.id, m])), [displayMessages]); + + useEffect(() => { + pendingMessagesRef.current = pendingMessages; + }, [pendingMessages]); + + useEffect(() => { + lastConfirmedMarkReadIdRef.current = Math.max( + lastConfirmedMarkReadIdRef.current, + chat?.last_read_message_id ?? 0, + ); + }, [chat?.last_read_message_id]); + + useEffect(() => { + if (markReadTimerRef.current) { + clearTimeout(markReadTimerRef.current); + markReadTimerRef.current = null; + } + + markReadInFlightRef.current = false; + queuedMarkReadIdRef.current = null; + lastConfirmedMarkReadIdRef.current = chat?.last_read_message_id ?? 0; + }, [chatId, chat?.last_read_message_id]); // Определяем текущий membership и права на отправку сообщений const myMembership = useMemo(() => { @@ -148,6 +183,237 @@ export default function MessageDialogPage() { () => uniqueEmoji(recentReactions).slice(0, MAX_RECENT_REACTIONS), [recentReactions] ); + const selectedActionReaders = selectedActionMessage?.read_by || []; + const sendingComposer = Boolean(editingMessageId && sending); + + const clearPendingTimer = useCallback((localId?: string | null) => { + if (!localId) return; + + const timer = pendingTimersRef.current.get(localId); + if (timer) { + clearTimeout(timer); + pendingTimersRef.current.delete(localId); + } + }, []); + + const markPendingAsDelayed = useCallback((localId: string) => { + setPendingMessages((prev) => + prev.map((message) => + message.local_id === localId && message.send_state === "pending" + ? { ...message, send_state: "delayed" } + : message + ) + ); + }, []); + + const schedulePendingTimer = useCallback((localId: string) => { + clearPendingTimer(localId); + + const timer = setTimeout(() => { + pendingTimersRef.current.delete(localId); + markPendingAsDelayed(localId); + }, PENDING_MESSAGE_DELAY_MS); + + pendingTimersRef.current.set(localId, timer); + }, [clearPendingTimer, markPendingAsDelayed]); + + const removePendingMessage = useCallback((localId?: string | null) => { + if (!localId) return; + + clearPendingTimer(localId); + setPendingMessages((prev) => prev.filter((message) => message.local_id !== localId)); + }, [clearPendingTimer]); + + const clearAllPendingTimers = useCallback(() => { + pendingTimersRef.current.forEach((timer) => clearTimeout(timer)); + pendingTimersRef.current.clear(); + }, []); + + const flushMarkRead = useCallback(async () => { + if (!chatId || Number.isNaN(chatId) || markReadInFlightRef.current) { + return; + } + + const targetMessageId = queuedMarkReadIdRef.current; + if (typeof targetMessageId !== "number") { + return; + } + + if (targetMessageId <= lastConfirmedMarkReadIdRef.current) { + queuedMarkReadIdRef.current = null; + return; + } + + queuedMarkReadIdRef.current = null; + markReadInFlightRef.current = true; + + try { + await apiClient.markChatAsRead(chatId, targetMessageId); + lastConfirmedMarkReadIdRef.current = Math.max( + lastConfirmedMarkReadIdRef.current, + targetMessageId, + ); + setChat((prev) => + prev + ? { + ...prev, + last_read_message_id: Math.max(prev.last_read_message_id ?? 0, targetMessageId), + } + : prev + ); + } catch (error) { + console.error("Ошибка отложенной отметки прочтения:", error); + queuedMarkReadIdRef.current = Math.max( + queuedMarkReadIdRef.current ?? 0, + targetMessageId, + ); + } finally { + markReadInFlightRef.current = false; + + if ( + queuedMarkReadIdRef.current !== null && + queuedMarkReadIdRef.current > lastConfirmedMarkReadIdRef.current && + !markReadTimerRef.current + ) { + markReadTimerRef.current = setTimeout(() => { + markReadTimerRef.current = null; + void flushMarkRead(); + }, MARK_READ_DEBOUNCE_MS); + } + } + }, [chatId]); + + const scheduleMarkRead = useCallback((messageId?: number | null) => { + if (!chatId || typeof messageId !== "number") { + return; + } + + const nextTarget = Math.max(queuedMarkReadIdRef.current ?? 0, messageId); + if (nextTarget <= lastConfirmedMarkReadIdRef.current) { + return; + } + + queuedMarkReadIdRef.current = nextTarget; + + if (markReadInFlightRef.current) { + return; + } + + if (markReadTimerRef.current) { + clearTimeout(markReadTimerRef.current); + } + + markReadTimerRef.current = setTimeout(() => { + markReadTimerRef.current = null; + void flushMarkRead(); + }, MARK_READ_DEBOUNCE_MS); + }, [chatId, flushMarkRead]); + + const getCurrentUserDisplayName = useCallback(() => { + if (!user) return "Вы"; + + const fullName = [user.last_name, user.first_name, user.patronymic].filter(Boolean).join(" ").trim(); + return fullName || user.email || "Вы"; + }, [user]); + + const guessAttachmentType = useCallback((file: File): string => { + const mime = (file.type || "").toLowerCase(); + if (mime.startsWith("image/")) return "image"; + if (mime.startsWith("video/")) return "video"; + if (mime.startsWith("audio/")) return "audio"; + return "file"; + }, []); + + const getAttachmentSignature = useCallback((message: Message) => { + return (message.attachments || []) + .map((attachment) => `${attachment.file_name}|${attachment.file_size ?? 0}|${attachment.mime_type ?? ""}`) + .sort() + .join("||"); + }, []); + + const findMatchingPendingLocalId = useCallback((confirmedMessage: Message) => { + if (!user?.id || confirmedMessage.author_id !== user.id) { + return null; + } + + const confirmedReplyToId = getReplyToId(confirmedMessage) ?? null; + const confirmedAttachmentSignature = getAttachmentSignature(confirmedMessage); + const confirmedTimestamp = getMessageTimestamp(confirmedMessage); + + const candidates = pendingMessagesRef.current + .filter((pending) => pending.author_id === user.id) + .filter((pending) => pending.content === confirmedMessage.content) + .filter((pending) => (getReplyToId(pending) ?? null) === confirmedReplyToId) + .filter((pending) => getAttachmentSignature(pending) === confirmedAttachmentSignature) + .map((pending) => ({ + localId: pending.local_id ?? null, + diff: Math.abs(getMessageTimestamp(pending) - confirmedTimestamp), + })) + .filter((candidate) => Boolean(candidate.localId)) + .sort((left, right) => left.diff - right.diff); + + const matchedCandidate = candidates[0]; + if (!matchedCandidate) { + return null; + } + + if (confirmedTimestamp > 0 && matchedCandidate.diff > 10 * 60 * 1000) { + return null; + } + + return matchedCandidate.localId; + }, [getAttachmentSignature, user?.id]); + + const removePendingMessageByServerMessage = useCallback((confirmedMessage: Message) => { + const localId = findMatchingPendingLocalId(confirmedMessage); + if (!localId) { + return; + } + + removePendingMessage(localId); + }, [findMatchingPendingLocalId, removePendingMessage]); + + const buildOptimisticMessage = useCallback((text: string, files: File[], localId: string): Message => { + localMessageSequenceRef.current += 1; + const now = Date.now() + localMessageSequenceRef.current; + + return { + id: -now, + chat: chatId, + local_id: localId, + author_id: user?.id, + author_name: getCurrentUserDisplayName(), + avatar: user?.avatar, + content: text, + is_read: false, + send_state: "pending", + is_optimistic: true, + created_at: new Date(now).toISOString(), + created_ts: now, + has_attachments: files.length > 0, + attachments: files.map((file, index) => ({ + id: -(now * 100 + index), + file_name: file.name, + file_type: guessAttachmentType(file), + file_url: "", + file_size: file.size, + mime_type: file.type, + is_local: true, + })), + reply_to_id: replyTo?.id, + }; + }, [chatId, getCurrentUserDisplayName, guessAttachmentType, replyTo?.id, user?.avatar, user?.id]); + + const resetComposerState = useCallback(() => { + setEditingMessageId(null); + setMessageText(""); + setReplyTo(null); + setExpandedReplyActionForId(null); + setAttachedFiles([]); + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + }, []); // WebSocket для real-time обновлений const { isConnected, sendTyping, handlers } = useWebSocket({ @@ -217,23 +483,17 @@ export default function MessageDialogPage() { const response = await apiClient.getChatMessages(chatId, { limit: options.limit ?? 20, after_id: newestMessage.id, - mark_read: shouldMarkRead, + mark_read: false, }); const newerMessages = response.messages || []; if (newerMessages.length > 0) { + newerMessages.forEach(removePendingMessageByServerMessage); 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 - ); + scheduleMarkRead(lastLoadedMessage.id); } if (shouldMarkRead && options.scrollOnSync) { @@ -249,7 +509,7 @@ export default function MessageDialogPage() { } finally { fallbackSyncInFlightRef.current = false; } - }, [chatId, isNearBottom, messages, messagesLoading, scrollToBottom]); + }, [chatId, isNearBottom, messages, messagesLoading, removePendingMessageByServerMessage, scheduleMarkRead, scrollToBottom]); // Обновление reactions_summary для конкретного сообщения const updateMessageReactionsSummary = useCallback(( @@ -272,6 +532,7 @@ export default function MessageDialogPage() { const newMsg = data.message; const isMyMessage = newMsg.author_id === user?.id; const wasNearBottom = isNearBottom(); + removePendingMessageByServerMessage(newMsg); setMessages(prev => { // Проверяем, нет ли уже такого сообщения (дедупликация) @@ -308,9 +569,7 @@ export default function MessageDialogPage() { // 2. И я был внизу (активно читал) // Прагматичный подход: загружено = прочитано (для сообщений в области видимости) if (!isMyMessage && wasNearBottom && chatId) { - apiClient.markChatAsRead(chatId, newMsg.id).catch(err => { - console.error('Ошибка автоотметки WebSocket сообщения:', err); - }); + scheduleMarkRead(newMsg.id); } // Автопрокрутка вниз если: @@ -399,6 +658,10 @@ export default function MessageDialogPage() { } if (readerUserId === null || readerUserId === user?.id) { + lastConfirmedMarkReadIdRef.current = Math.max( + lastConfirmedMarkReadIdRef.current, + lastReadMessageId, + ); setChat((prev) => prev ? { @@ -425,7 +688,7 @@ export default function MessageDialogPage() { handlers.current.onError = (error) => { console.error('❌ WebSocket error:', error); }; - }, [handlers, user?.id, chatId, isNearBottom, scrollToBottom, syncLatestMessages, updateMessageReactionsSummary]); + }, [handlers, user?.id, chatId, isNearBottom, removePendingMessageByServerMessage, scheduleMarkRead, scrollToBottom, syncLatestMessages, updateMessageReactionsSummary]); // Очистка таймаута при размонтировании useEffect(() => { @@ -436,8 +699,12 @@ export default function MessageDialogPage() { if (floatingDateTimeoutRef.current) { clearTimeout(floatingDateTimeoutRef.current); } + if (markReadTimerRef.current) { + clearTimeout(markReadTimerRef.current); + } + clearAllPendingTimers(); }; - }, []); + }, [clearAllPendingTimers]); useLayoutEffect(() => { if (typeof window === "undefined" || !window.history) return; @@ -542,7 +809,9 @@ export default function MessageDialogPage() { useEffect(() => { async function loadMessages() { if (!chatId || Number.isNaN(chatId)) { + clearAllPendingTimers(); setMessages([]); + setPendingMessages([]); setHasMoreOlder(false); setHasMoreNewer(false); setInitialAnchorId(null); @@ -576,8 +845,10 @@ export default function MessageDialogPage() { const aroundMessages = around.messages || []; if (aroundMessages.length > 0) { + clearAllPendingTimers(); const normalized = uniqueMessagesById(aroundMessages); setMessages(normalized); + setPendingMessages([]); // fallback на случай, если API не вернул флаг has_more_before setHasMoreOlder( typeof around.has_more_before === "boolean" ? around.has_more_before : normalized.length >= 50 @@ -587,8 +858,10 @@ export default function MessageDialogPage() { setInitialAnchorIndex(typeof around.anchor_index === "number" ? around.anchor_index : null); setAllowOneOlderProbe(Boolean(around.anchor_id)); } else { + clearAllPendingTimers(); const response = await apiClient.getChatMessages(chatId, { limit: 50 }); setMessages(uniqueMessagesById(response.messages || [])); + setPendingMessages([]); setHasMoreOlder(Boolean(response.has_more)); setHasMoreNewer(false); setInitialAnchorId(null); @@ -604,7 +877,9 @@ export default function MessageDialogPage() { const errorMessage = error instanceof Error ? error.message : String(error); if (errorMessage.includes("403")) { setError("Нет доступа к этому чату"); + clearAllPendingTimers(); setMessages([]); + setPendingMessages([]); } else { console.error("Ошибка загрузки сообщений:", error); } @@ -623,13 +898,13 @@ export default function MessageDialogPage() { } loadMessages(); - }, [chatId, userLoading, user, user?.id]); + }, [chatId, clearAllPendingTimers, userLoading, user, user?.id]); // Скролл к якорю или вниз после загрузки сообщений // Anchor scroll используется при переходе по прямой ссылке на сообщение (например, из уведомления) useEffect(() => { const component = messagesViewportRef.current; - if (messagesLoading || !component || !component.containerRef?.current || messages.length === 0 || initialScrolledRef.current) return; + if (messagesLoading || !component || !component.containerRef?.current || displayMessages.length === 0 || initialScrolledRef.current) return; const viewport = component.containerRef.current; @@ -686,7 +961,7 @@ export default function MessageDialogPage() { anchorOlderLoadAttemptsRef.current = 0; }, [ messagesLoading, - messages, + displayMessages, initialAnchorId, initialAnchorIndex, anchorRetryTick, @@ -745,23 +1020,17 @@ export default function MessageDialogPage() { const response = await apiClient.getChatMessages(chatId, { limit: 10, // Ограничено для минимальной погрешности автоотметки (было 40) after_id: newestMessage.id, - mark_read: shouldMarkRead, + mark_read: false, }); const newerMessages = response.messages || []; if (newerMessages.length > 0) { + newerMessages.forEach(removePendingMessageByServerMessage); 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 - ); + scheduleMarkRead(lastLoadedMessage.id); } // НЕ делаем автоскролл - ScrollableMessageList сам решит: @@ -776,7 +1045,7 @@ export default function MessageDialogPage() { } finally { setLoadingNewer(false); } - }, [chatId, hasMoreNewer, isNearBottom, loadingNewer, messages, messagesLoading]); + }, [chatId, hasMoreNewer, isNearBottom, loadingNewer, messages, messagesLoading, removePendingMessageByServerMessage, scheduleMarkRead]); useChatFallbackSync({ chatId: chatId || null, @@ -840,15 +1109,22 @@ export default function MessageDialogPage() { // Показ кнопки scroll-to-bottom когда не внизу const isAtBottom = isNearBottom(); setShowScrollToBottom(!isAtBottom); + + if (isAtBottom && !hasMoreNewer && messages.length > 0) { + const latestLoadedMessage = messages[messages.length - 1]; + scheduleMarkRead(latestLoadedMessage.id); + } }; viewport.addEventListener("scroll", onScroll, { passive: true }); return () => viewport.removeEventListener("scroll", onScroll); - }, [chatId, hasMoreOlder, hasMoreNewer, isNearBottom, loadNewerMessages, loadOlderMessages, loadingOlder, loadingNewer, messagesLoading, messages]); + }, [chatId, displayMessages, hasMoreOlder, hasMoreNewer, isNearBottom, loadNewerMessages, loadOlderMessages, loadingOlder, loadingNewer, messages, messagesLoading, scheduleMarkRead]); const handleSend = async () => { const text = messageText.trim(); - if (!chatId || sending) return; + let optimisticLocalId: string | null = null; + + if (!chatId || (editingMessageId && sending)) return; if (editingMessageId) { if (!text) return; } else if (!text && attachedFiles.length === 0) { @@ -859,24 +1135,38 @@ export default function MessageDialogPage() { const wasNearBottom = isNearBottom(); try { - setSending(true); if (editingMessageId) { + setSending(true); const updated = await apiClient.updateMessage(editingMessageId, text); setMessages((prev) => prev.map((m) => (m.id === editingMessageId ? { ...m, ...updated } : m))); + resetComposerState(); } else { - const sent = attachedFiles.length - ? await apiClient.sendMessageWithFiles(chatId, text, attachedFiles, replyTo?.id) - : await apiClient.sendMessage(chatId, text, replyTo?.id); - setMessages((prev) => uniqueMessagesById([...prev, sent])); - } + const files = [...attachedFiles]; + const replyToId = replyTo?.id; + const localId = typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" + ? crypto.randomUUID() + : `local-${Date.now()}-${Math.random().toString(16).slice(2)}`; + optimisticLocalId = localId; + const optimisticMessage = buildOptimisticMessage(text, files, localId); - setEditingMessageId(null); - setMessageText(""); - setReplyTo(null); - setExpandedReplyActionForId(null); - setAttachedFiles([]); - if (fileInputRef.current) { - fileInputRef.current.value = ""; + setPendingMessages((prev) => [...prev, optimisticMessage]); + schedulePendingTimer(localId); + resetComposerState(); + + const sent = files.length + ? await apiClient.sendMessageWithFiles(chatId, text, files, replyToId) + : await apiClient.sendMessage(chatId, text, replyToId); + + removePendingMessage(localId); + setMessages((prev) => uniqueMessagesById([...prev, sent])); + setHasMoreNewer(false); + + if (wasNearBottom) { + requestAnimationFrame(() => { + scrollToBottom(false); + }); + } + return; } // Автоскролл вниз ТОЛЬКО если был внизу при отправке @@ -888,8 +1178,20 @@ export default function MessageDialogPage() { } } catch (e) { console.error("Ошибка отправки сообщения:", e); + if (!editingMessageId && optimisticLocalId) { + clearPendingTimer(optimisticLocalId); + setPendingMessages((prev) => + prev.map((message) => + message.local_id === optimisticLocalId + ? { ...message, send_state: "failed" } + : message + ) + ); + } } finally { - setSending(false); + if (editingMessageId) { + setSending(false); + } } }; @@ -922,6 +1224,7 @@ export default function MessageDialogPage() { setEditingMessageId(null); setReplyTo({ id: message.id, author, preview }); setExpandedReplyActionForId(null); + setIsReadersModalOpen(false); setActionsMenuAnchor(null); requestAnimationFrame(focusMessageInput); }; @@ -980,6 +1283,7 @@ export default function MessageDialogPage() { } setExpandedReplyActionForId(null); + setIsReadersModalOpen(false); setActionsMenuAnchor(null); } catch (e) { console.error("Ошибка удаления сообщения:", e); @@ -989,10 +1293,12 @@ export default function MessageDialogPage() { const handleToggleMessageActions = useCallback((messageId: number, anchor: MessageActionsAnchor) => { setExpandedReplyActionForId((prev) => { if (prev === messageId) { + setIsReadersModalOpen(false); setActionsMenuAnchor(null); return null; } + setIsReadersModalOpen(false); setReactionPickerForMessageId(null); setShowComposerEmojiPicker(false); setActionsMenuAnchor(anchor); @@ -1173,14 +1479,14 @@ export default function MessageDialogPage() { > {messagesLoading ? (

Загрузка сообщений...

- ) : messages.length === 0 ? ( + ) : displayMessages.length === 0 ? (

Пока нет сообщений. Напишите первым.

) : (
{loadingOlder ? (

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

) : null} - {messages.map((message) => { + {displayMessages.map((message) => { const replyToId = getReplyToId(message); const repliedMessage = replyToId ? messagesById.get(replyToId) : null; @@ -1191,8 +1497,8 @@ export default function MessageDialogPage() { currentUserId={user?.id} repliedMessage={repliedMessage} isActionsOpen={expandedReplyActionForId === message.id} - canManage={canManageMessage(message)} - canReply={!message.is_deleted} + canManage={!message.is_optimistic && canManageMessage(message)} + canReply={!message.is_deleted && !message.is_optimistic} brokenMedia={brokenMedia} useOriginalImage={useOriginalImage} onToggleActions={handleToggleMessageActions} @@ -1248,7 +1554,7 @@ export default function MessageDialogPage() { replyTo={replyTo} attachedFiles={attachedFiles} messageText={messageText} - sending={sending} + sending={sendingComposer} showEmojiPicker={showComposerEmojiPicker} allReactions={ALL_REACTIONS} fileInputRef={fileInputRef} @@ -1293,6 +1599,7 @@ export default function MessageDialogPage() { {expandedReplyActionForId && actionsMenuAnchor && selectedActionMessage ? ( { handleReact(selectedActionMessage, emoji); setExpandedReplyActionForId(null); + setIsReadersModalOpen(false); setActionsMenuAnchor(null); }} onOpenReactionPicker={() => setReactionPickerForMessageId(selectedActionMessage.id)} + onShowAllReaders={() => setIsReadersModalOpen(true)} onReply={() => handleReplyToMessage(selectedActionMessage)} onEdit={() => handleStartEditMessage(selectedActionMessage)} onDelete={() => handleDeleteMessage(selectedActionMessage)} /> ) : null} + {selectedActionMessage ? ( + setIsReadersModalOpen(false)} + readers={selectedActionReaders} + /> + ) : null} + {reactionPickerForMessageId ? ( + + + ); + } + + if (sendState === "delayed") { + return ( + + + + ); + } + + if (sendState === "failed") { + return ( + + + + ); + } + const Icon = isRead ? CheckCheck : Check; return ( @@ -83,6 +107,7 @@ export default function ChatMessageItem({ ); const hasActions = canReply || canManage; const isRead = Boolean(message.is_read); + const sendState = message.send_state; return (
@@ -146,6 +171,19 @@ export default function ChatMessageItem({ {message.attachments && message.attachments.length > 0 ? (
{message.attachments.map((attachment) => { + if (attachment.is_local) { + return ( +
+ + {attachment.file_name} + {formatFileSize(attachment.file_size)} +
+ ); + } + const fileUrl = resolveMediaUrl(attachment.file_url); if (isImageAttachment(attachment)) { @@ -284,7 +322,7 @@ export default function ChatMessageItem({

{messageTime(message)}

- {isMine ? : null} + {isMine ? : null}
{Object.keys(message.reactions_summary || {}).length > 0 ? ( diff --git a/frontend/src/components/messages/MessageActionsMenu.tsx b/frontend/src/components/messages/MessageActionsMenu.tsx index a97d8d58..905e691e 100644 --- a/frontend/src/components/messages/MessageActionsMenu.tsx +++ b/frontend/src/components/messages/MessageActionsMenu.tsx @@ -1,17 +1,19 @@ "use client"; -import { Pencil, Reply, Smile, Trash2 } from "lucide-react"; +import { CheckCheck, Pencil, Reply, Smile, Trash2 } from "lucide-react"; import type { Message } from "@/types/api"; type MessageActionsMenuProps = { anchor: { x: number; y: number }; + currentUserId?: number; message: Message; canReply: boolean; canManage: boolean; recentReactions: string[]; onQuickReact: (emoji: string) => void; onOpenReactionPicker: () => void; + onShowAllReaders?: () => void; onReply: () => void; onEdit: () => void; onDelete: () => void; @@ -19,34 +21,49 @@ type MessageActionsMenuProps = { export default function MessageActionsMenu({ anchor, + currentUserId, message, canReply, canManage, recentReactions, onQuickReact, onOpenReactionPicker, + onShowAllReaders, onReply, onEdit, onDelete, }: MessageActionsMenuProps) { + const isMine = Boolean( + currentUserId && + (message.author_id === currentUserId || message.author?.id === currentUserId || message.sender?.id === currentUserId) + ); + const readers = message.read_by || []; + const previewReaders = readers.slice(0, 3); + const hasMoreReaders = readers.length > previewReaders.length; + return (
{canReply ? ( -
+
+
+ Быстрые реакции +
+ +
{recentReactions.map((emoji) => ( +
) : null} - {canReply ? ( - + {isMine ? ( +
+
+ + Прочитали +
+ + {readers.length > 0 ? ( + <> +
+ {previewReaders.map((reader) => ( +
+ {reader.name} +
+ ))} +
+ + {onShowAllReaders ? ( + + ) : null} + + ) : ( +

Пока никто не дочитал это сообщение.

+ )} +
) : null} - {canManage ? ( - <> +
+ {canReply ? ( + + ) : null} + + {canManage ? ( + ) : null} + {canManage ? ( - - ) : null} + ) : null} +
); } \ No newline at end of file diff --git a/frontend/src/components/messages/MessageReadersModal.tsx b/frontend/src/components/messages/MessageReadersModal.tsx new file mode 100644 index 00000000..58bee6bc --- /dev/null +++ b/frontend/src/components/messages/MessageReadersModal.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { CheckCheck } from "lucide-react"; + +import { Modal } from "@/components/ui"; +import type { MessageReader } from "@/types/api"; + +type MessageReadersModalProps = { + isOpen: boolean; + onClose: () => void; + readers: MessageReader[]; +}; + +export default function MessageReadersModal({ isOpen, onClose, readers }: MessageReadersModalProps) { + return ( + +
+
+
+ + + +
+

Полный список прочитавших

+

+ {readers.length === 1 ? "1 сотрудник" : `${readers.length} сотрудников`} +

+
+
+
+ + {readers.length > 0 ? ( +
+ {readers.map((reader) => ( +
+ {reader.name} +
+ ))} +
+ ) : ( +
+ Пока никто не дочитал это сообщение. +
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/hooks/useWebSocket.ts b/frontend/src/hooks/useWebSocket.ts index d6b33ad2..52948aab 100644 --- a/frontend/src/hooks/useWebSocket.ts +++ b/frontend/src/hooks/useWebSocket.ts @@ -36,6 +36,8 @@ interface UseWebSocketOptions { }) => void; } +const TYPING_THROTTLE_MS = 2000; + export function useWebSocket({ chatId, autoConnect = true, @@ -51,6 +53,7 @@ export function useWebSocket({ const reconnectAttemptsRef = useRef(0); const shouldReconnectRef = useRef(autoConnect); const connectRef = useRef<() => void>(() => {}); + const lastTypingSentAtRef = useRef(0); const handlers = useRef({ onMessage: () => {}, @@ -192,15 +195,24 @@ export function useWebSocket({ reconnectAttemptsRef.current = 0; setReconnectAttempts(0); isConnectingRef.current = false; + lastTypingSentAtRef.current = 0; }, [chatId]); const sendTyping = useCallback(() => { - if (wsRef.current?.readyState === WebSocket.OPEN) { - wsRef.current.send(JSON.stringify({ - action: 'typing' - })); + if (!chatId || wsRef.current?.readyState !== WebSocket.OPEN) { + return; } - }, []); + + const now = Date.now(); + if (now - lastTypingSentAtRef.current < TYPING_THROTTLE_MS) { + return; + } + + lastTypingSentAtRef.current = now; + wsRef.current.send(JSON.stringify({ + action: 'typing' + })); + }, [chatId]); const sendMessage = useCallback((message: Record) => { if (wsRef.current?.readyState === WebSocket.OPEN) { diff --git a/frontend/src/lib/messages/chatUtils.ts b/frontend/src/lib/messages/chatUtils.ts index f8c918f7..f7353000 100644 --- a/frontend/src/lib/messages/chatUtils.ts +++ b/frontend/src/lib/messages/chatUtils.ts @@ -121,4 +121,38 @@ export function uniqueMessagesById(items: Message[]): Message[] { map.set(message.id, message); }); return Array.from(map.values()); +} + +export function getMessageTimestamp(message: Message): number { + if (typeof message.created_ts === "number") { + return message.created_ts; + } + + if (message.created_at) { + const value = new Date(message.created_at).getTime(); + if (!Number.isNaN(value)) { + return value; + } + } + + return 0; +} + +export function mergeDisplayMessages(serverMessages: Message[], pendingMessages: Message[]): Message[] { + const combined = [...serverMessages, ...pendingMessages].map((message, index) => ({ message, index })); + + combined.sort((left, right) => { + const timestampDiff = getMessageTimestamp(left.message) - getMessageTimestamp(right.message); + if (timestampDiff !== 0) { + return timestampDiff; + } + + if (left.message.is_optimistic !== right.message.is_optimistic) { + return left.message.is_optimistic ? 1 : -1; + } + + return left.index - right.index; + }); + + return combined.map(({ message }) => message); } \ No newline at end of file diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index db785793..068559e4 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -507,6 +507,7 @@ export interface ChatUserSettings { export interface Message { id: number; chat?: number; + local_id?: string | null; sender?: User; author?: User; author_id?: number; @@ -514,6 +515,10 @@ export interface Message { avatar?: string; content: string; is_read?: boolean; + read_count?: number; + read_by?: MessageReader[]; + send_state?: 'pending' | 'delayed' | 'failed'; + is_optimistic?: boolean; created_at?: string; created?: string; created_ts?: number; @@ -531,6 +536,11 @@ export interface Message { }>; } +export interface MessageReader { + id: number; + name: string; +} + export interface MessageAttachment { id: number; file_name: string; @@ -541,6 +551,7 @@ export interface MessageAttachment { width?: number; height?: number; thumbnail?: string | null; + is_local?: boolean; } // Calendar types (django-scheduler)