diff --git a/backend/communications/api/serializers.py b/backend/communications/api/serializers.py index e7e0f5f0..2e773110 100644 --- a/backend/communications/api/serializers.py +++ b/backend/communications/api/serializers.py @@ -134,12 +134,20 @@ class ChatListSerializer(serializers.ModelSerializer): @extend_schema_field(ChatLastMessageSerializer) def get_last_message(self, obj): - """Последнее сообщение в чате""" - last_msg = ( - obj.messages.filter(is_deleted=False) - .order_by("-created_at") - .first() - ) + """Последнее сообщение в чате (использует prefetch если доступен)""" + last_msg = None + if ( + hasattr(obj, '_prefetched_last_message') + and obj._prefetched_last_message + ): + last_msg = obj._prefetched_last_message[0] + else: + last_msg = ( + obj.messages.filter(is_deleted=False) + .select_related('author') + .order_by("-created_at") + .first() + ) if last_msg: return { "id": last_msg.id, @@ -153,12 +161,11 @@ class ChatListSerializer(serializers.ModelSerializer): @extend_schema_field(serializers.ListField(child=serializers.CharField())) def get_participant_names(self, obj): - """Имена участников (для приватных чатов)""" + """Имена участников (для приватных чатов, из prefetch)""" if obj.type == "private": - active_members = obj.memberships.filter( - is_active=True - ).select_related("user")[:5] - return [m.user.get_full_name() for m in active_members] + # memberships уже prefetch'нуты в ChatViewSet.get_queryset + members = list(obj.memberships.all())[:5] + return [m.user.get_full_name() for m in members] return [] @extend_schema_field( diff --git a/backend/communications/api/viewsets.py b/backend/communications/api/viewsets.py index 736a82d2..243eb75f 100644 --- a/backend/communications/api/viewsets.py +++ b/backend/communications/api/viewsets.py @@ -116,7 +116,6 @@ class ChatViewSet(viewsets.ModelViewSet): queryset = Chat.objects.filter( Q(memberships__user=user, memberships__is_active=True) | Q(participants=user) - | Q(participants=user) | Q(include_all_users=True) | Q(created_by=user) # Создатель всегда видит свои чаты ).select_related( @@ -139,7 +138,15 @@ class ChatViewSet(viewsets.ModelViewSet): 'read_states', queryset=ChatReadState.objects.filter(user=user), to_attr='my_read_state' - ) + ), + # Prefetch последнее сообщение чата (1 запрос вместо N) + Prefetch( + 'messages', + queryset=Message.objects.filter( + is_deleted=False, + ).select_related('author').order_by('-created_at')[:1], + to_attr='_prefetched_last_message' + ), ).distinct() # Исключаем чаты-комментарии из общего списка diff --git a/docs/reports/CHAT_REFACTORING_ANALYSIS.md b/docs/reports/CHAT_REFACTORING_ANALYSIS.md new file mode 100644 index 00000000..965ab3f9 --- /dev/null +++ b/docs/reports/CHAT_REFACTORING_ANALYSIS.md @@ -0,0 +1,150 @@ +# Отчёт: Рефакторинг чатов EUSRR + Сравнительный анализ + +## 1. Что было сделано + +### 1.1 Frontend — Декомпозиция монолита `page.tsx` + +| Метрика | До | После | +|---|---|---| +| Строк в `page.tsx` | **1 989** | **893** (−55%) | +| `useState` в `page.tsx` | 40+ | 18 (UI-only) | +| `useEffect` в `page.tsx` | 15+ | 10 | +| `useCallback` в `page.tsx` | 20+ | ~12 | +| Выделенные хуки | 0 | 3 новых | + +**Новые файлы:** + +- `hooks/useMarkRead.ts` — дебаунсированная отметка прочтения с очередью, watermark-синхронизация. +- `hooks/useChatMessages.ts` — состояние сообщений, пагинация, pending/optimistic, unread-трекинг, навигация по дате, возврат к непрочитанным. +- `hooks/useChatScroll.ts` — управление скроллом, floating date, кнопка «вниз», определение позиции. + +### 1.2 Исправление бага со счётчиком непрочитанных + +**Причина:** `flushMarkRead` после `markChatAsRead` вызывал `getChat(chatId)` и перезаписывал `newMessagesBelowCount` серверным значением (0), из-за чего бейдж исчезал мгновенно. + +**Решение:** `onReadAcknowledged` в `useMarkRead` теперь **не трогает** локальный счётчик. Сброс `newMessagesBelowCount` происходит **только** когда пользователь физически доскроллит до низа (`syncScrollToBottomState`). Серверный `chat.unread_count` обновляется отдельно через `setChat(fresh)`. + +### 1.3 Backend — N+1 оптимизации + +| Проблема | Файл | Решение | +|---|---|---| +| `get_last_message` делал N запросов (1 на чат) | `serializers.py` | Добавлен `Prefetch('messages', ...)` с `to_attr='_prefetched_last_message'` в `ChatViewSet.get_queryset` | +| `get_participant_names` перезапрашивал memberships | `serializers.py` | Теперь использует уже prefetch'нутый `obj.memberships.all()` | +| Дублирующийся `Q(participants=user)` | `viewsets.py` | Удалён дубль | + +**Уже было оптимизировано (не трогали):** +- `reactions__user` — prefetch в `MessageViewSet.get_queryset` ✓ +- `ChatReadState.unread_count` — денормализовано через сигнал ✓ +- `react`/`unreact` — уже используют `select_related('user')` ✓ + +--- + +## 2. Оценка нагрузки на БД + +### 2.1 Список чатов (`GET /chats/`) + +| Ресурс | Запросов (до) | Запросов (после) | +|---|---|---| +| Основной запрос чатов | 1 | 1 | +| Prefetch memberships | 1 | 1 | +| Prefetch user_settings | 1 | 1 | +| Prefetch read_states | 1 | 1 | +| **last_message** | **N** (1 на чат) | **1** (prefetch) | +| **participant_names** | **N** (1 на чат) | **0** (из prefetch) | +| **ИТОГО для 50 чатов** | **~104** | **~5** | + +### 2.2 Страница чата (`GET /chats/{id}/`, `GET /messages/`) + +| Операция | Запросов | +|---|---| +| Загрузка чата | 5 (chat + 4 prefetch) | +| Загрузка 30 сообщений вокруг anchor | 1 + 4 prefetch = 5 | +| WebSocket new_message | 0 (из памяти) | +| mark-read (дебаунс 300ms) | 1 POST + 1 GET (refresh chat) | +| Реакция | 1 POST + 1 SELECT (summary) | +| **Типичное открытие чата** | **~12 запросов** | + +### 2.3 Узкие места (остаются) + +1. **`get_last_message` в retrieve** — для единичного `getChat()` prefetch не помогает (1 запрос). Допустимо. +2. **`PollViewSet.vote`** — `option.votes.count()` в цикле (N+1). Нужен `annotate(Count('votes'))`. +3. **`ChatViewSet.create`** — `User.objects.get(id=user_id)` в цикле при добавлении участников. Нужен `User.objects.filter(id__in=...)`. + +--- + +## 3. Сравнение с популярными мессенджерами + +### 3.1 Архитектура доставки сообщений + +| Фича | Telegram | WhatsApp | Slack | **EUSRR** | +|---|---|---|---|---| +| Транспорт | MTProto (custom) | Signal Protocol | WebSocket | **WebSocket** | +| Оффлайн-очередь | Push + pull при connect | Push + pull | Pull при connect | **Pull при connect** (fallback sync) | +| E2E шифрование | Secret Chats only | Да | Enterprise only | **Нет** (корп. сеть) | +| Оптимистичные сообщения | Да | Да | Да | **Да** ✓ | +| Pending → Delayed → Failed | Да (часы/галочки) | Да | Нет | **Да** ✓ | + +### 3.2 Управление прочтением + +| Фича | Telegram | WhatsApp | Slack | **EUSRR** | +|---|---|---|---|---| +| Подсчёт непрочитанных | Денормализован | Денормализован | Денормализован (badge API) | **Денормализован** (`ChatReadState.unread_count`) ✓ | +| Mark-as-read | При открытии (2с задержка) | При открытии | При фокусе канала | **При скролле до сообщения** (300ms debounce) | +| Двойные галочки (read receipt) | Да | Да | Нет | **Да** (`is_read`, `read_by`) ✓ | +| Кто прочитал | Нет (кроме групп Premium) | Нет | Hover-подсказка | **Модальное окно** ✓ | + +### 3.3 Пагинация и скролл + +| Фича | Telegram | WhatsApp | Slack | **EUSRR** | +|---|---|---|---|---| +| Пагинация | Бикурсорная (around + offset) | Local-first DB (SQLite) | Cursor-based | **Бикурсорная** (`before_id`/`after_id`/`around_id`) ✓ | +| Навигация по дате | Да (календарь) | Да (jump to date) | Нет | **Да** ✓ | +| Scroll restoration | position-to-bottom | Anchor-based | Виртуализация | **Anchor-based** (`getSnapshotBeforeUpdate`) ✓ | +| Виртуализация списка | Нет (native) | Нет (native) | Да | **Нет** ⚠️ | + +### 3.4 Реакции + +| Фича | Telegram | WhatsApp | Slack | **EUSRR** | +|---|---|---|---|---| +| Быстрые реакции | 5 недавних + picker | 6 недавних + picker | Несколько + picker | **5 недавних + 40 emoji** ✓ | +| Custom emoji | Premium | Нет | Workspace emoji | **Нет** | +| Реакция-нотификация | Push | Push | Push | **WebSocket** ✓ | + +### 3.5 Что есть у конкурентов, но нет у EUSRR + +| Фича | Приоритет | Сложность | +|---|---|---| +| **Виртуализация списка сообщений** (react-window) | Высокий (>1000 сообщений = тормоза) | Средняя | +| **Реал-тайм обновление списка чатов** (unread badge без перезагрузки) | Высокий | Низкая (WS уже есть) | +| **Threads** (ветки обсуждений) | Средний | Высокая | +| **Поиск по сообщениям** | Средний | Средняя | +| **Голосовые сообщения** | Низкий | Средняя | +| **Пересланные сообщения (UI)** | Низкий | Низкая | + +### 3.6 Что у EUSRR лучше аналогов + +1. **Система ролей в чатах** (admin/moderator/member/guest) — нет у Telegram/WhatsApp в таком виде. +2. **Интеграция с оргструктурой** — чаты отделов, привязка к LDAP. +3. **Контекстные чаты** (комментарии к постам/документам) — нет у классических мессенджеров. +4. **Управление доступом** — гости без права отправки, модерация контента. + +--- + +## 4. Рекомендации по дальнейшим улучшениям + +### Высокий приоритет + +1. **Виртуализация списка сообщений** — при >500 сообщений DOM замедляется. Использовать `@tanstack/react-virtual` или `react-window`. +2. **WebSocket обновление списка чатов** — `wsManager` уже существует; нужен подписчик на `new_message` для обновления `last_message` и `unread_count` в списке без перезагрузки. +3. **Annotate poll vote counts** — `PollViewSet.vote` имеет N+1 на `option.votes.count()`. Заменить на `annotate(Count('votes'))`. + +### Средний приоритет + +4. **State management** — рассмотреть Zustand или TanStack Query для кеширования чатов/сообщений между страницами. +5. **Bulk create memberships** — `ChatViewSet.create` делает N запросов при добавлении участников. +6. **Поиск по сообщениям** — PostgreSQL full-text search (`SearchVector`, `SearchQuery`). + +### Низкий приоритет + +7. Удалить дублирование WebSocket логики (`useWebSocket` vs `wsManager`). +8. Typed API client вместо `any` в `createMessagesApi`. diff --git a/frontend/src/app/messages/[chatId]/page.tsx b/frontend/src/app/messages/[chatId]/page.tsx index bfb8f767..36791bcc 100644 --- a/frontend/src/app/messages/[chatId]/page.tsx +++ b/frontend/src/app/messages/[chatId]/page.tsx @@ -12,77 +12,52 @@ import MessageReadersModal from "@/components/messages/MessageReadersModal"; import MessageComposer from "@/components/messages/MessageComposer"; import ReactionPickerModal from "@/components/messages/ReactionPickerModal"; import { apiClient } from "@/lib/api"; -import type { Chat, Message } from "@/types/api"; +import type { Message } from "@/types/api"; import { useUser } from "@/contexts/UserContext"; import { useChatFallbackSync } from "@/hooks/useChatFallbackSync"; import { useSilentChatReloadGuard } from "@/hooks/useSilentChatReloadGuard"; import { useWebSocket } from "@/hooks/useWebSocket"; -import { getMessageTimestamp, mergeDisplayMessages, uniqueMessagesById } from "@/lib/messages/chatUtils"; +import { useChatMessages } from "@/hooks/useChatMessages"; +import { useMarkRead } from "@/hooks/useMarkRead"; +import { useChatScroll, shiftDateInputValue } from "@/hooks/useChatScroll"; +import { mergeDisplayMessages, uniqueMessagesById } from "@/lib/messages/chatUtils"; import { formatDayDivider, getMessageDate, getMessagePreviewText, getReplyToId } from "@/lib/messages/messageUtils"; import wsManager from "@/lib/websocketManager"; import ScrollableMessageList, { ScrollableMessageListInner } from "@/components/ScrollableMessageList"; -type ReplyTarget = { - id: number; - author: string; - preview: string; -}; +/* ─── types ─── */ -type MessageActionsAnchor = { - x: number; - y: number; -}; +type ReplyTarget = { id: number; author: string; preview: string }; +type MessageActionsAnchor = { x: number; y: number }; + +/* ─── constants ─── */ 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 = [ - "👍", "❤️", "😂", "🔥", "👏", "🎉", "😊", "😉", "😁", "🤝", - "🙏", "😮", "😢", "😡", "💯", "✅", "👀", "🤔", "😍", "😎", - "🤩", "🥳", "😴", "🫡", "👌", "💪", "🙌", "🧠", "💡", "🚀", - "🎯", "⭐", "✨", "💩", "🫶", "🤗", "😅", "🤯", "🥲", "🫠", + "👍","❤️","😂","🔥","👏","🎉","😊","😉","😁","🤝", + "🙏","😮","😢","😡","💯","✅","👀","🤔","😍","😎", + "🤩","🥳","😴","🫡","👌","💪","🙌","🧠","💡","🚀", + "🎯","⭐","✨","💩","🫶","🤗","😅","🤯","🥲","🫠", ]; +/* ─── utils ─── */ + function uniqueEmoji(items: string[]): string[] { const seen = new Set(); - const out: string[] = []; - - items.forEach((item) => { - if (!item || seen.has(item)) return; - seen.add(item); - out.push(item); - }); - - return out; + return items.filter(e => { if (!e || seen.has(e)) return false; seen.add(e); return true; }); } function getMessageDayKey(message: Message): string | null { - const date = getMessageDate(message); - if (!date) { - return null; - } - - return `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`; + const d = getMessageDate(message); + return d ? `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}` : null; } function formatDateInputValue(date: Date): string { - const year = date.getFullYear(); - const month = `${date.getMonth() + 1}`.padStart(2, "0"); - const day = `${date.getDate()}`.padStart(2, "0"); - - return `${year}-${month}-${day}`; + return `${date.getFullYear()}-${`${date.getMonth()+1}`.padStart(2,"0")}-${`${date.getDate()}`.padStart(2,"0")}`; } -function shiftDateInputValue(value: string, deltaDays: number): string { - const baseDate = value ? new Date(`${value}T00:00:00`) : new Date(); - if (Number.isNaN(baseDate.getTime())) { - return formatDateInputValue(new Date()); - } - - baseDate.setDate(baseDate.getDate() + deltaDays); - return formatDateInputValue(baseDate); -} +/* ─── component ─── */ export default function MessageDialogPage() { const params = useParams<{ chatId: string }>(); @@ -90,12 +65,63 @@ export default function MessageDialogPage() { const { user, loading: userLoading } = useUser(); const { handleReconnectExhausted, resetReloadGuard } = useSilentChatReloadGuard(chatId || null); - const [chat, setChat] = useState(null); - const [messages, setMessages] = useState([]); - const [pendingMessages, setPendingMessages] = useState([]); - const [loading, setLoading] = useState(true); - const [messagesLoading, setMessagesLoading] = useState(false); - const [error, setError] = useState(null); + /* ── refs ── */ + const messagesViewportRef = useRef(null); + const fileInputRef = useRef(null); + const messageInputRef = useRef(null); + + /* ────────────────────────────────────────────────────── + * HOOK: Chat messages (state, pagination, pending) + * ────────────────────────────────────────────────────── */ + const cm = useChatMessages({ chatId, userId: user?.id, userLoading }); + + /* ────────────────────────────────────────────────────── + * HOOK: Scroll behavior (floating date, button, etc.) + * ────────────────────────────────────────────────────── */ + const scroll = useChatScroll({ + chatId, + messageCount: cm.messages.length + cm.pendingMessages.length, + viewportRef: messagesViewportRef, + hasMoreNewerRef: cm.hasMoreNewerRef, + unreadBelowIdsRef: cm.unreadBelowIdsRef, + historyNavigationMode: cm.historyNavigationMode, + }); + + // Wire scroll helpers into the messages hook so it can use them + useEffect(() => { + cm.isNearBottomRef.current = scroll.isNearBottom; + cm.scrollToBottomRef.current = scroll.scrollToBottom; + }, [cm.isNearBottomRef, cm.scrollToBottomRef, scroll.isNearBottom, scroll.scrollToBottom]); + + /* ────────────────────────────────────────────────────── + * HOOK: Mark-as-read (debounced) + * ────────────────────────────────────────────────────── */ + const markRead = useMarkRead({ + chatId, + onReadAcknowledged: useCallback(() => { + // We intentionally do NOT pull fresh chat/unread_count here. + // The local newMessagesBelowCount resets only when the user + // physically scrolls to the bottom (via syncScrollToBottomState). + }, []), + }); + + // Sync confirmed watermark from chat.last_read_message_id + useEffect(() => { + markRead.syncConfirmed(cm.chat?.last_read_message_id ?? 0); + }, [cm.chat?.last_read_message_id, markRead]); + + /* ── derived state ── */ + const displayMessages = useMemo(() => mergeDisplayMessages(cm.messages, cm.pendingMessages), [cm.messages, cm.pendingMessages]); + const messagesById = useMemo(() => new Map(displayMessages.map(m => [m.id, m])), [displayMessages]); + + /** + * Badge count: take the maximum of the server-provided unread_count + * and locally tracked new-messages-below. This ensures the badge + * stays visible even after mark-read flushes (until user scrolls down). + */ + const displayedUnreadCount = Math.max(cm.chat?.unread_count ?? 0, cm.newMessagesBelowCount); + + /* ── UI state ── */ const [messageText, setMessageText] = useState(""); const [sending, setSending] = useState(false); const [editingMessageId, setEditingMessageId] = useState(null); @@ -110,741 +136,71 @@ export default function MessageDialogPage() { const [brokenMedia, setBrokenMedia] = useState>({}); const [useOriginalImage, setUseOriginalImage] = useState>({}); const [mediaPreview, setMediaPreview] = useState(null); - const [floatingDate, setFloatingDate] = useState(null); - const [floatingDateValue, setFloatingDateValue] = useState(null); - const [showFloatingDate, setShowFloatingDate] = useState(false); - const [isDateNavigatorOpen, setIsDateNavigatorOpen] = useState(false); - const [selectedHistoryDate, setSelectedHistoryDate] = useState(""); - const [jumpingToDate, setJumpingToDate] = useState(false); - const [historyNavigationMode, setHistoryNavigationMode] = useState(false); - const [showScrollToBottom, setShowScrollToBottom] = useState(false); - const [newMessagesBelowCount, setNewMessagesBelowCount] = useState(0); - const floatingDateTimeoutRef = useRef(null); - const fileInputRef = useRef(null); - const messageInputRef = useRef(null); - const messagesViewportRef = useRef(null); - const [hasMoreOlder, setHasMoreOlder] = useState(false); - const [hasMoreNewer, setHasMoreNewer] = useState(false); - const [loadingOlder, setLoadingOlder] = useState(false); - const [loadingNewer, setLoadingNewer] = useState(false); - const [initialAnchorId, setInitialAnchorId] = useState(null); const [isTyping, setIsTyping] = useState(false); const typingTimeoutRef = useRef(null); - const [initialAnchorIndex, setInitialAnchorIndex] = useState(null); - const [allowOneOlderProbe, setAllowOneOlderProbe] = useState(false); - const initialScrolledRef = useRef(false); - const anchorScrollAttemptsRef = useRef(0); - const anchorOlderLoadAttemptsRef = useRef(0); - const [anchorRetryTick, setAnchorRetryTick] = useState(0); - const hasMoreNewerRef = useRef(false); - const fallbackSyncInFlightRef = useRef(false); - const pendingTimersRef = useRef>(new Map()); - const pendingMessagesRef = useRef([]); - const localMessageSequenceRef = useRef(0); - const markReadTimerRef = useRef(null); - const markReadInFlightRef = useRef(false); - const queuedMarkReadIdRef = useRef(null); - const lastConfirmedMarkReadIdRef = useRef(0); - const unreadBelowMessageIdsRef = useRef>(new Set()); - - // Локальное состояние для настроек чата const [isPinned, setIsPinned] = useState(false); const [notificationsEnabled, setNotificationsEnabled] = useState(true); - - // Синхронизируем ref с state + const [jumpingToDate, setJumpingToDate] = useState(false); + + // Sync settings from chat useEffect(() => { - hasMoreNewerRef.current = hasMoreNewer; - }, [hasMoreNewer]); - - // Синхронизация настроек чата - useEffect(() => { - if (chat) { - setIsPinned(chat.is_pinned ?? false); - setNotificationsEnabled(chat.notifications_enabled ?? true); + if (cm.chat) { + setIsPinned(cm.chat.is_pinned ?? false); + setNotificationsEnabled(cm.chat.notifications_enabled ?? true); } - }, [chat]); - - const displayMessages = useMemo(() => mergeDisplayMessages(messages, pendingMessages), [messages, pendingMessages]); - const messagesById = useMemo(() => new Map(displayMessages.map((m) => [m.id, m])), [displayMessages]); + }, [cm.chat]); - useEffect(() => { - pendingMessagesRef.current = pendingMessages; - }, [pendingMessages]); - - useEffect(() => { - if (floatingDateValue && !isDateNavigatorOpen) { - setSelectedHistoryDate(floatingDateValue); - } - }, [floatingDateValue, isDateNavigatorOpen]); - - 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; - unreadBelowMessageIdsRef.current.clear(); - setNewMessagesBelowCount(0); - setHistoryNavigationMode(false); - }, [chatId, chat?.last_read_message_id]); - - // Определяем текущий membership и права на отправку сообщений + /* ── membership & permissions ── */ const myMembership = useMemo(() => { - if (!chat?.memberships || !user?.id) return null; - return chat.memberships.find(m => m.user === user.id) || null; - }, [chat?.memberships, user?.id]); - + if (!cm.chat?.memberships || !user?.id) return null; + return cm.chat.memberships.find(m => m.user === user.id) || null; + }, [cm.chat?.memberships, user?.id]); + const canSendMessages = useMemo(() => { - // Если нет membership - разрешаем (обычные чаты без ролей) if (!myMembership) return true; - - // Проверяем флаг can_send_messages (гости имеют false) return myMembership.can_send_messages; }, [myMembership]); - + + /* ── action menu derived ── */ const selectedActionMessage = expandedReplyActionForId ? messagesById.get(expandedReplyActionForId) || null : null; const selectedActionCanManage = Boolean( - selectedActionMessage && - user?.id && - !selectedActionMessage.is_deleted && - (selectedActionMessage.author_id === user.id || - selectedActionMessage.author?.id === user.id || - selectedActionMessage.sender?.id === user.id) + selectedActionMessage && user?.id && !selectedActionMessage.is_deleted && + (selectedActionMessage.author_id === user.id || selectedActionMessage.author?.id === user.id || selectedActionMessage.sender?.id === user.id), ); const selectedActionCanReply = Boolean(selectedActionMessage && !selectedActionMessage.is_deleted); - const selectedActionRecentReactions = useMemo( - () => uniqueEmoji(recentReactions).slice(0, MAX_RECENT_REACTIONS), - [recentReactions] - ); + const selectedActionRecentReactions = useMemo(() => 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 resetNewMessagesBelow = useCallback(() => { - unreadBelowMessageIdsRef.current.clear(); - setNewMessagesBelowCount(0); - }, []); - - const registerNewMessagesBelow = useCallback((incomingMessages: Message[]) => { - if (!user?.id || incomingMessages.length === 0) { - return; - } - - let addedCount = 0; - - incomingMessages.forEach((message) => { - const authorId = message.author_id ?? message.author?.id ?? message.sender?.id; - if (authorId === user.id || message.id <= 0 || unreadBelowMessageIdsRef.current.has(message.id)) { - return; - } - - unreadBelowMessageIdsRef.current.add(message.id); - addedCount += 1; - }); - - if (addedCount > 0) { - setNewMessagesBelowCount((prev) => prev + addedCount); - } - }, [user?.id]); - - 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({ - chatId: chatId || null, - autoConnect: true, - onReconnectExhausted: handleReconnectExhausted, - }); - + /* ── reactions ── */ useEffect(() => { - if (!chatId || Number.isNaN(chatId)) { - return; - } - - wsManager.setActiveChat(chatId); - - return () => { - wsManager.clearActiveChat(chatId); - }; - }, [chatId]); - - useEffect(() => { - if (!isConnected) { - return; - } - - resetReloadGuard(); - }, [isConnected, resetReloadGuard]); - - // Проверка, находится ли пользователь внизу списка - const isNearBottom = useCallback(() => { - const component = messagesViewportRef.current; - if (!component || !component.containerRef?.current) return false; - - const viewport = component.containerRef.current; - const threshold = 150; // px от низа - const { scrollHeight, scrollTop, clientHeight } = viewport; - return scrollHeight - scrollTop - clientHeight <= threshold; - }, []); - - const syncScrollToBottomState = useCallback(() => { - const isAtBottom = isNearBottom(); - const hasBufferedNewer = hasMoreNewerRef.current || unreadBelowMessageIdsRef.current.size > 0; - - if (isAtBottom && !hasMoreNewerRef.current) { - setShowScrollToBottom(false); - resetNewMessagesBelow(); - setHistoryNavigationMode(false); - return true; - } - - setShowScrollToBottom(!isAtBottom || hasBufferedNewer || historyNavigationMode); - - return isAtBottom; - }, [historyNavigationMode, isNearBottom, resetNewMessagesBelow]); - - // Автопрокрутка вниз - const scrollToBottom = useCallback((smooth = false) => { - const component = messagesViewportRef.current; - if (!component) return; - - component.scrollToBottom(smooth ? 'smooth' : 'auto'); - }, []); - - const syncLatestMessages = useCallback(async ( - options: { limit?: number; scrollOnSync?: boolean } = {} - ) => { - if ( - !chatId || - Number.isNaN(chatId) || - messagesLoading || - messages.length === 0 || - fallbackSyncInFlightRef.current - ) { - return; - } - - const newestMessage = messages[messages.length - 1]; - const shouldMarkRead = isNearBottom(); - - fallbackSyncInFlightRef.current = true; - - try { - const response = await apiClient.getChatMessages(chatId, { - limit: options.limit ?? 20, - after_id: newestMessage.id, - mark_read: 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]; - scheduleMarkRead(lastLoadedMessage.id); - } - - if (shouldMarkRead && options.scrollOnSync) { - requestAnimationFrame(() => { - scrollToBottom(false); - }); - } - } - - setHasMoreNewer(Boolean(response.has_more)); - } catch (e) { - console.error("Ошибка тихой синхронизации сообщений:", e); - } finally { - fallbackSyncInFlightRef.current = false; - } - }, [chatId, isNearBottom, messages, messagesLoading, removePendingMessageByServerMessage, scheduleMarkRead, scrollToBottom]); - - // Обновление reactions_summary для конкретного сообщения - const updateMessageReactionsSummary = useCallback(( - messageId: number, - summary?: Record - ) => { - if (!summary) return; - setMessages((prev) => prev.map((m) => (m.id === messageId ? { ...m, reactions_summary: summary } : m))); - }, []); - - // Настройка обработчиков WebSocket событий - useEffect(() => { - handlers.current.onMessage = (data) => { - if (data.type === 'new_message') { - // Новое сообщение - if (!data.message) { - return; - } - - const newMsg = data.message; - const isMyMessage = newMsg.author_id === user?.id; - const wasNearBottom = isNearBottom(); - removePendingMessageByServerMessage(newMsg); - - if (!isMyMessage && !wasNearBottom) { - registerNewMessagesBelow([newMsg]); - setShowScrollToBottom(true); - } - - setMessages(prev => { - // Проверяем, нет ли уже такого сообщения (дедупликация) - if (prev.some(m => m.id === newMsg.id)) { - return prev; - } - - // ВАЖНО: Добавляем сообщение только если: - // 1. Это МОЕ сообщение (я только что отправил) - всегда добавляем - // 2. ИЛИ у нас загружены все сообщения (hasMoreNewerRef.current = false) - // 3. ИЛИ новое сообщение идет сразу после последнего (непрерывная последовательность) - - if (prev.length > 0 && !isMyMessage) { - const lastMessage = prev[prev.length - 1]; - const hasGap = newMsg.id - lastMessage.id > 1; // Есть пропущенные сообщения - - // Если есть разрыв И есть непрочитанные - НЕ добавляем - if (hasGap && hasMoreNewerRef.current) { - console.warn(`⚠️ WebSocket: пропуск сообщения ${newMsg.id} - есть незагруженные между ${lastMessage.id} и ${newMsg.id}`); - // Оставляем hasMoreNewer = true - return prev; - } - } - - // Добавляем сообщение - const updated = [...prev, newMsg]; - - // Сбрасываем флаг "есть еще новые сообщения" - setTimeout(() => { - setHasMoreNewer(false); - - // Автоотметка как прочитанное ТОЛЬКО если: - // 1. Это НЕ мое сообщение - // 2. И я был внизу (активно читал) - // Прагматичный подход: загружено = прочитано (для сообщений в области видимости) - if (!isMyMessage && wasNearBottom && chatId) { - scheduleMarkRead(newMsg.id); - } - - // Автопрокрутка вниз если: - // 1. Сообщение отправил я сам - // 2. Или я был внизу списка (читал последние сообщения) - if (isMyMessage || wasNearBottom) { - requestAnimationFrame(() => { - requestAnimationFrame(() => { - scrollToBottom(true); - }); - }); - } - }, 0); - - return updated; - }); - } - else if (data.type === 'message_edited') { - // Редактирование сообщения - if (!data.message) { - return; - } - - const editedMsg = data.message; - setMessages(prev => prev.map(m => - m.id === editedMsg.id ? { ...m, ...editedMsg } : m - )); - } - else if (data.type === 'message_deleted') { - // Удаление сообщения - setMessages(prev => prev.filter(m => m.id !== data.message_id)); - } - else if (data.type === 'typing_start') { - // Индикатор "печатает..." - if (data.user_id !== user?.id) { - setIsTyping(true); - - if (typingTimeoutRef.current) { - clearTimeout(typingTimeoutRef.current); - } - - typingTimeoutRef.current = setTimeout(() => { - setIsTyping(false); - }, 3000); - } - } - else if (data.type === 'typing_stop') { - // Остановка печати - if (data.user_id !== user?.id) { - setIsTyping(false); - if (typingTimeoutRef.current) { - clearTimeout(typingTimeoutRef.current); - } - } - } - else if (data.type === 'reaction_added') { - // Реакция добавлена - if (data.reactions_summary && typeof data.message_id === 'number') { - updateMessageReactionsSummary(data.message_id, data.reactions_summary); - } - } - else if (data.type === 'reaction_removed') { - // Реакция удалена - if (data.reactions_summary && typeof data.message_id === 'number') { - updateMessageReactionsSummary(data.message_id, data.reactions_summary); - } - } - else if (data.type === 'marked_read' && data.chat_id === chatId && typeof data.last_read_message_id === 'number') { - const lastReadMessageId = data.last_read_message_id; - const readerUserId = typeof data.reader_user_id === 'number' ? data.reader_user_id : null; - - if (readerUserId !== null) { - setMessages((prev) => - prev.map((message) => { - const authorId = message.author_id ?? message.author?.id ?? message.sender?.id; - if (message.id > lastReadMessageId || authorId === readerUserId || message.is_read) { - return message; - } - - return { - ...message, - is_read: true, - }; - }) - ); - } - - if (readerUserId === null || readerUserId === user?.id) { - lastConfirmedMarkReadIdRef.current = Math.max( - lastConfirmedMarkReadIdRef.current, - lastReadMessageId, - ); - setChat((prev) => - prev - ? { - ...prev, - last_read_message_id: Math.max(prev.last_read_message_id ?? 0, lastReadMessageId), - } - : prev - ); - } - } - }; - - handlers.current.onConnect = () => { - void syncLatestMessages({ - limit: 30, - scrollOnSync: isNearBottom(), - }); - }; - - handlers.current.onDisconnect = () => { - // WebSocket отключен - }; - - handlers.current.onError = (error) => { - console.error('❌ WebSocket error:', error); - }; - }, [handlers, user?.id, chatId, isNearBottom, registerNewMessagesBelow, removePendingMessageByServerMessage, scheduleMarkRead, scrollToBottom, syncLatestMessages, updateMessageReactionsSummary]); - - // Очистка таймаута при размонтировании - useEffect(() => { - return () => { - if (typingTimeoutRef.current) { - clearTimeout(typingTimeoutRef.current); - } - if (floatingDateTimeoutRef.current) { - clearTimeout(floatingDateTimeoutRef.current); - } - if (markReadTimerRef.current) { - clearTimeout(markReadTimerRef.current); - } - clearAllPendingTimers(); - }; - }, [clearAllPendingTimers]); - - useLayoutEffect(() => { - if (typeof window === "undefined" || !window.history) return; - const prev = window.history.scrollRestoration; - window.history.scrollRestoration = "manual"; - return () => { - window.history.scrollRestoration = prev; - }; - }, []); - - useEffect(() => { - if (typeof window === "undefined") return; try { const raw = localStorage.getItem(RECENT_REACTIONS_KEY); if (!raw) return; const parsed = JSON.parse(raw); if (!Array.isArray(parsed)) return; - const normalized = uniqueEmoji(parsed.filter((v): v is string => typeof v === "string")).slice(0, MAX_RECENT_REACTIONS); - if (normalized.length > 0) { - setRecentReactions(normalized); - } - } catch { - // ignore - } + const norm = uniqueEmoji(parsed.filter((v): v is string => typeof v === "string")).slice(0, MAX_RECENT_REACTIONS); + if (norm.length > 0) setRecentReactions(norm); + } catch { /* ignore */ } }, []); const pushRecentReaction = (emoji: string) => { const next = uniqueEmoji([emoji, ...recentReactions]).slice(0, MAX_RECENT_REACTIONS); setRecentReactions(next); - if (typeof window !== "undefined") { - localStorage.setItem(RECENT_REACTIONS_KEY, JSON.stringify(next)); - } + if (typeof window !== "undefined") localStorage.setItem(RECENT_REACTIONS_KEY, JSON.stringify(next)); }; - const appendEmojiToComposer = (emoji: string) => { - setMessageText((prev) => `${prev}${emoji}`); - requestAnimationFrame(() => { - const input = messageInputRef.current; - if (!input) return; - input.focus(); - const len = input.value.length; - input.setSelectionRange(len, len); - }); - }; + const updateMessageReactionsSummary = useCallback(( + messageId: number, + summary?: Record, + ) => { + if (!summary) return; + cm.setMessages(prev => prev.map(m => m.id === messageId ? { ...m, reactions_summary: summary } : m)); + }, [cm]); const hasMyReaction = (message: Message, emoji: string): boolean => { if (!user?.id) return false; - const users = message.reactions_summary?.[emoji]?.users || []; - return users.includes(user.id); + return (message.reactions_summary?.[emoji]?.users || []).includes(user.id); }; const handleReact = async (message: Message, emoji: string) => { @@ -852,7 +208,6 @@ export default function MessageDialogPage() { const response = hasMyReaction(message, emoji) ? await apiClient.unreactToMessage(message.id, emoji) : await apiClient.reactToMessage(message.id, emoji); - pushRecentReaction(emoji); updateMessageReactionsSummary(message.id, response.reactions_summary); } catch (e) { @@ -860,406 +215,234 @@ export default function MessageDialogPage() { } }; - useEffect(() => { - async function loadChats() { - if (!chatId || Number.isNaN(chatId)) { - setChat(null); - setLoading(false); - return; - } - - // На full reload дожидаемся восстановления auth-состояния, - // иначе возможен первый 401 и «залипание» без повторной загрузки. - if (userLoading) { - setLoading(true); - return; - } - - if (!user) { - setChat(null); - setLoading(false); - return; - } - - try { - setLoading(true); - setError(null); - const loadedChat = await apiClient.getChat(chatId); - setChat(loadedChat); - } catch { - setChat(null); - setError("Не удалось загрузить чат. Проверьте подключение и попробуйте снова."); - } finally { - setLoading(false); - } - } - - loadChats(); - }, [chatId, userLoading, user, user?.id]); + /* ────────────────────────────────────────────────────── + * WebSocket + * ────────────────────────────────────────────────────── */ + const { isConnected, sendTyping, handlers } = useWebSocket({ + chatId: chatId || null, + autoConnect: true, + onReconnectExhausted: handleReconnectExhausted, + }); useEffect(() => { - async function loadMessages() { - if (!chatId || Number.isNaN(chatId)) { - clearAllPendingTimers(); - setMessages([]); - setPendingMessages([]); - setHasMoreOlder(false); - setHasMoreNewer(false); - setInitialAnchorId(null); - setInitialAnchorIndex(null); - setAllowOneOlderProbe(false); - initialScrolledRef.current = false; - resetNewMessagesBelow(); - return; - } + if (!chatId || Number.isNaN(chatId)) return; + wsManager.setActiveChat(chatId); + return () => { wsManager.clearActiveChat(chatId); }; + }, [chatId]); - // Ключевой фикс для полного reload: грузим сообщения только - // после завершения инициализации пользователя. - if (userLoading || !user) { - return; - } + useEffect(() => { if (isConnected) resetReloadGuard(); }, [isConnected, resetReloadGuard]); - try { - setMessagesLoading(true); - - // 1. Получаем детали чата с last_read_message_id - const chatDetails = await apiClient.getChat(chatId); - const lastReadId = chatDetails.last_read_message_id; - - // 2. Загружаем сообщения вокруг last_read_message_id - // Асимметричные лимиты: 24 контекста + 6 новых = 30 сообщений - // Автоотметка последнего загруженного как прочитанного - const around = await apiClient.getChatMessagesAround(chatId, { - limit: 30, // 24 до + 6 после - around_id: lastReadId || undefined + // WS event handler + useEffect(() => { + handlers.current.onMessage = (data) => { + if (data.type === "new_message" && data.message) { + const newMsg = data.message; + const isMyMessage = newMsg.author_id === user?.id; + const wasNearBottom = scroll.isNearBottom(); + cm.removePendingByServer(newMsg); + + if (!isMyMessage && !wasNearBottom) { + cm.registerNewMessagesBelow([newMsg]); + scroll.setShowScrollToBottom(true); + } + + cm.setMessages(prev => { + if (prev.some(m => m.id === newMsg.id)) return prev; + + if (prev.length > 0 && !isMyMessage) { + const last = prev[prev.length - 1]; + if (newMsg.id - last.id > 1 && cm.hasMoreNewerRef.current) return prev; + } + + const updated = [...prev, newMsg]; + + setTimeout(() => { + if (!isMyMessage && wasNearBottom && chatId) markRead.scheduleMarkRead(newMsg.id); + if (isMyMessage || wasNearBottom) { + requestAnimationFrame(() => requestAnimationFrame(() => scroll.scrollToBottom(true))); + } + }, 0); + + return updated; }); - - const aroundMessages = around.messages || []; + } else if (data.type === "message_edited" && data.message) { + const edited = data.message; + cm.setMessages(prev => prev.map(m => m.id === edited.id ? { ...m, ...edited } : m)); + } else if (data.type === "message_deleted") { + cm.setMessages(prev => prev.filter(m => m.id !== data.message_id)); + } else if (data.type === "typing_start" && data.user_id !== user?.id) { + setIsTyping(true); + if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current); + typingTimeoutRef.current = setTimeout(() => setIsTyping(false), 3000); + } else if (data.type === "typing_stop" && data.user_id !== user?.id) { + setIsTyping(false); + if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current); + } else if (data.type === "reaction_added" || data.type === "reaction_removed") { + if (data.reactions_summary && typeof data.message_id === "number") { + updateMessageReactionsSummary(data.message_id, data.reactions_summary); + } + } else if (data.type === "marked_read" && data.chat_id === chatId && typeof data.last_read_message_id === "number") { + const lastReadId = data.last_read_message_id; + const readerId = typeof data.reader_user_id === "number" ? data.reader_user_id : null; - 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 + if (readerId !== null) { + cm.setMessages(prev => + prev.map(m => { + const aId = m.author_id ?? m.author?.id ?? m.sender?.id; + if (m.id > lastReadId || aId === readerId || m.is_read) return m; + return { ...m, is_read: true }; + }), ); - setHasMoreNewer(Boolean(around.has_more_after)); - setInitialAnchorId(around.anchor_id ?? null); - setInitialAnchorIndex(typeof around.anchor_index === "number" ? around.anchor_index : null); - setAllowOneOlderProbe(Boolean(around.anchor_id)); - unreadBelowMessageIdsRef.current = new Set( - normalized - .filter((message) => { - const authorId = message.author_id ?? message.author?.id ?? message.sender?.id; - return authorId !== user?.id && message.id > (lastReadId ?? 0); - }) - .map((message) => message.id) - ); - setNewMessagesBelowCount(Math.max(chatDetails.unread_count ?? unreadBelowMessageIdsRef.current.size, 0)); - } else { - clearAllPendingTimers(); - const response = await apiClient.getChatMessages(chatId, { limit: 50 }); - const normalized = uniqueMessagesById(response.messages || []); - setMessages(normalized); - setPendingMessages([]); - setHasMoreOlder(Boolean(response.has_more)); - setHasMoreNewer(false); - setInitialAnchorId(null); - setInitialAnchorIndex(null); - setAllowOneOlderProbe(false); - unreadBelowMessageIdsRef.current = new Set( - normalized - .filter((message) => { - const authorId = message.author_id ?? message.author?.id ?? message.sender?.id; - return authorId !== user?.id && message.id > (lastReadId ?? 0); - }) - .map((message) => message.id) - ); - setNewMessagesBelowCount(Math.max(chatDetails.unread_count ?? unreadBelowMessageIdsRef.current.size, 0)); } - initialScrolledRef.current = false; - anchorScrollAttemptsRef.current = 0; - anchorOlderLoadAttemptsRef.current = 0; - setAnchorRetryTick(0); - } catch (error: unknown) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("403")) { - setError("Нет доступа к этому чату"); - clearAllPendingTimers(); - setMessages([]); - setPendingMessages([]); - } else { - console.error("Ошибка загрузки сообщений:", error); + if (readerId === null || readerId === user?.id) { + markRead.syncConfirmed(lastReadId); + // Refresh chat to get updated unread_count, but do NOT reset local counter + void apiClient.getChat(chatId).then(fresh => cm.setChat(fresh)).catch(() => {}); } - setHasMoreOlder(false); - setHasMoreNewer(false); - setInitialAnchorId(null); - setInitialAnchorIndex(null); - setAllowOneOlderProbe(false); - resetNewMessagesBelow(); - initialScrolledRef.current = false; - anchorScrollAttemptsRef.current = 0; - anchorOlderLoadAttemptsRef.current = 0; - setAnchorRetryTick(0); - } finally { - setMessagesLoading(false); } - } + }; - loadMessages(); - }, [chatId, clearAllPendingTimers, resetNewMessagesBelow, userLoading, user, user?.id]); + handlers.current.onConnect = () => { + void cm.syncLatestMessages({ limit: 30, scrollOnSync: scroll.isNearBottom() }); + }; + handlers.current.onDisconnect = () => {}; + handlers.current.onError = (err) => { console.error("❌ WebSocket error:", err); }; + }, [handlers, user?.id, chatId, scroll, cm, markRead, updateMessageReactionsSummary]); - // Скролл к якорю или вниз после загрузки сообщений - // Anchor scroll используется при переходе по прямой ссылке на сообщение (например, из уведомления) + /* ── cleanup timers ── */ + useEffect(() => () => { + if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current); + }, []); + + useLayoutEffect(() => { + if (typeof window === "undefined" || !window.history) return; + const prev = window.history.scrollRestoration; + window.history.scrollRestoration = "manual"; + return () => { window.history.scrollRestoration = prev; }; + }, []); + + /* ── anchor scroll ── */ useEffect(() => { const component = messagesViewportRef.current; - if (messagesLoading || !component || !component.containerRef?.current || displayMessages.length === 0 || initialScrolledRef.current) return; + if (cm.messagesLoading || !component?.containerRef?.current || displayMessages.length === 0 || !cm.isInitialScrollPending()) return; const viewport = component.containerRef.current; - // Если есть якорь - пытаемся к нему прокрутиться - if (initialAnchorId) { - const el = viewport.querySelector(`[data-message-id="${initialAnchorId}"]`) as HTMLElement | null; + if (cm.initialAnchorId) { + const el = viewport.querySelector(`[data-message-id="${cm.initialAnchorId}"]`) as HTMLElement | null; if (el) { - // Ручной scrollTop вместо scrollIntoView (iOS Safari не поддерживает scrollIntoView в overflow контейнерах) - const containerRect = viewport.getBoundingClientRect(); - const elRect = el.getBoundingClientRect(); - const offset = elRect.top - containerRect.top + viewport.scrollTop - viewport.clientHeight / 2 + elRect.height / 2; - viewport.scrollTop = Math.max(0, offset); - syncScrollToBottomState(); - initialScrolledRef.current = true; - anchorScrollAttemptsRef.current = 0; - setInitialAnchorId(null); - setInitialAnchorIndex(null); + const cRect = viewport.getBoundingClientRect(); + const eRect = el.getBoundingClientRect(); + viewport.scrollTop = Math.max(0, eRect.top - cRect.top + viewport.scrollTop - viewport.clientHeight / 2 + eRect.height / 2); + scroll.syncScrollToBottomState(); + cm.markInitialScrollDone(); return; } - // Пробуем по индексу - if (typeof initialAnchorIndex === "number" && initialAnchorIndex >= 0) { - const children = viewport.querySelectorAll("[data-message-id]"); - const byIndex = children.item(initialAnchorIndex) as HTMLElement | null; + if (typeof cm.initialAnchorIndex === "number" && cm.initialAnchorIndex >= 0) { + const byIndex = viewport.querySelectorAll("[data-message-id]").item(cm.initialAnchorIndex) as HTMLElement | null; if (byIndex) { - const containerRect = viewport.getBoundingClientRect(); - const elRect = byIndex.getBoundingClientRect(); - const offset = elRect.top - containerRect.top + viewport.scrollTop - viewport.clientHeight / 2 + elRect.height / 2; - viewport.scrollTop = Math.max(0, offset); - syncScrollToBottomState(); - initialScrolledRef.current = true; - anchorScrollAttemptsRef.current = 0; - setInitialAnchorId(null); - setInitialAnchorIndex(null); + const cRect = viewport.getBoundingClientRect(); + const eRect = byIndex.getBoundingClientRect(); + viewport.scrollTop = Math.max(0, eRect.top - cRect.top + viewport.scrollTop - viewport.clientHeight / 2 + eRect.height / 2); + scroll.syncScrollToBottomState(); + cm.markInitialScrollDone(); return; } } - // Даем DOM время на отрисовку (максимум 5 попыток, iOS рендерит медленнее) - if (anchorScrollAttemptsRef.current < 5) { - anchorScrollAttemptsRef.current += 1; - setTimeout(() => setAnchorRetryTick((v) => v + 1), 100); + // Retry up to 5 times for slow DOM rendering + if (cm.anchorRetryTick < 5) { + setTimeout(() => cm.setAnchorRetryTick(v => v + 1), 100); return; } - - // Якорь не найден после 5 попыток - скроллим вниз - console.warn('Anchor not found after 5 attempts, scrolling to bottom'); - setInitialAnchorId(null); - setInitialAnchorIndex(null); } - // Обычная прокрутка вниз без анимации (автоматический скролл при первой загрузке чата) - component.scrollToBottom('auto'); - syncScrollToBottomState(); - initialScrolledRef.current = true; - anchorScrollAttemptsRef.current = 0; - anchorOlderLoadAttemptsRef.current = 0; - }, [ - messagesLoading, - displayMessages, - initialAnchorId, - initialAnchorIndex, - anchorRetryTick, - syncScrollToBottomState, - ]); - - // Удалено: prependAdjustRef useLayoutEffect - // ScrollableMessageList уже корректирует позицию автоматически через getSnapshotBeforeUpdate - // Дублирующая корректировка вызывала "подергивание" при загрузке старых сообщений - - const loadOlderMessages = useCallback(async () => { - if (!chatId || Number.isNaN(chatId) || loadingOlder || messagesLoading || messages.length === 0) { - return; - } - - if (!hasMoreOlder && !allowOneOlderProbe) { - return; - } - - const oldestMessage = messages[0]; - - try { - setLoadingOlder(true); - const response = await apiClient.getChatMessages(chatId, { - limit: 40, - before_id: oldestMessage.id, - }); - - const olderMessages = response.messages || []; - if (olderMessages.length > 0) { - // ScrollableMessageList автоматически сохранит позицию через getSnapshotBeforeUpdate - setMessages((prev) => uniqueMessagesById([...olderMessages, ...prev])); - } - - setHasMoreOlder(Boolean(response.has_more)); - setAllowOneOlderProbe(false); - if (!response.has_more && (!response.messages || response.messages.length === 0)) { - anchorOlderLoadAttemptsRef.current = 3; - } - } catch (e) { - console.error("Ошибка подгрузки старых сообщений:", e); - } finally { - setLoadingOlder(false); - } - }, [allowOneOlderProbe, chatId, hasMoreOlder, loadingOlder, messages, messagesLoading]); - - const loadNewerMessages = useCallback(async () => { - if (!chatId || Number.isNaN(chatId) || loadingNewer || messagesLoading || !hasMoreNewer || messages.length === 0) { - return; - } - - const newestMessage = messages[messages.length - 1]; - const shouldMarkRead = !historyNavigationMode && isNearBottom(); - - try { - setLoadingNewer(true); - const response = await apiClient.getChatMessages(chatId, { - limit: 10, // Ограничено для минимальной погрешности автоотметки (было 40) - after_id: newestMessage.id, - 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]; - scheduleMarkRead(lastLoadedMessage.id); - } - - // НЕ делаем автоскролл - ScrollableMessageList сам решит: - // - Если был внизу (sticky) → автоматически проскроллит вниз - // - Если был НЕ внизу → сохранит текущую позицию - // Это правильное поведение для дозагрузки истории - } - - setHasMoreNewer(Boolean(response.has_more)); - } catch (e) { - console.error("Ошибка подгрузки новых сообщений:", e); - } finally { - setLoadingNewer(false); - } - }, [chatId, hasMoreNewer, historyNavigationMode, isNearBottom, loadingNewer, messages, messagesLoading, removePendingMessageByServerMessage, scheduleMarkRead]); - - useChatFallbackSync({ - chatId: chatId || null, - isConnected, - userLoading, - hasUser: Boolean(user), - messagesLoading, - hasMessages: messages.length > 0, - isNearBottom, - syncLatestMessages, - }); + component.scrollToBottom("auto"); + scroll.syncScrollToBottomState(); + cm.markInitialScrollDone(); + }, [cm, displayMessages, scroll]); + /* ── scroll handler: pagination + mark-read ── */ useEffect(() => { - const component = messagesViewportRef.current; - if (!component || !component.containerRef?.current) return; - - const viewport = component.containerRef.current; + const c = messagesViewportRef.current; + if (!c?.containerRef?.current) return; + const viewport = c.containerRef.current; const onScroll = () => { - // Загрузка старых сообщений при приближении к верху - if (!loadingOlder && !messagesLoading && hasMoreOlder && viewport.scrollTop <= 120) { - loadOlderMessages(); + if (!cm.loadingOlder && !cm.messagesLoading && cm.hasMoreOlder && viewport.scrollTop <= 120) { + cm.loadOlderMessages(); } - // Загрузка новых сообщений при приближении к низу (щедрый порог для предзагрузки) const distFromBottom = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight; - if (!loadingNewer && !messagesLoading && hasMoreNewer && distFromBottom <= 300) { - loadNewerMessages(); + if (!cm.loadingNewer && !cm.messagesLoading && cm.hasMoreNewer && distFromBottom <= 300) { + cm.loadNewerMessages(); } - const messageEls = viewport.querySelectorAll('[data-message-date]'); - const viewportTop = viewport.getBoundingClientRect().top; - let topDate: string | null = null; - let topDateValue: string | null = null; + const isAtBottom = scroll.syncScrollToBottomState(); - for (let i = 0; i < messageEls.length; i += 1) { - const el = messageEls[i]; - const rect = el.getBoundingClientRect(); - - if (rect.bottom > viewportTop + 8) { - const isoDate = el.getAttribute('data-message-date'); - if (isoDate) { - const date = new Date(isoDate); - if (!Number.isNaN(date.getTime())) { - topDate = formatDayDivider(date); - topDateValue = formatDateInputValue(date); - } - } - break; - } - } - - if (topDate) { - setFloatingDate(topDate); - setFloatingDateValue(topDateValue); - setShowFloatingDate(true); - - if (floatingDateTimeoutRef.current) { - clearTimeout(floatingDateTimeoutRef.current); - } - - floatingDateTimeoutRef.current = setTimeout(() => { - setShowFloatingDate(false); - }, 1100); - } - - // Показ кнопки scroll-to-bottom когда не внизу - const isAtBottom = syncScrollToBottomState(); - - if (isAtBottom && !hasMoreNewer && messages.length > 0) { - const latestLoadedMessage = messages[messages.length - 1]; - scheduleMarkRead(latestLoadedMessage.id); + if (isAtBottom && !cm.hasMoreNewer && cm.messages.length > 0) { + const latest = cm.messages[cm.messages.length - 1]; + markRead.scheduleMarkRead(latest.id); + cm.resetNewMessagesBelow(); } }; viewport.addEventListener("scroll", onScroll, { passive: true }); return () => viewport.removeEventListener("scroll", onScroll); - }, [chatId, displayMessages, hasMoreOlder, hasMoreNewer, loadNewerMessages, loadOlderMessages, loadingOlder, loadingNewer, messages, messagesLoading, scheduleMarkRead, syncScrollToBottomState]); + }, [cm, markRead, scroll]); + + /* ── fallback sync ── */ + useChatFallbackSync({ + chatId: chatId || null, + isConnected, + userLoading, + hasUser: Boolean(user), + messagesLoading: cm.messagesLoading, + hasMessages: cm.messages.length > 0, + isNearBottom: scroll.isNearBottom, + syncLatestMessages: cm.syncLatestMessages, + }); + + /* ── composer helpers ── */ + + const resetComposerState = useCallback(() => { + setEditingMessageId(null); + setMessageText(""); + setReplyTo(null); + setExpandedReplyActionForId(null); + setAttachedFiles([]); + if (fileInputRef.current) fileInputRef.current.value = ""; + }, []); + + const focusMessageInput = () => { + const input = messageInputRef.current; + if (!input) return; + input.focus(); + input.setSelectionRange(input.value.length, input.value.length); + }; + + const appendEmojiToComposer = (emoji: string) => { + setMessageText(prev => `${prev}${emoji}`); + requestAnimationFrame(() => focusMessageInput()); + }; const handleSend = async () => { const text = messageText.trim(); let optimisticLocalId: string | null = null; if (!chatId || (editingMessageId && sending)) return; - if (editingMessageId) { - if (!text) return; - } else if (!text && attachedFiles.length === 0) { - return; - } + if (editingMessageId) { if (!text) return; } + else if (!text && attachedFiles.length === 0) return; - // Запоминаем позицию ДО отправки - const wasNearBottom = isNearBottom(); + const wasNearBottom = scroll.isNearBottom(); try { if (editingMessageId) { setSending(true); const updated = await apiClient.updateMessage(editingMessageId, text); - setMessages((prev) => prev.map((m) => (m.id === editingMessageId ? { ...m, ...updated } : m))); + cm.setMessages(prev => prev.map(m => m.id === editingMessageId ? { ...m, ...updated } : m)); resetComposerState(); } else { const files = [...attachedFiles]; @@ -1268,148 +451,79 @@ export default function MessageDialogPage() { ? crypto.randomUUID() : `local-${Date.now()}-${Math.random().toString(16).slice(2)}`; optimisticLocalId = localId; - const optimisticMessage = buildOptimisticMessage(text, files, localId); - - setPendingMessages((prev) => [...prev, optimisticMessage]); - schedulePendingTimer(localId); + const opt = cm.buildOptimistic(text, files, localId, replyToId); + cm.addPending(opt); + cm.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); + cm.removePendingMessage(localId); + cm.setMessages(prev => uniqueMessagesById([...prev, sent])); - if (wasNearBottom) { - requestAnimationFrame(() => { - scrollToBottom(false); - }); - } + if (wasNearBottom) requestAnimationFrame(() => scroll.scrollToBottom(false)); return; } - // Автоскролл вниз ТОЛЬКО если был внизу при отправке - // Если читал историю вверху - не скроллим, оставляем там где был - if (wasNearBottom) { - requestAnimationFrame(() => { - scrollToBottom(false); - }); - } + if (wasNearBottom) requestAnimationFrame(() => scroll.scrollToBottom(false)); } catch (e) { console.error("Ошибка отправки сообщения:", e); - if (!editingMessageId && optimisticLocalId) { - clearPendingTimer(optimisticLocalId); - setPendingMessages((prev) => - prev.map((message) => - message.local_id === optimisticLocalId - ? { ...message, send_state: "failed" } - : message - ) - ); - } + if (!editingMessageId && optimisticLocalId) cm.markPendingFailed(optimisticLocalId); } finally { - if (editingMessageId) { - setSending(false); - } + if (editingMessageId) setSending(false); } }; - const handlePickFiles = () => { - if (editingMessageId) return; - fileInputRef.current?.click(); - }; + /* ── file handling ── */ const addAttachedFiles = useCallback((files: File[]) => { - if (editingMessageId || files.length === 0) { - return; - } - - setAttachedFiles((prev) => [...prev, ...files]); + if (editingMessageId || files.length === 0) return; + setAttachedFiles(prev => [...prev, ...files]); }, [editingMessageId]); - const handleFilesChange = (e: React.ChangeEvent) => { - const files = Array.from(e.target.files || []); - addAttachedFiles(files); - }; + const handleFilesChange = (e: React.ChangeEvent) => addAttachedFiles(Array.from(e.target.files || [])); + const removeAttachedFile = (index: number) => setAttachedFiles(prev => prev.filter((_, i) => i !== index)); + const handlePickFiles = () => { if (!editingMessageId) fileInputRef.current?.click(); }; - const removeAttachedFile = (index: number) => { - setAttachedFiles((prev) => prev.filter((_, i) => i !== index)); - }; - - const focusMessageInput = () => { - const input = messageInputRef.current; - if (!input) return; - input.focus(); - const len = input.value.length; - input.setSelectionRange(len, len); - }; - - const handleReplyToMessage = (message: Message) => { - const author = message.author_name || message.author?.last_name || message.sender?.last_name || "Сотрудник"; - const preview = getMessagePreviewText(message); - setEditingMessageId(null); - setReplyTo({ id: message.id, author, preview }); - setExpandedReplyActionForId(null); - setIsReadersModalOpen(false); - setActionsMenuAnchor(null); - requestAnimationFrame(focusMessageInput); - }; + /* ── message actions ── */ const canManageMessage = (message: Message): boolean => { if (!user?.id || message.is_deleted) return false; return message.author_id === user.id || message.author?.id === user.id || message.sender?.id === user.id; }; + const handleReplyToMessage = (message: Message) => { + const author = message.author_name || message.author?.last_name || message.sender?.last_name || "Сотрудник"; + setEditingMessageId(null); + setReplyTo({ id: message.id, author, preview: getMessagePreviewText(message) }); + setExpandedReplyActionForId(null); + setIsReadersModalOpen(false); + setActionsMenuAnchor(null); + requestAnimationFrame(focusMessageInput); + }; + const handleStartEditMessage = (message: Message) => { if (!canManageMessage(message)) return; - setEditingMessageId(message.id); setReplyTo(null); setExpandedReplyActionForId(null); setMessageText(message.content || ""); setAttachedFiles([]); - if (fileInputRef.current) { - fileInputRef.current.value = ""; - } - + if (fileInputRef.current) fileInputRef.current.value = ""; requestAnimationFrame(focusMessageInput); }; - const handleCancelEdit = () => { - setEditingMessageId(null); - setMessageText(""); - }; + const handleCancelEdit = () => { setEditingMessageId(null); setMessageText(""); }; const handleDeleteMessage = async (message: Message) => { if (!canManageMessage(message)) return; - try { await apiClient.deleteMessage(message.id); - - setMessages((prev) => - prev.map((m) => - m.id === message.id - ? { - ...m, - content: "", - attachments: [], - has_attachments: false, - is_deleted: true, - } - : m - ) - ); - - if (replyTo?.id === message.id) { - setReplyTo(null); - } - if (editingMessageId === message.id) { - setEditingMessageId(null); - setMessageText(""); - } - + cm.setMessages(prev => prev.map(m => m.id === message.id ? { ...m, content: "", attachments: [], has_attachments: false, is_deleted: true } : m)); + if (replyTo?.id === message.id) setReplyTo(null); + if (editingMessageId === message.id) { setEditingMessageId(null); setMessageText(""); } setExpandedReplyActionForId(null); setIsReadersModalOpen(false); setActionsMenuAnchor(null); @@ -1419,13 +533,8 @@ export default function MessageDialogPage() { }; const handleToggleMessageActions = useCallback((messageId: number, anchor: MessageActionsAnchor) => { - setExpandedReplyActionForId((prev) => { - if (prev === messageId) { - setIsReadersModalOpen(false); - setActionsMenuAnchor(null); - return null; - } - + setExpandedReplyActionForId(prev => { + if (prev === messageId) { setIsReadersModalOpen(false); setActionsMenuAnchor(null); return null; } setIsReadersModalOpen(false); setReactionPickerForMessageId(null); setShowComposerEmojiPicker(false); @@ -1434,207 +543,81 @@ export default function MessageDialogPage() { }); }, []); - const handleAttachmentLoad = useCallback((attachmentId: number) => { - setBrokenMedia((prev) => ({ ...prev, [attachmentId]: false })); + const handleAttachmentLoad = useCallback((id: number) => { + setBrokenMedia(prev => ({ ...prev, [id]: false })); messagesViewportRef.current?.updateScrollPosition(); }, []); + const handleAttachmentError = useCallback((id: number) => { setBrokenMedia(prev => ({ ...prev, [id]: true })); }, []); + const handleUseOriginalImage = useCallback((id: number) => { setUseOriginalImage(prev => ({ ...prev, [id]: true })); }, []); - const handleAttachmentError = useCallback((attachmentId: number) => { - setBrokenMedia((prev) => ({ ...prev, [attachmentId]: true })); - }, []); - - const handleUseOriginalImage = useCallback((attachmentId: number) => { - setUseOriginalImage((prev) => ({ ...prev, [attachmentId]: true })); - }, []); - - const openDateNavigator = useCallback((dateValue?: string | null) => { - setSelectedHistoryDate(dateValue || floatingDateValue || formatDateInputValue(new Date())); - setShowFloatingDate(true); - setIsDateNavigatorOpen(true); - }, [floatingDateValue]); + /* ── date navigation ── */ const handleJumpToDate = useCallback(async (dateValue: string) => { - if (!chatId || Number.isNaN(chatId) || !dateValue || jumpingToDate) { - return; - } - - const dayStart = new Date(`${dateValue}T00:00:00`); - if (Number.isNaN(dayStart.getTime())) { - return; - } - - try { - setJumpingToDate(true); - setHistoryNavigationMode(true); - setMessagesLoading(true); - setIsDateNavigatorOpen(false); - - const [olderResponse, newerResponse] = await Promise.all([ - apiClient.getChatMessages(chatId, { - limit: 20, - before: dayStart.toISOString(), - }), - apiClient.getChatMessages(chatId, { - limit: 20, - after: dayStart.toISOString(), - mark_read: false, - }), - ]); - - const olderMessages = olderResponse.messages || []; - const newerMessages = newerResponse.messages || []; - const combinedMessages = uniqueMessagesById([...olderMessages, ...newerMessages]); - const anchorMessage = newerMessages[0] || olderMessages[olderMessages.length - 1] || combinedMessages[0] || null; - - if (!anchorMessage) { - return; - } - - setMessages(combinedMessages); - setHasMoreOlder(Boolean(olderResponse.has_more)); - setHasMoreNewer(Boolean(newerResponse.has_more)); - setInitialAnchorId(anchorMessage.id); - setInitialAnchorIndex(combinedMessages.findIndex((message) => message.id === anchorMessage.id)); - setAllowOneOlderProbe(Boolean(newerMessages[0])); - - const anchorDate = getMessageDate(anchorMessage); - if (anchorDate) { - setFloatingDate(formatDayDivider(anchorDate)); - setFloatingDateValue(formatDateInputValue(anchorDate)); - setShowFloatingDate(true); - } - - initialScrolledRef.current = false; - anchorScrollAttemptsRef.current = 0; - anchorOlderLoadAttemptsRef.current = 0; - setAnchorRetryTick(0); - resetNewMessagesBelow(); - } catch (jumpError) { - console.error("Ошибка перехода к дате:", jumpError); - } finally { - setMessagesLoading(false); - setJumpingToDate(false); - } - }, [chatId, jumpingToDate, resetNewMessagesBelow]); + if (jumpingToDate) return; + setJumpingToDate(true); + scroll.setIsDateNavigatorOpen(false); + await cm.jumpToDate(dateValue); + setJumpingToDate(false); + }, [cm, jumpingToDate, scroll]); const handleReturnToNewMessages = useCallback(async () => { - if (!chatId || Number.isNaN(chatId)) { + const c = messagesViewportRef.current?.containerRef?.current; + + if (!cm.hasMoreNewer && !cm.historyNavigationMode) { + cm.setHistoryNavigationMode(false); + if (c) c.scrollTop = c.scrollHeight; + cm.resetNewMessagesBelow(); return; } - const component = messagesViewportRef.current; - const container = component?.containerRef?.current; - const lastReadMessageId = Math.max(chat?.last_read_message_id ?? 0, lastConfirmedMarkReadIdRef.current); - const shouldRestoreUnreadBoundary = (hasMoreNewer || historyNavigationMode) && lastReadMessageId > 0; - - if (!shouldRestoreUnreadBoundary) { - setHistoryNavigationMode(false); - if (container) { - container.scrollTop = container.scrollHeight; - } - resetNewMessagesBelow(); - return; + const result = await cm.returnToUnread(); + if (!result) { + if (c) c.scrollTop = c.scrollHeight; + cm.setHistoryNavigationMode(false); + cm.resetNewMessagesBelow(); + } else { + scroll.setShowScrollToBottom(true); } + }, [cm, scroll]); - try { - setMessagesLoading(true); - - const around = await apiClient.getChatMessagesAround(chatId, { - limit: 30, - around_id: lastReadMessageId, - }); - - const normalized = uniqueMessagesById(around.messages || []); - if (normalized.length === 0) { - if (container) { - container.scrollTop = container.scrollHeight; - } - setHistoryNavigationMode(false); - resetNewMessagesBelow(); - return; - } - - setMessages(normalized); - setHasMoreOlder( - typeof around.has_more_before === "boolean" ? around.has_more_before : normalized.length >= 50 - ); - setHasMoreNewer(Boolean(around.has_more_after)); - setInitialAnchorId(around.anchor_id ?? lastReadMessageId); - setInitialAnchorIndex(typeof around.anchor_index === "number" ? around.anchor_index : null); - setAllowOneOlderProbe(Boolean(around.anchor_id ?? lastReadMessageId)); - setHistoryNavigationMode(Boolean(around.has_more_after)); - setShowScrollToBottom(true); - - initialScrolledRef.current = false; - anchorScrollAttemptsRef.current = 0; - anchorOlderLoadAttemptsRef.current = 0; - setAnchorRetryTick(0); - } catch (error) { - console.error("Ошибка возврата к непрочитанным сообщениям:", error); - } finally { - setMessagesLoading(false); - } - }, [chat?.last_read_message_id, chatId, hasMoreNewer, historyNavigationMode, resetNewMessagesBelow]); + /* ── chat settings toggles ── */ const handleTogglePin = async () => { if (!chatId) return; - try { - const response = await apiClient.togglePinChat(chatId); - const newIsPinned = response.is_pinned ?? !isPinned; - setIsPinned(newIsPinned); - - // Обновляем chat объект - if (chat) { - setChat({ ...chat, is_pinned: newIsPinned }); - } - } catch (e) { - console.error("Ошибка переключения закрепления:", e); - } + const resp = await apiClient.togglePinChat(chatId); + const v = resp.is_pinned ?? !isPinned; + setIsPinned(v); + if (cm.chat) cm.setChat({ ...cm.chat, is_pinned: v }); + } catch (e) { console.error("Ошибка переключения закрепления:", e); } }; const handleToggleNotifications = async () => { if (!chatId) return; - try { - const response = await apiClient.toggleChatNotifications(chatId); - const newNotificationsEnabled = response.notifications_enabled ?? !notificationsEnabled; - setNotificationsEnabled(newNotificationsEnabled); - - // Обновляем chat объект - if (chat) { - setChat({ ...chat, notifications_enabled: newNotificationsEnabled }); - } - } catch (e) { - console.error("Ошибка переключения уведомлений:", e); - } + const resp = await apiClient.toggleChatNotifications(chatId); + const v = resp.notifications_enabled ?? !notificationsEnabled; + setNotificationsEnabled(v); + if (cm.chat) cm.setChat({ ...cm.chat, notifications_enabled: v }); + } catch (e) { console.error("Ошибка переключения уведомлений:", e); } }; - useEffect(() => { - const onWindowClick = (e: MouseEvent) => { - const target = e.target as HTMLElement | null; - if (!target) return; - if (target.closest("[data-actions-menu='true']")) return; - if (target.closest("[data-actions-trigger='true']")) return; - if (target.closest("[data-reaction-picker='true']")) return; - if (target.closest(".reaction-picker-modal")) return; - if (target.closest("[data-composer-emoji='true']")) return; - if (target.closest("[data-date-navigator='true']")) return; - if (target.closest("[data-date-trigger='true']")) return; + /* ── global click / escape dismissals ── */ + useEffect(() => { + const onClick = (e: MouseEvent) => { + const t = e.target as HTMLElement | null; + if (!t) return; + if (t.closest("[data-actions-menu='true'],[data-actions-trigger='true'],[data-reaction-picker='true'],.reaction-picker-modal,[data-composer-emoji='true'],[data-date-navigator='true'],[data-date-trigger='true']")) return; setExpandedReplyActionForId(null); setActionsMenuAnchor(null); setReactionPickerForMessageId(null); setShowComposerEmojiPicker(false); - setIsDateNavigatorOpen(false); + scroll.setIsDateNavigatorOpen(false); }; - - window.addEventListener("mousedown", onWindowClick); - return () => window.removeEventListener("mousedown", onWindowClick); - }, []); - - const isInteractiveArea = (target: HTMLElement): boolean => - Boolean(target.closest("textarea, input, button, a, video, audio, [contenteditable='true']")); + window.addEventListener("mousedown", onClick); + return () => window.removeEventListener("mousedown", onClick); + }, [scroll]); useEffect(() => { const onKey = (e: KeyboardEvent) => { @@ -1644,74 +627,64 @@ export default function MessageDialogPage() { setActionsMenuAnchor(null); setReactionPickerForMessageId(null); setShowComposerEmojiPicker(false); - setIsDateNavigatorOpen(false); + scroll.setIsDateNavigatorOpen(false); } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); - }, []); + }, [scroll]); - // При открытии мобильной клавиатуры прокручиваем чат вниз + // Mobile keyboard viewport adjustment useEffect(() => { const vv = window.visualViewport; if (!vv) return; - - let prevHeight = vv.height; - + let prevH = vv.height; const onResize = () => { - const newHeight = vv.height; - if (newHeight < prevHeight) { - requestAnimationFrame(() => { - messagesViewportRef.current?.scrollToBottom('auto'); - }); - } - prevHeight = newHeight; + if (vv.height < prevH) requestAnimationFrame(() => messagesViewportRef.current?.scrollToBottom("auto")); + prevH = vv.height; }; - - vv.addEventListener('resize', onResize); - return () => vv.removeEventListener('resize', onResize); + vv.addEventListener("resize", onResize); + return () => vv.removeEventListener("resize", onResize); }, []); + // Redirect wheel events outside chat container into it useEffect(() => { - const onGlobalWheel = (e: WheelEvent) => { + const onWheel = (e: WheelEvent) => { if (e.defaultPrevented || mediaPreview) return; - - const component = messagesViewportRef.current; - if (!component || !component.containerRef?.current) return; - const viewport = component.containerRef.current; - - const target = e.target as HTMLElement | null; - if (!target) return; - - if (viewport.contains(target)) return; - if (isInteractiveArea(target)) return; - + const c = messagesViewportRef.current; + if (!c?.containerRef?.current) return; + const v = c.containerRef.current; + const t = e.target as HTMLElement | null; + if (!t || v.contains(t) || t.closest("textarea,input,button,a,video,audio,[contenteditable='true']")) return; e.preventDefault(); - viewport.scrollTop += e.deltaY; + v.scrollTop += e.deltaY; }; - - window.addEventListener("wheel", onGlobalWheel, { passive: false }); - return () => window.removeEventListener("wheel", onGlobalWheel); + window.addEventListener("wheel", onWheel, { passive: false }); + return () => window.removeEventListener("wheel", onWheel); }, [mediaPreview]); + /* ────────────────────────────────────────────────────── + * RENDER + * ────────────────────────────────────────────────────── */ + return ( - {loading ? ( + {cm.loading ? (

Загрузка чатов...

- ) : error ? ( + ) : cm.error ? (
-

{error}

+

{cm.error}

) : (
- {chat ? ( + {cm.chat ? ( <>
- {floatingDate ? ( -
-
) : null} - {isDateNavigatorOpen ? ( + {/* Date navigator panel */} + {scroll.isDateNavigatorOpen ? (

Перейти к дате

- - setSelectedHistoryDate(event.target.value)} - className="h-9 flex-1 rounded-lg border border-gray-200 bg-white px-3 text-sm text-gray-800 outline-none transition focus:border-sky-400 focus:ring-2 focus:ring-sky-100" - /> - + + scroll.setSelectedHistoryDate(e.target.value)} className="h-9 flex-1 rounded-lg border border-gray-200 bg-white px-3 text-sm text-gray-800 outline-none transition focus:border-sky-400 focus:ring-2 focus:ring-sky-100" /> +
- - - + + +
) : null} + {/* Message list */} - {messagesLoading ? ( + {cm.messagesLoading ? (

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

) : displayMessages.length === 0 ? (

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

) : (
- {loadingOlder ? ( -

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

- ) : null} + {cm.loadingOlder ?

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

: null} {displayMessages.map((message, index) => { const replyToId = getReplyToId(message); const repliedMessage = replyToId ? messagesById.get(replyToId) : null; - const currentDayKey = getMessageDayKey(message); - const previousDayKey = index > 0 ? getMessageDayKey(displayMessages[index - 1]) : null; - const currentMessageDate = getMessageDate(message); - const showDayDivider = Boolean(currentMessageDate && currentDayKey !== previousDayKey); + const curDay = getMessageDayKey(message); + const prevDay = index > 0 ? getMessageDayKey(displayMessages[index - 1]) : null; + const curDate = getMessageDate(message); + const showDayDivider = Boolean(curDate && curDay !== prevDay); return ( - {showDayDivider && currentMessageDate ? ( + {showDayDivider && curDate ? (
-
) : null} @@ -1850,35 +776,34 @@ export default function MessageDialogPage() { )} - {/* Кнопка прокрутки вниз */} - {showScrollToBottom && ( + {/* Scroll-to-bottom button */} + {scroll.showScrollToBottom && (
)} + {/* Composer area */}
{isTyping && (
- - - + + +
Собеседник печатает...
@@ -1901,15 +826,12 @@ export default function MessageDialogPage() { onFilesChange={handleFilesChange} onRemoveFile={removeAttachedFile} onToggleEmojiPicker={() => { - setShowComposerEmojiPicker((prev) => !prev); + setShowComposerEmojiPicker(prev => !prev); setExpandedReplyActionForId(null); setActionsMenuAnchor(null); setReactionPickerForMessageId(null); }} - onSelectEmoji={(emoji) => { - appendEmojiToComposer(emoji); - setShowComposerEmojiPicker(false); - }} + onSelectEmoji={(emoji) => { appendEmojiToComposer(emoji); setShowComposerEmojiPicker(false); }} onInputClick={() => setShowComposerEmojiPicker(false)} onChangeMessage={setMessageText} onTyping={sendTyping} @@ -1942,12 +864,7 @@ export default function MessageDialogPage() { canReply={selectedActionCanReply} canManage={selectedActionCanManage} recentReactions={selectedActionRecentReactions} - onQuickReact={(emoji) => { - handleReact(selectedActionMessage, emoji); - setExpandedReplyActionForId(null); - setIsReadersModalOpen(false); - setActionsMenuAnchor(null); - }} + onQuickReact={(emoji) => { handleReact(selectedActionMessage, emoji); setExpandedReplyActionForId(null); setIsReadersModalOpen(false); setActionsMenuAnchor(null); }} onOpenReactionPicker={() => setReactionPickerForMessageId(selectedActionMessage.id)} onShowAllReaders={() => setIsReadersModalOpen(true)} onReply={() => handleReplyToMessage(selectedActionMessage)} @@ -1957,21 +874,15 @@ export default function MessageDialogPage() { ) : null} {selectedActionMessage ? ( - setIsReadersModalOpen(false)} - readers={selectedActionReaders} - /> + setIsReadersModalOpen(false)} readers={selectedActionReaders} /> ) : null} {reactionPickerForMessageId ? ( setReactionPickerForMessageId(null)} onSelect={(emoji) => { - const message = messagesById.get(reactionPickerForMessageId); - if (message) { - void handleReact(message, emoji); - } + const msg = messagesById.get(reactionPickerForMessageId); + if (msg) void handleReact(msg, emoji); setReactionPickerForMessageId(null); setExpandedReplyActionForId(null); setActionsMenuAnchor(null); diff --git a/frontend/src/hooks/useChatMessages.ts b/frontend/src/hooks/useChatMessages.ts new file mode 100644 index 00000000..97a154bb --- /dev/null +++ b/frontend/src/hooks/useChatMessages.ts @@ -0,0 +1,576 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { apiClient } from "@/lib/api"; +import type { Chat, Message } from "@/types/api"; +import { getMessageTimestamp, uniqueMessagesById } from "@/lib/messages/chatUtils"; +import { getReplyToId } from "@/lib/messages/messageUtils"; + +const PENDING_MESSAGE_DELAY_MS = 8000; + +/* ─── public types ─── */ + +export interface UseChatMessagesOptions { + chatId: number; + userId: number | undefined; + userLoading: boolean; +} + +export interface UseChatMessagesReturn { + chat: Chat | null; + setChat: React.Dispatch>; + messages: Message[]; + setMessages: React.Dispatch>; + pendingMessages: Message[]; + loading: boolean; + messagesLoading: boolean; + error: string | null; + hasMoreOlder: boolean; + hasMoreNewer: boolean; + loadingOlder: boolean; + loadingNewer: boolean; + initialAnchorId: number | null; + initialAnchorIndex: number | null; + allowOneOlderProbe: boolean; + anchorRetryTick: number; + /** Whether we are in "date navigation" (history) mode */ + historyNavigationMode: boolean; + setHistoryNavigationMode: React.Dispatch>; + /** Number of new (unread) messages that appeared below the viewport */ + newMessagesBelowCount: number; + /** Ref containing unread-below message ids */ + unreadBelowIdsRef: React.MutableRefObject>; + /** hasMoreNewer kept in a ref for synchronous access */ + hasMoreNewerRef: React.MutableRefObject; + /** Load older (prepend) */ + loadOlderMessages: () => Promise; + /** Load newer (append) */ + loadNewerMessages: (opts?: { markRead?: boolean }) => Promise; + /** Background "fallback sync" of latest messages */ + syncLatestMessages: (opts?: { limit?: number; scrollOnSync?: boolean }) => Promise; + /** Register incoming messages as "below viewport" */ + registerNewMessagesBelow: (msgs: Message[]) => void; + /** Reset the "below viewport" counter & set */ + resetNewMessagesBelow: () => void; + /** Remove a pending message by its local_id */ + removePendingMessage: (localId?: string | null) => void; + /** Reconcile a server-confirmed message with its pending twin */ + removePendingByServer: (confirmed: Message) => void; + /** Create an optimistic message for instant UI */ + buildOptimistic: (text: string, files: File[], localId: string, replyToId?: number) => Message; + /** Append a pending message */ + addPending: (msg: Message) => void; + /** Start the "delayed" timer for a pending message */ + schedulePendingTimer: (localId: string) => void; + /** Mark a pending message as failed */ + markPendingFailed: (localId: string) => void; + /** Jump to a specific date (history navigation) */ + jumpToDate: (dateISO: string) => Promise; + /** Return to unread boundary (around last_read_message_id) */ + returnToUnread: () => Promise<{ + hasMoreAfter: boolean; + anchorId: number | null; + anchorIndex: number | null; + } | null>; + /** Set anchorRetryTick (used to retry anchor scroll) */ + setAnchorRetryTick: React.Dispatch>; + /** Mark initial scroll as done */ + markInitialScrollDone: () => void; + /** Whether initial scroll still pending */ + isInitialScrollPending: () => boolean; + /** Ref to isNearBottom callback (set by scroll hook) */ + isNearBottomRef: React.MutableRefObject<() => boolean>; + /** Ref to scrollToBottom callback (set by scroll hook) */ + scrollToBottomRef: React.MutableRefObject<(smooth?: boolean) => void>; +} + +/* ─── hook ─── */ + +export function useChatMessages({ + chatId, + userId, + userLoading, +}: UseChatMessagesOptions): UseChatMessagesReturn { + /* ── core state ── */ + const [chat, setChat] = useState(null); + const [messages, setMessages] = useState([]); + const [pendingMessages, setPendingMessages] = useState([]); + const [loading, setLoading] = useState(true); + const [messagesLoading, setMessagesLoading] = useState(false); + const [error, setError] = useState(null); + + /* ── pagination ── */ + const [hasMoreOlder, setHasMoreOlder] = useState(false); + const [hasMoreNewer, setHasMoreNewer] = useState(false); + const [loadingOlder, setLoadingOlder] = useState(false); + const [loadingNewer, setLoadingNewer] = useState(false); + + /* ── anchor / history ── */ + const [initialAnchorId, setInitialAnchorId] = useState(null); + const [initialAnchorIndex, setInitialAnchorIndex] = useState(null); + const [allowOneOlderProbe, setAllowOneOlderProbe] = useState(false); + const [anchorRetryTick, setAnchorRetryTick] = useState(0); + const [historyNavigationMode, setHistoryNavigationMode] = useState(false); + + /* ── unread tracking ── */ + const [newMessagesBelowCount, setNewMessagesBelowCount] = useState(0); + const unreadBelowIdsRef = useRef>(new Set()); + + /* ── refs ── */ + const hasMoreNewerRef = useRef(false); + const initialScrolledRef = useRef(false); + const pendingMessagesRef = useRef([]); + const localSeqRef = useRef(0); + const pendingTimersRef = useRef>(new Map()); + const fallbackInFlightRef = useRef(false); + + /** Injected by scroll hook */ + const isNearBottomRef = useRef<() => boolean>(() => false); + const scrollToBottomRef = useRef<(smooth?: boolean) => void>(() => {}); + + /* keep refs in sync */ + useEffect(() => { hasMoreNewerRef.current = hasMoreNewer; }, [hasMoreNewer]); + useEffect(() => { pendingMessagesRef.current = pendingMessages; }, [pendingMessages]); + + /* ── pending message helpers ── */ + + const clearPendingTimer = useCallback((localId?: string | null) => { + if (!localId) return; + const t = pendingTimersRef.current.get(localId); + if (t) { clearTimeout(t); pendingTimersRef.current.delete(localId); } + }, []); + + const markPendingAsDelayed = useCallback((localId: string) => { + setPendingMessages(prev => + prev.map(m => m.local_id === localId && m.send_state === "pending" ? { ...m, send_state: "delayed" } : m), + ); + }, []); + + const schedulePendingTimer = useCallback((localId: string) => { + clearPendingTimer(localId); + const t = setTimeout(() => { pendingTimersRef.current.delete(localId); markPendingAsDelayed(localId); }, PENDING_MESSAGE_DELAY_MS); + pendingTimersRef.current.set(localId, t); + }, [clearPendingTimer, markPendingAsDelayed]); + + const removePendingMessage = useCallback((localId?: string | null) => { + if (!localId) return; + clearPendingTimer(localId); + setPendingMessages(prev => prev.filter(m => m.local_id !== localId)); + }, [clearPendingTimer]); + + const clearAllPendingTimers = useCallback(() => { + pendingTimersRef.current.forEach(t => clearTimeout(t)); + pendingTimersRef.current.clear(); + }, []); + + const markPendingFailed = useCallback((localId: string) => { + clearPendingTimer(localId); + setPendingMessages(prev => prev.map(m => m.local_id === localId ? { ...m, send_state: "failed" } : m)); + }, [clearPendingTimer]); + + /* ── attachment signature matching ── */ + + const getAttachmentSig = useCallback((msg: Message) => + (msg.attachments || []).map(a => `${a.file_name}|${a.file_size ?? 0}|${a.mime_type ?? ""}`).sort().join("||"), + []); + + const findMatchingPendingLocalId = useCallback((confirmed: Message) => { + if (!userId || confirmed.author_id !== userId) return null; + const confirmedReplyToId = getReplyToId(confirmed) ?? null; + const confirmedSig = getAttachmentSig(confirmed); + const confirmedTs = getMessageTimestamp(confirmed); + + const candidates = pendingMessagesRef.current + .filter(p => p.author_id === userId && p.content === confirmed.content) + .filter(p => (getReplyToId(p) ?? null) === confirmedReplyToId) + .filter(p => getAttachmentSig(p) === confirmedSig) + .map(p => ({ localId: p.local_id ?? null, diff: Math.abs(getMessageTimestamp(p) - confirmedTs) })) + .filter(c => Boolean(c.localId)) + .sort((a, b) => a.diff - b.diff); + + const best = candidates[0]; + if (!best) return null; + if (confirmedTs > 0 && best.diff > 10 * 60 * 1000) return null; + return best.localId; + }, [getAttachmentSig, userId]); + + const removePendingByServer = useCallback((confirmed: Message) => { + const localId = findMatchingPendingLocalId(confirmed); + if (localId) removePendingMessage(localId); + }, [findMatchingPendingLocalId, removePendingMessage]); + + /* ── unread below ── */ + + const resetNewMessagesBelow = useCallback(() => { + unreadBelowIdsRef.current.clear(); + setNewMessagesBelowCount(0); + }, []); + + const registerNewMessagesBelow = useCallback((incoming: Message[]) => { + if (!userId || incoming.length === 0) return; + let added = 0; + incoming.forEach(m => { + const authorId = m.author_id ?? m.author?.id ?? m.sender?.id; + if (authorId === userId || m.id <= 0 || unreadBelowIdsRef.current.has(m.id)) return; + unreadBelowIdsRef.current.add(m.id); + added += 1; + }); + if (added > 0) { + setNewMessagesBelowCount(prev => prev + added); + } + }, [userId]); + + /* ── optimistic message builder ── */ + + const guessType = useCallback((f: File) => { + const mime = (f.type || "").toLowerCase(); + if (mime.startsWith("image/")) return "image"; + if (mime.startsWith("video/")) return "video"; + if (mime.startsWith("audio/")) return "audio"; + return "file"; + }, []); + + const buildOptimistic = useCallback((text: string, files: File[], localId: string, replyToId?: number): Message => { + localSeqRef.current += 1; + const now = Date.now() + localSeqRef.current; + return { + id: -now, + chat: chatId, + local_id: localId, + author_id: userId, + 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((f, i) => ({ + id: -(now * 100 + i), + file_name: f.name, + file_type: guessType(f), + file_url: "", + file_size: f.size, + mime_type: f.type, + is_local: true, + })), + reply_to_id: replyToId, + }; + }, [chatId, guessType, userId]); + + const addPending = useCallback((msg: Message) => { + setPendingMessages(prev => [...prev, msg]); + }, []); + + /* ── load chat ── */ + + useEffect(() => { + if (!chatId || Number.isNaN(chatId)) { setChat(null); setLoading(false); return; } + if (userLoading) { setLoading(true); return; } + if (!userId) { setChat(null); setLoading(false); return; } + + let cancelled = false; + (async () => { + try { + setLoading(true); + setError(null); + const loaded = await apiClient.getChat(chatId); + if (!cancelled) setChat(loaded); + } catch { + if (!cancelled) { setChat(null); setError("Не удалось загрузить чат. Проверьте подключение и попробуйте снова."); } + } finally { + if (!cancelled) setLoading(false); + } + })(); + return () => { cancelled = true; }; + }, [chatId, userLoading, userId]); + + /* ── load messages ── */ + + useEffect(() => { + if (!chatId || Number.isNaN(chatId)) { + clearAllPendingTimers(); setMessages([]); setPendingMessages([]); + setHasMoreOlder(false); setHasMoreNewer(false); + setInitialAnchorId(null); setInitialAnchorIndex(null); + setAllowOneOlderProbe(false); + initialScrolledRef.current = false; + resetNewMessagesBelow(); + return; + } + if (userLoading || !userId) return; + + let cancelled = false; + + (async () => { + try { + setMessagesLoading(true); + const chatDetails = await apiClient.getChat(chatId); + if (cancelled) return; + setChat(chatDetails); + const lastReadId = chatDetails.last_read_message_id; + + const around = await apiClient.getChatMessagesAround(chatId, { + limit: 30, + around_id: lastReadId || undefined, + }); + if (cancelled) return; + + const aroundMsgs = around.messages || []; + if (aroundMsgs.length > 0) { + clearAllPendingTimers(); + const norm = uniqueMessagesById(aroundMsgs); + setMessages(norm); + setPendingMessages([]); + setHasMoreOlder(typeof around.has_more_before === "boolean" ? around.has_more_before : norm.length >= 50); + setHasMoreNewer(Boolean(around.has_more_after)); + setInitialAnchorId(around.anchor_id ?? null); + setInitialAnchorIndex(typeof around.anchor_index === "number" ? around.anchor_index : null); + setAllowOneOlderProbe(Boolean(around.anchor_id)); + + // Seed unread-below from loaded messages + unreadBelowIdsRef.current = new Set( + norm.filter(m => { + const aId = m.author_id ?? m.author?.id ?? m.sender?.id; + return aId !== userId && m.id > (lastReadId ?? 0); + }).map(m => m.id), + ); + setNewMessagesBelowCount(Math.max(chatDetails.unread_count ?? 0, unreadBelowIdsRef.current.size)); + } else { + clearAllPendingTimers(); + const resp = await apiClient.getChatMessages(chatId, { limit: 50 }); + if (cancelled) return; + const norm = uniqueMessagesById(resp.messages || []); + setMessages(norm); setPendingMessages([]); + setHasMoreOlder(Boolean(resp.has_more)); setHasMoreNewer(false); + setInitialAnchorId(null); setInitialAnchorIndex(null); setAllowOneOlderProbe(false); + unreadBelowIdsRef.current = new Set( + norm.filter(m => { + const aId = m.author_id ?? m.author?.id ?? m.sender?.id; + return aId !== userId && m.id > (lastReadId ?? 0); + }).map(m => m.id), + ); + setNewMessagesBelowCount(Math.max(chatDetails.unread_count ?? 0, unreadBelowIdsRef.current.size)); + } + + initialScrolledRef.current = false; + setAnchorRetryTick(0); + } catch (err: unknown) { + if (cancelled) return; + const msg = err instanceof Error ? err.message : String(err); + if (msg.includes("403")) { + setError("Нет доступа к этому чату"); + clearAllPendingTimers(); setMessages([]); setPendingMessages([]); + } else { + console.error("Ошибка загрузки сообщений:", err); + } + setHasMoreOlder(false); setHasMoreNewer(false); + setInitialAnchorId(null); setInitialAnchorIndex(null); setAllowOneOlderProbe(false); + resetNewMessagesBelow(); + initialScrolledRef.current = false; + setAnchorRetryTick(0); + } finally { + if (!cancelled) setMessagesLoading(false); + } + })(); + + return () => { cancelled = true; }; + }, [chatId, clearAllPendingTimers, resetNewMessagesBelow, userLoading, userId]); + + // Cleanup timers on unmount + useEffect(() => () => { clearAllPendingTimers(); }, [clearAllPendingTimers]); + + // Reset per-chat state + useEffect(() => { + setHistoryNavigationMode(false); + resetNewMessagesBelow(); + }, [chatId, resetNewMessagesBelow]); + + /* ── pagination ── */ + + const loadOlderMessages = useCallback(async () => { + if (!chatId || Number.isNaN(chatId) || loadingOlder || messagesLoading || messages.length === 0) return; + if (!hasMoreOlder && !allowOneOlderProbe) return; + + const oldest = messages[0]; + try { + setLoadingOlder(true); + const resp = await apiClient.getChatMessages(chatId, { limit: 40, before_id: oldest.id }); + const older = resp.messages || []; + if (older.length > 0) setMessages(prev => uniqueMessagesById([...older, ...prev])); + setHasMoreOlder(Boolean(resp.has_more)); + setAllowOneOlderProbe(false); + } catch (e) { + console.error("Ошибка подгрузки старых сообщений:", e); + } finally { + setLoadingOlder(false); + } + }, [allowOneOlderProbe, chatId, hasMoreOlder, loadingOlder, messages, messagesLoading]); + + const loadNewerMessages = useCallback(async (opts?: { markRead?: boolean }) => { + if (!chatId || Number.isNaN(chatId) || loadingNewer || messagesLoading || !hasMoreNewer || messages.length === 0) return; + + const newest = messages[messages.length - 1]; + try { + setLoadingNewer(true); + const resp = await apiClient.getChatMessages(chatId, { limit: 10, after_id: newest.id, mark_read: false }); + const newer = resp.messages || []; + if (newer.length > 0) { + newer.forEach(removePendingByServer); + setMessages(prev => uniqueMessagesById([...prev, ...newer])); + } + setHasMoreNewer(Boolean(resp.has_more)); + } catch (e) { + console.error("Ошибка подгрузки новых сообщений:", e); + } finally { + setLoadingNewer(false); + } + }, [chatId, hasMoreNewer, loadingNewer, messages, messagesLoading, removePendingByServer]); + + /* ── fallback sync ── */ + + const syncLatestMessages = useCallback(async (options: { limit?: number; scrollOnSync?: boolean } = {}) => { + if (!chatId || Number.isNaN(chatId) || messagesLoading || messages.length === 0 || fallbackInFlightRef.current) return; + + const newest = messages[messages.length - 1]; + fallbackInFlightRef.current = true; + + try { + const resp = await apiClient.getChatMessages(chatId, { limit: options.limit ?? 20, after_id: newest.id, mark_read: false }); + const newer = resp.messages || []; + if (newer.length > 0) { + newer.forEach(removePendingByServer); + setMessages(prev => uniqueMessagesById([...prev, ...newer])); + + if (options.scrollOnSync) { + requestAnimationFrame(() => scrollToBottomRef.current(false)); + } + } + setHasMoreNewer(Boolean(resp.has_more)); + } catch (e) { + console.error("Ошибка тихой синхронизации сообщений:", e); + } finally { + fallbackInFlightRef.current = false; + } + }, [chatId, messages, messagesLoading, removePendingByServer]); + + /* ── jump to date ── */ + + const jumpToDate = useCallback(async (dateValue: string) => { + if (!chatId || Number.isNaN(chatId) || !dateValue) return; + const dayStart = new Date(`${dateValue}T00:00:00`); + if (Number.isNaN(dayStart.getTime())) return; + + try { + setMessagesLoading(true); + setHistoryNavigationMode(true); + + const [olderResp, newerResp] = await Promise.all([ + apiClient.getChatMessages(chatId, { limit: 20, before: dayStart.toISOString() }), + apiClient.getChatMessages(chatId, { limit: 20, after: dayStart.toISOString(), mark_read: false }), + ]); + + const combined = uniqueMessagesById([...(olderResp.messages || []), ...(newerResp.messages || [])]); + const anchor = (newerResp.messages || [])[0] || (olderResp.messages || []).at(-1) || combined[0] || null; + if (!anchor) return; + + setMessages(combined); + setHasMoreOlder(Boolean(olderResp.has_more)); + setHasMoreNewer(Boolean(newerResp.has_more)); + setInitialAnchorId(anchor.id); + setInitialAnchorIndex(combined.findIndex(m => m.id === anchor.id)); + setAllowOneOlderProbe(Boolean((newerResp.messages || [])[0])); + + initialScrolledRef.current = false; + setAnchorRetryTick(0); + } catch (e) { + console.error("Ошибка перехода к дате:", e); + } finally { + setMessagesLoading(false); + } + }, [chatId]); + + /* ── return to unread boundary ── */ + + const returnToUnread = useCallback(async () => { + if (!chatId || Number.isNaN(chatId)) return null; + + const lastReadMessageId = Math.max(chat?.last_read_message_id ?? 0, 0); + const shouldRestore = (hasMoreNewer || historyNavigationMode) && lastReadMessageId > 0; + + if (!shouldRestore) return null; + + try { + setMessagesLoading(true); + const around = await apiClient.getChatMessagesAround(chatId, { limit: 30, around_id: lastReadMessageId }); + const norm = uniqueMessagesById(around.messages || []); + if (norm.length === 0) return null; + + setMessages(norm); + setHasMoreOlder(typeof around.has_more_before === "boolean" ? around.has_more_before : norm.length >= 50); + setHasMoreNewer(Boolean(around.has_more_after)); + setInitialAnchorId(around.anchor_id ?? lastReadMessageId); + setInitialAnchorIndex(typeof around.anchor_index === "number" ? around.anchor_index : null); + setAllowOneOlderProbe(Boolean(around.anchor_id ?? lastReadMessageId)); + setHistoryNavigationMode(Boolean(around.has_more_after)); + + initialScrolledRef.current = false; + setAnchorRetryTick(0); + + return { + hasMoreAfter: Boolean(around.has_more_after), + anchorId: around.anchor_id ?? lastReadMessageId, + anchorIndex: typeof around.anchor_index === "number" ? around.anchor_index : null, + }; + } catch (e) { + console.error("Ошибка возврата к непрочитанным сообщениям:", e); + return null; + } finally { + setMessagesLoading(false); + } + }, [chat?.last_read_message_id, chatId, hasMoreNewer, historyNavigationMode]); + + /* ── scroll tracking helpers ── */ + + const markInitialScrollDone = useCallback(() => { initialScrolledRef.current = true; }, []); + const isInitialScrollPending = useCallback(() => !initialScrolledRef.current, []); + + return { + chat, + setChat, + messages, + setMessages, + pendingMessages, + loading, + messagesLoading, + error, + hasMoreOlder, + hasMoreNewer, + loadingOlder, + loadingNewer, + initialAnchorId, + initialAnchorIndex, + allowOneOlderProbe, + anchorRetryTick, + historyNavigationMode, + setHistoryNavigationMode, + newMessagesBelowCount, + unreadBelowIdsRef, + hasMoreNewerRef, + loadOlderMessages, + loadNewerMessages, + syncLatestMessages, + registerNewMessagesBelow, + resetNewMessagesBelow, + removePendingMessage, + removePendingByServer, + buildOptimistic, + addPending, + schedulePendingTimer, + markPendingFailed, + jumpToDate, + returnToUnread, + setAnchorRetryTick, + markInitialScrollDone, + isInitialScrollPending, + isNearBottomRef, + scrollToBottomRef, + }; +} diff --git a/frontend/src/hooks/useChatScroll.ts b/frontend/src/hooks/useChatScroll.ts new file mode 100644 index 00000000..ea875a65 --- /dev/null +++ b/frontend/src/hooks/useChatScroll.ts @@ -0,0 +1,194 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { formatDayDivider } from "@/lib/messages/messageUtils"; +import type { ScrollableMessageListInner } from "@/components/ScrollableMessageList"; + +/* ─── helpers ─── */ + +function formatDateInputValue(date: Date): string { + const year = date.getFullYear(); + const month = `${date.getMonth() + 1}`.padStart(2, "0"); + const day = `${date.getDate()}`.padStart(2, "0"); + return `${year}-${month}-${day}`; +} + +export function shiftDateInputValue(value: string, deltaDays: number): string { + const baseDate = value ? new Date(`${value}T00:00:00`) : new Date(); + if (Number.isNaN(baseDate.getTime())) return formatDateInputValue(new Date()); + baseDate.setDate(baseDate.getDate() + deltaDays); + return formatDateInputValue(baseDate); +} + +/* ─── types ─── */ + +export interface UseChatScrollOptions { + chatId: number; + /** Number of rendered messages, used to recalculate floating date after reload/jump */ + messageCount?: number; + /** Ref to the scroll container component */ + viewportRef: React.RefObject; + /** Whether there are more newer messages to load */ + hasMoreNewerRef: React.MutableRefObject; + /** Ref to unread-below message ids */ + unreadBelowIdsRef: React.MutableRefObject>; + /** Whether we are in history navigation mode */ + historyNavigationMode: boolean; +} + +export interface UseChatScrollReturn { + /** Whether the scroll-to-bottom button should be visible */ + showScrollToBottom: boolean; + setShowScrollToBottom: React.Dispatch>; + /** Floating date display */ + floatingDate: string | null; + floatingDateValue: string | null; + showFloatingDate: boolean; + /** Date navigator state */ + isDateNavigatorOpen: boolean; + setIsDateNavigatorOpen: React.Dispatch>; + selectedHistoryDate: string; + setSelectedHistoryDate: React.Dispatch>; + /** Check if user is near bottom */ + isNearBottom: () => boolean; + /** Scroll to bottom */ + scrollToBottom: (smooth?: boolean) => void; + /** Synchronize the scroll-to-bottom button visibility. Returns true if at bottom. */ + syncScrollToBottomState: () => boolean; + /** Open date navigator, optionally with a preset date */ + openDateNavigator: (dateValue?: string | null) => void; +} + +/* ─── hook ─── */ + +export function useChatScroll({ + chatId, + messageCount = 0, + viewportRef, + hasMoreNewerRef, + unreadBelowIdsRef, + historyNavigationMode, +}: UseChatScrollOptions): UseChatScrollReturn { + const [showScrollToBottom, setShowScrollToBottom] = useState(false); + const [floatingDate, setFloatingDate] = useState(null); + const [floatingDateValue, setFloatingDateValue] = useState(null); + const [showFloatingDate, setShowFloatingDate] = useState(false); + const [isDateNavigatorOpen, setIsDateNavigatorOpen] = useState(false); + const [selectedHistoryDate, setSelectedHistoryDate] = useState(""); + + const floatingDateTimeoutRef = useRef(null); + + // Sync selected date from floating date when navigator is closed + useEffect(() => { + if (floatingDateValue && !isDateNavigatorOpen) { + setSelectedHistoryDate(floatingDateValue); + } + }, [floatingDateValue, isDateNavigatorOpen]); + + // Cleanup + useEffect(() => () => { + if (floatingDateTimeoutRef.current) clearTimeout(floatingDateTimeoutRef.current); + }, []); + + const isNearBottom = useCallback(() => { + const c = viewportRef.current; + if (!c?.containerRef?.current) return false; + const v = c.containerRef.current; + return v.scrollHeight - v.scrollTop - v.clientHeight <= 150; + }, [viewportRef]); + + const scrollToBottom = useCallback((smooth = false) => { + viewportRef.current?.scrollToBottom(smooth ? "smooth" : "auto"); + }, [viewportRef]); + + const syncScrollToBottomState = useCallback(() => { + const atBottom = isNearBottom(); + const hasBufferedNewer = hasMoreNewerRef.current || unreadBelowIdsRef.current.size > 0; + + if (atBottom && !hasMoreNewerRef.current) { + setShowScrollToBottom(false); + return true; + } + + setShowScrollToBottom(!atBottom || hasBufferedNewer || historyNavigationMode); + return atBottom; + }, [hasMoreNewerRef, historyNavigationMode, isNearBottom, unreadBelowIdsRef]); + + const openDateNavigator = useCallback((dateValue?: string | null) => { + setSelectedHistoryDate(dateValue || floatingDateValue || formatDateInputValue(new Date())); + setShowFloatingDate(true); + setIsDateNavigatorOpen(true); + }, [floatingDateValue]); + + /** Update floating date from scroll position – call inside onScroll handler */ + const updateFloatingDate = useCallback((viewport: HTMLElement) => { + const messageEls = viewport.querySelectorAll("[data-message-date]"); + const viewportTop = viewport.getBoundingClientRect().top; + let topDate: string | null = null; + let topDateVal: string | null = null; + + for (let i = 0; i < messageEls.length; i += 1) { + const el = messageEls[i]; + if (el.getBoundingClientRect().bottom > viewportTop + 8) { + const iso = el.getAttribute("data-message-date"); + if (iso) { + const d = new Date(iso); + if (!Number.isNaN(d.getTime())) { + topDate = formatDayDivider(d); + topDateVal = formatDateInputValue(d); + } + } + break; + } + } + + if (topDate) { + setFloatingDate(topDate); + setFloatingDateValue(topDateVal); + setShowFloatingDate(true); + + if (floatingDateTimeoutRef.current) clearTimeout(floatingDateTimeoutRef.current); + floatingDateTimeoutRef.current = setTimeout(() => setShowFloatingDate(false), 1100); + return; + } + + setFloatingDate(null); + setFloatingDateValue(null); + setShowFloatingDate(false); + }, []); + + // Expose updateFloatingDate as a stable ref so the scroll handler can call it + const updateFloatingDateRef = useRef(updateFloatingDate); + useEffect(() => { updateFloatingDateRef.current = updateFloatingDate; }, [updateFloatingDate]); + + // Attach scroll handler after the message list actually exists in the DOM. + // `ref.current` changes do not trigger effects, so we re-run when messageCount changes. + useEffect(() => { + const c = viewportRef.current; + if (!c?.containerRef?.current) return; + const viewport = c.containerRef.current; + + const onScroll = () => updateFloatingDateRef.current(viewport); + const frameId = window.requestAnimationFrame(onScroll); + + viewport.addEventListener("scroll", onScroll, { passive: true }); + return () => { + window.cancelAnimationFrame(frameId); + viewport.removeEventListener("scroll", onScroll); + }; + }, [chatId, messageCount, viewportRef]); + + return { + showScrollToBottom, + setShowScrollToBottom, + floatingDate, + floatingDateValue, + showFloatingDate, + isDateNavigatorOpen, + setIsDateNavigatorOpen, + selectedHistoryDate, + setSelectedHistoryDate, + isNearBottom, + scrollToBottom, + syncScrollToBottomState, + openDateNavigator, + }; +} diff --git a/frontend/src/hooks/useMarkRead.ts b/frontend/src/hooks/useMarkRead.ts new file mode 100644 index 00000000..bd2ff8cb --- /dev/null +++ b/frontend/src/hooks/useMarkRead.ts @@ -0,0 +1,124 @@ +import { useCallback, useEffect, useRef } from "react"; +import { apiClient } from "@/lib/api"; + +const MARK_READ_DEBOUNCE_MS = 300; + +interface UseMarkReadOptions { + chatId: number; + /** Callback to update the chat state after a successful read acknowledgment */ + onReadAcknowledged?: (confirmedId: number) => void; +} + +/** + * Debounced mark-as-read logic. + * + * Returns helpers to schedule mark-read events, query the last confirmed + * read position, and clean-up resources. + */ +export function useMarkRead({ chatId, onReadAcknowledged }: UseMarkReadOptions) { + const timerRef = useRef(null); + const inFlightRef = useRef(false); + const queuedIdRef = useRef(null); + const lastConfirmedRef = useRef(0); + const onReadAcknowledgedRef = useRef(onReadAcknowledged); + + useEffect(() => { + onReadAcknowledgedRef.current = onReadAcknowledged; + }, [onReadAcknowledged]); + + // Reset all state when chatId changes + useEffect(() => { + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + inFlightRef.current = false; + queuedIdRef.current = null; + lastConfirmedRef.current = 0; + }, [chatId]); + + // Cleanup on unmount + useEffect(() => { + return () => { + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + }; + }, []); + + const flush = useCallback(async () => { + if (!chatId || Number.isNaN(chatId) || inFlightRef.current) return; + + const targetId = queuedIdRef.current; + if (typeof targetId !== "number") return; + if (targetId <= lastConfirmedRef.current) { + queuedIdRef.current = null; + return; + } + + queuedIdRef.current = null; + inFlightRef.current = true; + + try { + await apiClient.markChatAsRead(chatId, targetId); + lastConfirmedRef.current = Math.max(lastConfirmedRef.current, targetId); + onReadAcknowledgedRef.current?.(targetId); + } catch (error) { + console.error("Ошибка отложенной отметки прочтения:", error); + // Re-queue the failed id so the next flush picks it up + queuedIdRef.current = Math.max(queuedIdRef.current ?? 0, targetId); + } finally { + inFlightRef.current = false; + + // If another id was queued while we were in-flight, schedule another flush + if ( + queuedIdRef.current !== null && + queuedIdRef.current > lastConfirmedRef.current && + !timerRef.current + ) { + timerRef.current = setTimeout(() => { + timerRef.current = null; + void flush(); + }, MARK_READ_DEBOUNCE_MS); + } + } + }, [chatId]); + + /** + * Schedule a mark-read for the given message id. + * Debounces calls so multiple rapid updates collapse into one. + */ + const scheduleMarkRead = useCallback( + (messageId?: number | null) => { + if (!chatId || typeof messageId !== "number") return; + + const nextTarget = Math.max(queuedIdRef.current ?? 0, messageId); + if (nextTarget <= lastConfirmedRef.current) return; + + queuedIdRef.current = nextTarget; + + if (inFlightRef.current) return; + + if (timerRef.current) { + clearTimeout(timerRef.current); + } + + timerRef.current = setTimeout(() => { + timerRef.current = null; + void flush(); + }, MARK_READ_DEBOUNCE_MS); + }, + [chatId, flush], + ); + + /** Sync the confirmed watermark from external sources (e.g. chat.last_read_message_id). */ + const syncConfirmed = useCallback((id: number) => { + lastConfirmedRef.current = Math.max(lastConfirmedRef.current, id); + }, []); + + /** Read-only accessor – useful for comparisons without triggering re-renders. */ + const getLastConfirmed = useCallback(() => lastConfirmedRef.current, []); + + return { scheduleMarkRead, syncConfirmed, getLastConfirmed } as const; +}