Отчёт: Рефакторинг чатов 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 Узкие места (остаются)
get_last_message в retrieve — для единичного getChat() prefetch не помогает (1 запрос). Допустимо.
PollViewSet.vote — option.votes.count() в цикле (N+1). Нужен annotate(Count('votes')).
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 лучше аналогов
- Система ролей в чатах (admin/moderator/member/guest) — нет у Telegram/WhatsApp в таком виде.
- Интеграция с оргструктурой — чаты отделов, привязка к LDAP.
- Контекстные чаты (комментарии к постам/документам) — нет у классических мессенджеров.
- Управление доступом — гости без права отправки, модерация контента.
4. Рекомендации по дальнейшим улучшениям
Высокий приоритет
- Виртуализация списка сообщений — при >500 сообщений DOM замедляется. Использовать
@tanstack/react-virtual или react-window.
- WebSocket обновление списка чатов —
wsManager уже существует; нужен подписчик на new_message для обновления last_message и unread_count в списке без перезагрузки.
- Annotate poll vote counts —
PollViewSet.vote имеет N+1 на option.votes.count(). Заменить на annotate(Count('votes')).
Средний приоритет
- State management — рассмотреть Zustand или TanStack Query для кеширования чатов/сообщений между страницами.
- Bulk create memberships —
ChatViewSet.create делает N запросов при добавлении участников.
- Поиск по сообщениям — PostgreSQL full-text search (
SearchVector, SearchQuery).
Низкий приоритет
- Удалить дублирование WebSocket логики (
useWebSocket vs wsManager).
- Typed API client вместо
any в createMessagesApi.