Improve chat delivery states and read details

This commit is contained in:
lizerk 2026-04-03 11:10:14 +03:00
parent d1aea28be9
commit c5f53cca15
11 changed files with 711 additions and 90 deletions

View File

@ -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 уведомление

View File

@ -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]
)

View File

@ -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:

View File

@ -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/"

View File

@ -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<Chat | null>(null);
const [messages, setMessages] = useState<Message[]>([]);
const [pendingMessages, setPendingMessages] = useState<Message[]>([]);
const [loading, setLoading] = useState(true);
const [messagesLoading, setMessagesLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@ -71,6 +75,7 @@ export default function MessageDialogPage() {
const [replyTo, setReplyTo] = useState<ReplyTarget | null>(null);
const [expandedReplyActionForId, setExpandedReplyActionForId] = useState<number | null>(null);
const [actionsMenuAnchor, setActionsMenuAnchor] = useState<{ x: number; y: number } | null>(null);
const [isReadersModalOpen, setIsReadersModalOpen] = useState(false);
const [reactionPickerForMessageId, setReactionPickerForMessageId] = useState<number | null>(null);
const [showComposerEmojiPicker, setShowComposerEmojiPicker] = useState(false);
const [recentReactions, setRecentReactions] = useState<string[]>(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<Map<string, NodeJS.Timeout>>(new Map());
const pendingMessagesRef = useRef<Message[]>([]);
const localMessageSequenceRef = useRef(0);
const markReadTimerRef = useRef<NodeJS.Timeout | null>(null);
const markReadInFlightRef = useRef(false);
const queuedMarkReadIdRef = useRef<number | null>(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 ? (
<p className="text-center text-sm text-gray-500">Загрузка сообщений...</p>
) : messages.length === 0 ? (
) : displayMessages.length === 0 ? (
<p className="text-center text-sm text-gray-500">Пока нет сообщений. Напишите первым.</p>
) : (
<div className="flex min-h-full flex-col justify-end">
{loadingOlder ? (
<p className="mb-3 text-center text-xs text-gray-500">Подгружаем старые сообщения...</p>
) : 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 ? (
<MessageActionsMenu
anchor={actionsMenuAnchor}
currentUserId={user?.id}
message={selectedActionMessage}
canReply={selectedActionCanReply}
canManage={selectedActionCanManage}
@ -1300,15 +1607,25 @@ export default function MessageDialogPage() {
onQuickReact={(emoji) => {
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 ? (
<MessageReadersModal
isOpen={isReadersModalOpen}
onClose={() => setIsReadersModalOpen(false)}
readers={selectedActionReaders}
/>
) : null}
{reactionPickerForMessageId ? (
<ReactionPickerModal
allReactions={ALL_REACTIONS}

View File

@ -1,7 +1,7 @@
"use client";
import Image from "next/image";
import { Check, CheckCheck, ChevronRight, FileText } from "lucide-react";
import { AlertCircle, Check, CheckCheck, ChevronRight, Clock3, FileText, Loader2 } from "lucide-react";
import { resolveMediaUrl } from "@/lib/url";
import {
@ -44,7 +44,31 @@ function getMessageAuthorLabel(message: Message): string {
return message.author_name || message.author?.last_name || message.sender?.last_name || "Сотрудник";
}
function MessageDeliveryStatus({ isRead }: { isRead: boolean }) {
function MessageDeliveryStatus({ sendState, isRead }: { sendState?: Message["send_state"]; isRead: boolean }) {
if (sendState === "pending") {
return (
<span className="inline-flex items-center text-sky-100" title="Отправляется" aria-label="Отправляется">
<Loader2 size={13} strokeWidth={2.2} className="animate-spin" />
</span>
);
}
if (sendState === "delayed") {
return (
<span className="inline-flex items-center text-sky-100" title="Сервер отвечает медленно" aria-label="Сервер отвечает медленно">
<Clock3 size={13} strokeWidth={2.2} />
</span>
);
}
if (sendState === "failed") {
return (
<span className="inline-flex items-center text-rose-200" title="Не отправлено" aria-label="Не отправлено">
<AlertCircle size={13} strokeWidth={2.2} />
</span>
);
}
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 (
<div data-message-id={message.id} data-message-date={currentDate?.toISOString() || ""} className="mb-3 last:mb-0">
@ -146,6 +171,19 @@ export default function ChatMessageItem({
{message.attachments && message.attachments.length > 0 ? (
<div className="mt-2 space-y-2">
{message.attachments.map((attachment) => {
if (attachment.is_local) {
return (
<div
key={attachment.id}
className={`flex items-center gap-2 rounded-lg px-3 py-2 text-sm ${isMine ? "bg-sky-400/25 text-sky-50" : "bg-white/80 text-gray-700 ring-1 ring-gray-200"}`}
>
<FileText size={16} className="shrink-0" />
<span className="min-w-0 flex-1 truncate">{attachment.file_name}</span>
<span className={`shrink-0 text-xs ${isMine ? "text-sky-100" : "text-gray-500"}`}>{formatFileSize(attachment.file_size)}</span>
</div>
);
}
const fileUrl = resolveMediaUrl(attachment.file_url);
if (isImageAttachment(attachment)) {
@ -284,7 +322,7 @@ export default function ChatMessageItem({
<div className={`mt-1 flex items-center ${isMine ? "justify-end gap-1.5" : "justify-end"}`}>
<p className={`text-right text-[11px] ${isMine ? "text-sky-100" : "text-gray-400"}`}>{messageTime(message)}</p>
{isMine ? <MessageDeliveryStatus isRead={isRead} /> : null}
{isMine ? <MessageDeliveryStatus sendState={sendState} isRead={isRead} /> : null}
</div>
{Object.keys(message.reactions_summary || {}).length > 0 ? (

View File

@ -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 (
<div
data-actions-menu="true"
className="fixed z-[60] flex min-w-[176px] flex-col gap-1 rounded-lg border border-gray-200 bg-white p-1 shadow-xl"
className="fixed z-[60] flex min-w-[240px] max-w-[280px] flex-col gap-2 rounded-2xl border border-gray-200/80 bg-white/98 p-2 text-gray-900 shadow-[0_18px_50px_-18px_rgba(15,23,42,0.35)] backdrop-blur"
style={{
left: anchor.x,
top: anchor.y - 6,
top: anchor.y - 8,
transform: "translate(-100%, -100%)",
}}
>
{canReply ? (
<div className="mb-1 flex items-center gap-1 rounded-md bg-gray-50 p-1">
<div className="rounded-xl border border-gray-200 bg-gray-50/80 p-1.5">
<div className="mb-1 flex items-center justify-between px-1">
<span className="text-[11px] font-semibold uppercase tracking-[0.08em] text-gray-400">Быстрые реакции</span>
</div>
<div className="flex items-center gap-1">
{recentReactions.map((emoji) => (
<button
key={`recent-${message.id}-${emoji}`}
type="button"
onClick={() => onQuickReact(emoji)}
className="inline-flex h-7 w-7 items-center justify-center rounded-md bg-white text-base hover:bg-sky-50"
className="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-white bg-white text-base shadow-sm transition hover:-translate-y-0.5 hover:border-sky-200 hover:bg-sky-50"
title="Быстрая реакция"
>
{emoji}
@ -55,46 +72,88 @@ export default function MessageActionsMenu({
<button
type="button"
onClick={onOpenReactionPicker}
className="ml-auto inline-flex h-7 w-7 items-center justify-center rounded-md bg-white text-gray-600 hover:bg-sky-50 hover:text-sky-700"
className="ml-auto inline-flex h-8 w-8 items-center justify-center rounded-lg border border-white bg-white text-gray-600 shadow-sm transition hover:border-sky-200 hover:bg-sky-50 hover:text-sky-700"
title="Все смайлы"
>
<Smile size={14} />
</button>
</div>
</div>
) : null}
{canReply ? (
<button
type="button"
onClick={onReply}
className="inline-flex w-full items-center gap-1 rounded-md border border-sky-200 bg-sky-50 px-2 py-1 text-xs font-medium text-sky-700 hover:bg-sky-100"
>
<Reply size={12} />
Ответить
</button>
{isMine ? (
<div className="rounded-xl border border-emerald-200/80 bg-emerald-50/70 p-2.5">
<div className="mb-1.5 flex items-center gap-1.5 text-emerald-800">
<CheckCheck size={13} />
<span className="text-[11px] font-semibold uppercase tracking-[0.08em]">Прочитали</span>
</div>
{readers.length > 0 ? (
<>
<div className="flex flex-col gap-1.5">
{previewReaders.map((reader) => (
<div
key={`reader-${message.id}-${reader.id}`}
className="rounded-lg bg-white/85 px-2 py-1.5 text-xs font-medium leading-4 text-gray-700 ring-1 ring-emerald-100 break-words"
>
{reader.name}
</div>
))}
</div>
{onShowAllReaders ? (
<button
type="button"
onClick={onShowAllReaders}
className="mt-2 inline-flex w-full items-center justify-between rounded-lg border border-emerald-200 bg-white/90 px-2.5 py-2 text-left text-xs font-semibold text-emerald-800 transition hover:bg-white"
>
<span>{hasMoreReaders ? "Показать весь список" : "Открыть список"}</span>
<span className="rounded-full bg-emerald-100 px-2 py-0.5 text-[11px] font-semibold text-emerald-900">
{readers.length}
</span>
</button>
) : null}
</>
) : (
<p className="text-xs text-gray-500">Пока никто не дочитал это сообщение.</p>
)}
</div>
) : null}
{canManage ? (
<>
<div className="flex flex-col gap-1">
{canReply ? (
<button
type="button"
onClick={onReply}
className="inline-flex w-full items-center gap-2 rounded-xl border border-sky-200 bg-sky-50 px-3 py-2 text-xs font-medium text-sky-700 transition hover:bg-sky-100"
>
<Reply size={13} />
Ответить
</button>
) : null}
{canManage ? (
<button
type="button"
onClick={onEdit}
className="inline-flex w-full items-center gap-1 rounded-md border border-gray-200 bg-white px-2 py-1 text-xs font-medium text-gray-700 hover:bg-gray-50"
className="inline-flex w-full items-center gap-2 rounded-xl border border-gray-200 bg-white px-3 py-2 text-xs font-medium text-gray-700 transition hover:bg-gray-50"
>
<Pencil size={12} />
<Pencil size={13} />
Редактировать
</button>
) : null}
{canManage ? (
<button
type="button"
onClick={onDelete}
className="inline-flex w-full items-center gap-1 rounded-md border border-red-200 bg-red-50 px-2 py-1 text-xs font-medium text-red-700 hover:bg-red-100"
className="inline-flex w-full items-center gap-2 rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-xs font-medium text-red-700 transition hover:bg-red-100"
>
<Trash2 size={12} />
<Trash2 size={13} />
Удалить
</button>
</>
) : null}
) : null}
</div>
</div>
);
}

View File

@ -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 (
<Modal isOpen={isOpen} onClose={onClose} title="Прочитали сообщение" size="sm">
<div className="space-y-4">
<div className="rounded-2xl border border-emerald-200/80 bg-emerald-50/80 p-4">
<div className="flex items-center gap-2 text-emerald-900">
<span className="inline-flex h-8 w-8 items-center justify-center rounded-full bg-white/90 ring-1 ring-emerald-100">
<CheckCheck size={16} />
</span>
<div>
<p className="text-sm font-semibold">Полный список прочитавших</p>
<p className="text-xs text-emerald-800/80">
{readers.length === 1 ? "1 сотрудник" : `${readers.length} сотрудников`}
</p>
</div>
</div>
</div>
{readers.length > 0 ? (
<div className="max-h-[52vh] space-y-2 overflow-y-auto pr-1">
{readers.map((reader) => (
<div
key={`message-reader-${reader.id}`}
className="rounded-2xl border border-gray-200 bg-white px-3 py-3 text-sm font-medium leading-5 text-gray-800 shadow-sm"
>
{reader.name}
</div>
))}
</div>
) : (
<div className="rounded-2xl border border-dashed border-gray-200 bg-gray-50 px-4 py-6 text-center text-sm text-gray-500">
Пока никто не дочитал это сообщение.
</div>
)}
</div>
</Modal>
);
}

View File

@ -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<WebSocketHandlers>({
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<string, unknown>) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {

View File

@ -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);
}

View File

@ -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)