Refactor chat frontend and optimize chat queries
This commit is contained in:
parent
9d3d8bdd57
commit
e80412918d
@ -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(
|
||||
|
||||
@ -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()
|
||||
|
||||
# Исключаем чаты-комментарии из общего списка
|
||||
|
||||
150
docs/reports/CHAT_REFACTORING_ANALYSIS.md
Normal file
150
docs/reports/CHAT_REFACTORING_ANALYSIS.md
Normal file
@ -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`.
|
||||
File diff suppressed because it is too large
Load Diff
576
frontend/src/hooks/useChatMessages.ts
Normal file
576
frontend/src/hooks/useChatMessages.ts
Normal file
@ -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<React.SetStateAction<Chat | null>>;
|
||||
messages: Message[];
|
||||
setMessages: React.Dispatch<React.SetStateAction<Message[]>>;
|
||||
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<React.SetStateAction<boolean>>;
|
||||
/** Number of new (unread) messages that appeared below the viewport */
|
||||
newMessagesBelowCount: number;
|
||||
/** Ref containing unread-below message ids */
|
||||
unreadBelowIdsRef: React.MutableRefObject<Set<number>>;
|
||||
/** hasMoreNewer kept in a ref for synchronous access */
|
||||
hasMoreNewerRef: React.MutableRefObject<boolean>;
|
||||
/** Load older (prepend) */
|
||||
loadOlderMessages: () => Promise<void>;
|
||||
/** Load newer (append) */
|
||||
loadNewerMessages: (opts?: { markRead?: boolean }) => Promise<void>;
|
||||
/** Background "fallback sync" of latest messages */
|
||||
syncLatestMessages: (opts?: { limit?: number; scrollOnSync?: boolean }) => Promise<void>;
|
||||
/** 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<void>;
|
||||
/** 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<React.SetStateAction<number>>;
|
||||
/** 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<Chat | null>(null);
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [pendingMessages, setPendingMessages] = useState<Message[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [messagesLoading, setMessagesLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
/* ── 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<number | null>(null);
|
||||
const [initialAnchorIndex, setInitialAnchorIndex] = useState<number | null>(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<Set<number>>(new Set());
|
||||
|
||||
/* ── refs ── */
|
||||
const hasMoreNewerRef = useRef(false);
|
||||
const initialScrolledRef = useRef(false);
|
||||
const pendingMessagesRef = useRef<Message[]>([]);
|
||||
const localSeqRef = useRef(0);
|
||||
const pendingTimersRef = useRef<Map<string, NodeJS.Timeout>>(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,
|
||||
};
|
||||
}
|
||||
194
frontend/src/hooks/useChatScroll.ts
Normal file
194
frontend/src/hooks/useChatScroll.ts
Normal file
@ -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<ScrollableMessageListInner | null>;
|
||||
/** Whether there are more newer messages to load */
|
||||
hasMoreNewerRef: React.MutableRefObject<boolean>;
|
||||
/** Ref to unread-below message ids */
|
||||
unreadBelowIdsRef: React.MutableRefObject<Set<number>>;
|
||||
/** 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<React.SetStateAction<boolean>>;
|
||||
/** Floating date display */
|
||||
floatingDate: string | null;
|
||||
floatingDateValue: string | null;
|
||||
showFloatingDate: boolean;
|
||||
/** Date navigator state */
|
||||
isDateNavigatorOpen: boolean;
|
||||
setIsDateNavigatorOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
selectedHistoryDate: string;
|
||||
setSelectedHistoryDate: React.Dispatch<React.SetStateAction<string>>;
|
||||
/** 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<string | null>(null);
|
||||
const [floatingDateValue, setFloatingDateValue] = useState<string | null>(null);
|
||||
const [showFloatingDate, setShowFloatingDate] = useState(false);
|
||||
const [isDateNavigatorOpen, setIsDateNavigatorOpen] = useState(false);
|
||||
const [selectedHistoryDate, setSelectedHistoryDate] = useState("");
|
||||
|
||||
const floatingDateTimeoutRef = useRef<NodeJS.Timeout | null>(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<HTMLElement>("[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,
|
||||
};
|
||||
}
|
||||
124
frontend/src/hooks/useMarkRead.ts
Normal file
124
frontend/src/hooks/useMarkRead.ts
Normal file
@ -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<NodeJS.Timeout | null>(null);
|
||||
const inFlightRef = useRef(false);
|
||||
const queuedIdRef = useRef<number | null>(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;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user