Фронтенд — окно диалога, группы, участники

Перед началом: список диалогов (Модуль 8). Инструменты: React, effector, WS. Трудоёмкость: ~7 ч.

🔥 Инцидент: «Прочитал — а бейдж остался»

Тестировщик открывает диалог с 12 непрочитанными, читает всё, бейдж в списке остаётся «12». Второй сценарий: бейдж погас, а сообщения в ленте остались неподсвеченными. Обе поломки — про одно: «прочитано» отправлялось по наитию (например, при монтировании окна), а не по честному правилу «сообщение показано пользователю». В этом модуле собираем окно диалога целиком: лента, статусы, receipts, typing, системные сообщения, панель участников — и одно честное определение «прочитано».

📖 Теория

9.1 Лента: догрузка вверх и якорь скролла

История грузится страницами по before_seq (Модуль 3) и приклеивается сверху. Классический баг «лента прыгает при подгрузке» лечится якорем: запоминаем scrollHeight до вставки и восстанавливаем позицию после:

ts
const before = el.scrollHeight
prependMessages(older)
requestAnimationFrame(() => {
  el.scrollTop = el.scrollHeight - before
})

Виртуализацию большого списка оставляем как упражнение (в ui-kit есть VirtualList — см. курс ui-kit).

9.2 Статусы собственных сообщений

Три состояния, каждое — из своего источника:

СтатусИсточник
sendingоптимистичная запись до ответа REST
sentответ 201 с серверным id/seq (или WS-эхо с нашим id)
readmessage.read от собеседника: min(last_read_seq других) ≥ seq сообщения

Оптимистичная запись дедуплицируется по client_msg_id → при приходе серверного id заменяется каноничной записью (не «добавляется рядом»). Ошибка отправки — статус error и кнопка «повторить» (тот же client_msg_id — безопасно, Модуль 3).

9.3 «Прочитано» — это правило, а не событие монтирования

Честное определение: сообщение прочитано, когда лента открыта, диалог активен, вкладка видима и скролл у нижней границы. Тогда — отправка read с текущим last_seq диалога. Условия проверяются:

  • при новых сообщениях (если видимо и внизу — сразу read);
  • при visibilitychange (вернулись во вкладку);
  • при доскролле к низу (debounce ~300 мс).

Разделитель «непрочитанные» в ленте: запоминаем last_read_seq на момент открытия диалога и рисуем линию перед первым непрочитанным — тот же WhatsApp/Telegram-паттерн.

9.4 Typing: дебаунс туда, таймер обратно

Клиент: событие typing не чаще, чем раз в 2 секунды на диалог (иначе зальём канал). Получатель: рисует «печатает…» и гасит по таймеру 3–4 секунды от последнего события — новые события продлевают таймер. Тишина — индикатор исчез. Не забываем исключать себя.

9.5 Системные сообщения и панель участников

Системные сообщения рендерятся из payload (Модуль 5): центрированная строка «Алиса добавила Боба», без аватара и пузыря. Своё имя — «Вы».

Панель участников (для групп): список с ролями, действия по той же матрице прав, что на бэкенде — кнопки не показываем там, где сервер ответит 403. Это дублирование правил — нормальная цена: UX не должен водить пользователя в отказ. Единый источник: константа can(action, role) из entities/conversation, захардкоженная зеркально бэкенду (в будущем — из контракта).

💻 Практика

  1. entities/message: добавьте статусы (sending | sent | read | error), привязку client_msg_id, селектор lastOwnReadTile (по receipts).

  2. widgets/chat-window v2:

    • лента с догрузкой вверх по before_seq и якорем скролла;
    • пузыри: свои/чужие, время, галочки статусов; системные — по центру;
    • разделитель «непрочитанные» по правилу §9.3;
    • шапка диалога: имя (peer/title), typing-индикатор, статус присутствия у direct, кнопка «Участники» у групп.
  3. features/mark-read: правило §9.3 целиком (активность + видимость + низ скролла; debounce). Проверьте инцидент дня: открыли диалог сверху (не у низа) — бейдж не должен сгорать до честного прочтения.

  4. features/typing-indicator: отправка (§9.4) и отображение.

  5. widgets/member-panel: список участников, роли, добавление/удаление, передача владения, приглашение по ссылке (копирование), мьют диалога. Кнопки — по матрице §5.1.

  6. Сквозной сценарий на двух браузерных профилях (главный тест курса):

    • Алиса пишет Бобу → у Боба бейдж «1», у Алисы галочка sent;
    • Боб открывает диалог → галочка read; свайп не нужен — правило честное;
    • Боб печатает → Алиса видит индикатор, через 4 секунды он гаснет;
    • группа: создаём втроём, Боб добавляет Кэрол → системное сообщение у всех; Боб выгнан → его окно переходит в состояние «доступ закрыт».

Чек-лист модуля: лента не прыгает при подгрузке; статусы честные; read отправляется по правилу; typing живёт и гаснет; системные сообщения локализованы клиентом; действия в панели совпадают с правами сервера.

🧩 Quiz

1. Что произойдёт с оптимистичным сообщением, когда придёт серверное с client_msg_id?
2. Индикатор «печатает» не гаснет две минуты. Что забыли?
3. Зачем якорь скролла при подгрузке истории вверх?
4. Кнопка «Удалить участника» показывается member'у. Что не так?
5. Почему «прочитано» нельзя отправлять при простом монтировании окна?
6. Боб выгнан из группы с открытым окном. Что показываем?

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

  • Лента: страницы по seq, якорь скролла, разделитель непрочитанных.
  • Статусы сообщений собраны из честных источников: REST-ответ, receipts по last_read_seq, локальная ошибка с повтором.
  • «Прочитано» — правило (активен + видим + низ), а не факт открытия.
  • Typing: дебаунс на отправке, таймер на приёме.
  • Панель участников повторяет матрицу прав бэкенда; выгнанный пользователь получает явное состояние.

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

Всё живёт, пока вкладка открыта. Закрыл ноутбук, ушёл из дома — и не узнаешь о сообщении, пока не вернёшься. Следующий модуль — уведомления: Web Push для офлайн-получателей, правила «кого будить» (mute, mentions) и то, почему эти правила обязан применять сервер, а не клиент.