| Сводка | Модальное окно предпросмотра тем – открывайте и взаимодействуйте с темами, не покидая список тем | |
| Предпросмотр | Theme Creator | |
| Репозиторий | GitHub - VaperinaDEV/discourse-topic-preview-modal: Open a topic directly from the topic list in a native Discourse modal, read and interact with the topic, and then continue browsing the list without navigating away from it. · GitHub | |
| Пригодилось? | > ./support --coffee | |
| Гид по установке | Как установить тему или компонент темы | |
| Новичок в темах Discourse? | Гид для начинающих по использованию тем Discourse |
Установить этот компонент темы
Topic Preview Modal – открывайте и взаимодействуйте с темами, не покидая список тем
Я создал новый компонент темы Discourse под названием Topic Preview Modal.
Идея довольно проста:
Открывайте тему напрямую из списка тем в нативном модальном окне Discourse, читайте и взаимодействуйте с темой, а затем продолжайте просмотр списка, не покидая его.
Все началось с Facebook-style Topic Modal - Is it better? , но в итоге потребовалась значительная интеграция с системами тем, потока сообщений, композера, модальных окон, закладок, маршрутизации, присутствия, отслеживания прочтения и предварительной загрузки в Discourse.
Зачем?
Обычный поток работы в Discourse выглядит так:
- Вы просматриваете список тем.
- Вы кликаете по теме.
- Discourse переходит по адресу
/t/.... - Вы читаете/отвечаете/взаимодействуете с темой.
- Вы возвращаетесь в список тем.
Для многих сценариев этого вполне достаточно.
Однако, просматривая загруженный список тем, иногда я хочу быстро просмотреть тему, прочитать несколько сообщений, проверить последние ответы, отреагировать на что-то или ответить на быстрый вопрос.
В таком случае покидать список тем кажется неоправданно затратным.
Целью этого компонента было сделать так, чтобы список тем работал больше как входящие:
список тем → предпросмотр → взаимодействие → закрытие → продолжение ровно с того места, где вы остановились.
Что он делает
Предпросмотр – это не просто статичный фрагмент.
Он рендерит фактические компоненты сообщений Discourse внутри нативного DModal.
Это означает, что пользователи могут:
- читать сообщения
- прокручивать тему
- загружать более ранние сообщения
- загружать новые сообщения ниже
- реагировать на сообщения
- добавлять сообщения в закладки
- цитировать текст
- отвечать на тему
- отвечать на отдельные сообщения
- редактировать сообщения, если это разрешено
- удалять/восстанавливать сообщения, если это разрешено
- жаловаться на сообщения
- просматривать историю сообщений
- выполнять различные стандартные действия с сообщениями
- видеть присутствие в теме
- переходить по ссылкам на другие сообщения в рамках той же темы
- переходить непосредственно к нужному сообщению
- открывать полную тему, если это необходимо
Намерение состоит в том, чтобы предпросмотр ощущался как можно ближе к реальному открытию темы.
Два режима запуска
Есть два способа открыть предпросмотр.
1. Вся строка списка тем
Это значение по умолчанию.
Вся строка списка тем становится кликабельной, при этом общие интерактивные элементы, такие как:
- карточки пользователей
- участники
- ссылки на категории
- теги
- ссылки на статус темы
- массовый выбор
исключаются из триггера модального окна.
Это делает опыт очень быстрым при просмотре списка тем.
2. Явная кнопка развертывания
В качестве альтернативы, компонент может рендерить маленькую иконку развертывания через плагинный отсек (plugin outlet) Discourse. Кастомные темы могут просто создать новый <PluginOutlet />, чтобы показать триггер.
В этом режиме стандартное поведение списка тем остается полностью нетронутым.
Пользователь кликает по иконке развертывания, чтобы открыть предпросмотр, в то время как клик по заголовку темы по-прежнему выполняет стандартную навигацию Discourse.
Это полезно, если сайт хочет сохранить стандартную модель взаимодействия со списком тем.
Настройка:
trigger_style:
row
или:
trigger_style:
button
При использовании режима кнопки отсек (outlet) также настраивается.
Предпросмотр начинается с позиции непрочитанного пользователя
Одна из важных деталей заключается в том, что модальное окно не просто загружает первое сообщение.
Если тема уже была частично прочитана, предпросмотр вычисляет:
last_read_post_number + 1
и открывается вокруг этого сообщения.
Таким образом, если в теме 200 сообщений, а пользователь прочитал до #165, открытие предпросмотра начнется примерно с #166.
Это делает предпросмотр гораздо более полезным для реального просмотра.
Это также означает, что компонент должен иметь дело с обеими сторонами потока сообщений:
- загрузка более ранних сообщений при необходимости
- загрузка новых сообщений ниже
Кнопка Ранние сообщения отображается, если есть сообщения выше текущего загруженного диапазона, в то время как сентинел IntersectionObserver автоматически загружает новые сообщения, когда пользователь достигает конца.
Предварительная загрузка (Prefetching)
Одна из самых больших частей компонента – это система предварительной загрузки.
Проблема с модальным окном такого типа в том, что пользователь ожидает мгновенного отклика.
Если мы начнем загрузку темы только после клика пользователя, модальное окно все равно может потратить заметное время на ожидание сети.
Вместо этого компонент может проактивно предварительно загружать темы, пока пользователь просматривает список.
Когда строка темы приближается к области просмотра, IntersectionObserver может запланировать предварительную загрузку.
Существует несколько механизмов защиты, чтобы это не превратилось в неконтролируемый фоновый трафик.
Дебаунсинг (Debouncing)
Тема не запускает запрос немедленно просто потому, что она на мгновение появилась в области просмотра.
Компонент ждет в течение настроенного периода дебаунсинга.
По умолчанию:
400 мс
Это особенно полезно при быстром прокручивании длинного списка тем.
Поле корня (Root margin)
Предварительная загрузка может начаться немного раньше, чем тема фактически войдет в область просмотра.
По умолчанию:
50 px
Это дает запросу небольшое преимущество в старте.
Ограничение одновременных запросов
Количество одновременных предварительных загрузок ограничено.
По умолчанию:
2
Настройка позволяет от 1 до 6 одновременных предварительных загрузок.
Бюджет на минуту
Также существует второй механизм защиты:
max_prefetches_per_minute
По умолчанию:
15
Таким образом, даже если пользователь продолжает прокручивать сотни тем, компонент не будет непрерывно генерировать спекулятивные запросы.
0 отключает лимит.
Предварительная загрузка может быть полностью отключена
Если сайт не хочет никакого спекулятивного сетевого трафика:
enable_prefetch = false
Компонент продолжает работать нормально. Темы просто загружаются, когда открывается предпросмотр.
Данные предварительной загрузки хранятся отдельно от обычной навигации по темам
Здесь есть важная деталь реализации.
Ответ предварительной загрузки не записывается немедленно в обычный ключ предзагрузки topic_<id> Discourse.
Вместо этого компонент использует собственное пространство имен:
topic-preview-modal:prefetch:<topicId>
Только когда пользователь фактически открывает предпросмотр, промис предварительной загрузки повышается до основного ключа предзагрузки темы.
Это сделано намеренно.
Предпросмотр может загружать тему, начиная с last_read_post_number + 1, и я не хочу, чтобы этот специфичный для предпросмотра ответ «протекал» в обычную навигацию по маршруту темы.
Таким образом, жизненный цикл выглядит примерно так:
тема входит в область просмотра
↓
предварительная загрузка
↓
приватное хранилище предзагрузки
↓
пользователь открывает предпросмотр
↓
повышение предзагрузки
↓
Topic.find()/PostStream используют тот же промис
Это также означает, что модальное окно не должно ждать завершения запроса предварительной загрузки перед открытием.
Модальное окно может открыться немедленно со своим скелетным интерфейсом, пока тот же промис продолжает разрешаться.
Поддержка мобильных устройств
На самом деле, это была одна из причин, по которой я потратил значительно больше времени на реализацию.
Первоначальная идея работала приемлемо на десктопе, но мобильные устройства выявили несколько проблем, связанных с:
- сенсорным взаимодействием
- прокруткой модального окна
- фокусом
- вложенными меню
- композером
- видимостью сообщений
- загрузкой изображений
- производительностью
Поэтому в итоговой реализации модальное окно не рассматривается как полностью отдельный миниатюрный форум.
Вместо этого оно переиспользует как можно больше существующей инфраструктуры Discourse.
Реальные компоненты сообщений Discourse
Модальное окно не пересоздает сообщения, используя упрощенный кастомный шаблон.
Оно рендерит фактические компоненты:
Post
PostSmallAction
Discourse.
Это важно, потому что иначе предпросмотр быстро превратился бы во вторую реализацию пользовательского интерфейса сообщений.
Компонент передает соответствующие действия в обычные компоненты сообщений, включая такие вещи, как:
- ответ
- редактирование
- удаление
- восстановление
- жалоба
- история
- закладка
- вики
- блокировка/разблокировка
- тип сообщения
- изменения владения
- значки
- скрытые сообщения
- цитирование
- и т.д.
В результате предпросмотр может вести себя гораздо больше как обычная тема, чем как традиционный компонент «предпросмотра».
Ответы и композер
Композер – одна из более сложных частей.
Предпросмотр может открыть обычный композер Discourse для:
Ответа на тему
Композер темы открывается с моделью темы и правильной информацией о черновике.
Ответа на конкретное сообщение
Сообщение передается в композер, чтобы ответ вел себя как обычный ответ на сообщение.
Цитирования выделенного текста
Компонент также интегрируется с PostTextSelection.
Это означает, что пользователи могут выделять текст внутри предпросмотра и использовать стандартный поток цитирования/ответа Discourse.
Вложенные модальные окна
Еще одной сложной частью была система модальных окон Discourse.
Сообщения могут открывать другие модальные окна и диалоги:
- жалобы
- история
- диалоги, связанные со значками
- изменения владения
- подтверждения удаления
- и т.д.
Если бы им позволили взаимодействовать с глобальным сервисом модальных окон как обычно, открытие одного из них могло бы закрыть весь предпросмотр темы.
Чтобы избежать этого, компонент создает локальный механизм под-модальных окон.
Концептуально:
Модальное окно предпросмотра темы
│
├── Модальное окно жалобы
├── Модальное окно истории
├── Подтверждение удаления
├── Модальное окно значка
└── другое модальное окно, связанное с сообщением
Предпросмотр остается смонтированным под ним.
Компонент временно патчит соответствующие методы сервиса модальных окон, пока он активен, и восстанавливает их при уничтожении.
Маршрутизация внутри модального окна
Еще одна важная деталь – ссылки на сообщения в рамках той же темы.
Например, если сообщение содержит ссылку на:
/t/my-topic/123
предпросмотру не нужно закрываться и уходить.
Вместо этого компонент перехватывает навигацию в рамках той же темы и переходит к запрошенному сообщению внутри модального окна.
То же самое относится к ссылкам, указывающим на тему без конкретного номера сообщения.
Это удерживает пользователя внутри предпросмотра.
Если ссылка указывает на действительно другую тему, компонент сначала восстанавливает свои временные патчи сервисов и закрывается, прежде чем разрешить стандартный переход маршрута Discourse.
Эта очистка важна, потому что иначе подписки предпросмотра и трекер времени могли бы остаться активными, пока инициализируется реальный маршрут темы.
Отслеживание прочтения и отслеживание времени
Я также хотел, чтобы предпросмотр правильно работал с точки зрения Discourse.
Открытие предпросмотра не должно означать, что отслеживание прочтения полностью обходится.
Поэтому компонент обрабатывает:
- отслеживание посещений темы
- отслеживание видимых сообщений
- тайминг темы
- обновления последнего прочитанного сообщения
Трекер времени использует IntersectionObserver, чтобы определить, какие сообщения фактически видны.
Каждые 5 секунд тайминг видимых сообщений сбрасывается на:
/topics/timings
Когда модальное окно закрывается, выполняется одна последняя сброска, чтобы последние несколько секунд не были потеряны.
Реализация также ограничивает один интервал тайминга 60 секундами.
Синхронизация состояния непрочитанного в списке тем
Здесь была еще одна тонкая проблема.
Обновления состояния отслеживания тем в Discourse недостаточно для обновления значка непрочитанных, отображаемого непосредственно на строке списка тем.
Поэтому компонент обновляет фактический объект темы, связанный со строкой, после того, как информация о тайминге была сброшена.
Он обновляет значения, такие как:
last_read_post_number
unread_posts
unread
new_posts
при необходимости.
Это означает, что после прочтения темы внутри модального окна список тем может немедленно отразить новое состояние прочтения, не требуя полной перезагрузки страницы.
Видимость сообщений
Предпросмотр использует общий IntersectionObserver, чтобы определить, когда отдельные сообщения становятся видимыми.
Также есть синхронная проверка видимости при подключении наблюдателя.
Это обрабатывает крайний случай, когда сообщение уже видно при его монтировании, но асинхронный первый обратный вызов IntersectionObserver еще не сработал.
Это особенно актуально для очень коротких тем, где вся тема может уже быть видна, когда модальное окно открывается.
Соображения производительности
Одной из основных целей было избежать превращения модального окна в производительностно-тяжелую миниатюрную страницу темы.
Для этого делается несколько вещей.
Прогрессивный рендеринг
Первоначальная загрузка не рендерит каждое сообщение немедленно.
Компонент сначала рендерит достаточно сообщений, чтобы достичь целевой позиции.
Остальные сообщения затем рендерятся прогрессивно с использованием:
requestIdleCallback
когда доступно, с фолбэком на setTimeout.
Это особенно полезно при открытии длинной темы вокруг сообщения, далеко внизу потока.
Содержимое CSS (CSS Containment)
Сообщения используют:
contain: layout;
content-visibility: auto;
contain-intrinsic-size: 1px 180px;
Это позволяет браузеру избегать ненужной работы по рендерингу для сообщений, которые в данный момент не видны.
Ленивая загрузка изображений
Изображениям, которые еще не указали режим загрузки, автоматически присваиваются:
loading="lazy"
decoding="async"
Это предотвращает немедленную загрузку всего содержимого в длинной теме с множеством изображений.
Состояние загрузки
Модальное окно не просто показывает пустую белую/пустую область, пока выполняется запрос.
У него есть скелетный интерфейс с:
- плейсхолдерами аватаров
- плейсхолдерами имен пользователей
- плейсхолдерами тела сообщений
- анимацией мерцания (shimmer)
Мерцание уважает:
prefers-reduced-motion
так что анимация отключается для пользователей, запросивших уменьшенное движение.
Стабилизация позиции прокрутки
Есть несколько мест, где компоненту нужно вручную манипулировать позицией прокрутки.
Например, при загрузке более ранних сообщений newly inserted content увеличивает высоту прокрутки.
Просто добавление сообщений в начало заставило бы текущую позицию пользователя подпрыгнуть.
Поэтому компонент записывает предыдущую высоту прокрутки и компенсирует разницу после вставки сообщений.
Это удерживает текущее видимое содержимое примерно в том же месте.
То же самое относится к переходу к определенному сообщению.
Компонент выполняет этап позиционирования после рендеринга и повторно проверяет позицию на последующих кадрах, чтобы учесть содержимое, которое может еще устаканиваться.
Присутствие в теме
Когда соответствующие данные темы доступны, предпросмотр также может отображать информацию о присутствии в теме Discourse в нижней части модального окна.
Так пользователи могут видеть, кто еще в данный момент просматривает тему, не выходя из предпросмотра.
Взаимодействие с мобильными меню и фокусом
Мобильные устройства привнесли еще одну категорию проблем.
Некоторые элементы пользовательского интерфейса Discourse используют общие сервисы модальных окон/меню, и эти сервисы не обязательно знают, что предпросмотр темы в данный момент действует как вложенный контекст просмотра.
Поэтому у компонента есть дополнительная обработка вокруг:
modal.close()- меню Float Kit
- восстановления фокуса
- композера
- клавиатурного управления лайтбоксом
- блокировки прокрутки body
Например, если меню внутренне пытается вызвать глобальный метод закрытия модального окна, это не должно случайно закрыть весь предпросмотр темы.
Аналогично, когда композер открыт, фокус должен оставаться внутри композера, а не быть возвращен в контекст фокуса предпросмотра.
Конфигурация
В настоящее время компонент предоставляет следующие настройки:
| Настройка | По умолчанию | Описание |
|---|---|---|
trigger_style |
row |
Сделать всю строку кликабельной или использовать явную кнопку |
plugin_outlet |
topic-list-after-title |
Отсек (outlet), используемый триггером кнопки |
enable_prefetch |
true |
Включить/отключить фоновую предварительную загрузку тем |
max_concurrent_prefetches |
2 |
Максимальное количество одновременных запросов предварительной загрузки |
prefetch_debounce_ms |
400 |
Задержка перед началом предварительной загрузки |
prefetch_root_margin_px |
50 |
Начать предварительную загрузку за такое количество пикселей до входа строки в область просмотра |
max_prefetches_per_minute |
15 |
Максимальное количество спекулятивных запросов в минуту |
Управление предварительной загрузкой намеренно настраивается, потому что у разных сообществ могут быть очень разные паттерны трафика и характеристики хостинга/сети.
Одна из основных целей дизайна: не ломать обычный Discourse
Я пытался сохранить компонент как можно ближе к существующей архитектуре Discourse.
Он не реализует собственный рендерер сообщений, собственный композер, собственную модель темы или свой полностью отдельный поток сообщений.
Вместо этого он создает временный контекст просмотра вокруг существующих компонентов и сервисов Discourse.
Именно поэтому некоторые части реализации более сложны, чем могут показаться на первый взгляд.
Более интересной задачей было:
Может ли тема вести себя почти как обычная тема Discourse, пока она фактически отображается внутри другого пользовательского интерфейса?
Для этого потребовалось иметь дело с границами между глобальными сервисами Discourse и локальным предпросмотром.





