Refactor chat frontend and optimize chat queries

This commit is contained in:
lizerk 2026-04-03 17:05:14 +03:00
parent 9d3d8bdd57
commit e80412918d
7 changed files with 1496 additions and 1527 deletions

View File

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

View File

@ -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()
# Исключаем чаты-комментарии из общего списка

View 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

View 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,
};
}

View 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,
};
}

View 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;
}