Фронтенд — список диалогов

Перед началом: бэкенд-ядро (Модули 1–7). Инструменты: React, TypeScript, effector, FSD. Трудоёмкость: ~6 ч.

🔥 Инцидент: «Бейджи удвоились после реконнекта»

Студент показывает баг: «Открываю ноутбук после обеда — у диалога с Бобом бейдж "8", хотя сообщений три». Разгадка в двух строках кода: непрочитанные считались инкрементом при каждом WS-событии — а после реконнекта sync доиграл те же события заново. Инкремент поверх уже загруженной истории — почти классика. В этом модуле строим список диалогов так, чтобы источником правды были серверные числа, а WS только двигал их с известной точки.

📖 Теория

8.1 Типы из контракта, а не руками

В бэкенде живёт api/openapi.yaml — единственный источник правды. Генерируем:

bash
npx openapi-typescript ../api/openapi.yaml -o src/shared/api/schema.d.ts

Дальше shared/api экспортирует алиасы (ConversationListItem, Message, UserCard) из сгенерированных типов. Дрейф типов исчезает как класс: изменили путь в спеке — фронт не собрался, а не «почему-то 404 в рантайме».

8.2 Слайсы FSD под список

Раскладка под наш экран:

entities/conversation   — модель списка: $conversations, события WS, селекторы
entities/session        — текущий пользователь, токен
features/start-dialog   — «написать» из контактов (создание direct)
features/create-group   — мастер группы (название + участники)
widgets/conversation-list — рендер списка с бейджами и статусами
pages/chats             — master-detail: список + область диалога

Правило импортов не меняется: pages → widgets → features → entities → shared, только вниз.

8.3 Единственный источник правды для непрочитанных — сервер

Модель состояния диалогов в entities/conversation:

  • при загрузке/синке: $conversations перезаписывается серверными значениями (unread уже посчитан бэкендом как last_seq - last_read_seq);
  • live-событие message.new для диалога не в фокусе → unread + 1 (это инкремент поверх серверной базы, а не поверх «своей» истории);
  • событие message.read от собеседника → unread не трогаем (это про наши отправленные), но запоминаем last_read_seq для галочек;
  • после sync.complete никаких повторных инкрементов: догон применяется к истории сообщений, а список просто перечитывается с сервера.

Простейший надёжный приём: события живут после sync.complete; всё, что пришло из догона — уже учтено серверным состоянием списка.

8.4 Сортировка и превью

Список сортируется по last_at (серверное поле), сообщения «из будущего» невозможны. Превью последнего сообщения для системных — рендерим текстом по payload (та же логика, что в ленте, переиспользуем функцию из entities/message). Активный диалог подсвечивается по URL — источник активности один: роутер.

💻 Практика

  1. Сгенерируйте типы из спецификации и подключите в shared/api (скрипт npm run gen:api в package.json).

  2. entities/conversation/model.ts:

ts
// события
conversationsLoaded   // ответ REST /conversations
messageNew            // WS: обновить превью, сорт, unread если не активен
readStateChanged      // WS message.read: запомнить last_read_seq собеседника
presenceChanged       // WS presence: статус собеседника в списке

// сторы
$conversations        // список с серверными полями
$activeId             // активный диалог (из роутера)
  1. widgets/conversation-list: аватар, имя (для direct — peer, для группы — title), превью последнего, время (HH:MM сегодня, иначе дата), бейдж непрочитанных, точка присутствия у direct.

  2. features/start-dialog: из GET /contacts — поиск и кнопка «Написать»; после POST /conversations {type: direct} — навигация в диалог.

  3. features/create-group: мини-мастер — название, мультивыбор из контактов, создание; после создания — в новый диалог.

  4. Связка с sync из Модуля 6: при коннекте грузим список (GET /conversations), отправляем sync, и только после sync.complete помечаем поток «живым» (флаг $live). Проверьте инцидент дня: тот же сценарий реконнекта — бейджи больше не удваиваются.

Чек-лист модуля: типы генерируются из спеки; список показывает непрочитанные/превью/присутствие; старт диалога и создание группы работают; после реконнекта бейджи не «плывут».

🧩 Quiz

1. Откуда берётся превью последнего сообщения для системных сообщений?
2. Типы API генерируются из openapi.yaml. Что это чинит как класс ошибок?
3. Что делает sync.complete в модели списка?
4. Где в FSD живёт «создать группу»?
5. Почему непрочитанные не считаются на клиенте сложением своих событий?

✅ Итоги модуля

  • Контракт → типы → код: openapi-typescript убирает дрейф типов.
  • Список диалогов — селекторы поверх серверного состояния плюс живые события после sync.complete.
  • FSD-раскладка: entities (данные), features (сценарии), widgets (композиция), pages (маршрутизация).
  • Присутствие и непрочитанные стали видимыми — список ожил.

🔗 Мостик к Модулю 9

Список приводит в диалог — теперь делаем сам диалог: ленту с догрузкой истории вверх, статусы собственных сообщений (отправляется → отправлено → прочитано), индикатор набора, системные сообщения и панель участников группы с действиями по матрице прав.