Improve chat delivery states and read details
This commit is contained in:
parent
d1aea28be9
commit
c5f53cca15
@ -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 уведомление
|
||||
|
||||
@ -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]
|
||||
)
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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/"
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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 ? (
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
51
frontend/src/components/messages/MessageReadersModal.tsx
Normal file
51
frontend/src/components/messages/MessageReadersModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user