Привет ![]()
Подход отличается от подхода @Damian_Boon: без iframe, всё работает как нативные компоненты Glimmer, повторно использующие собственный механизм Post/post-stream Discourse. Поскольку в теме возникло несколько конкретных вопросов, вот как я в итоге решил каждый из них.
Почему не iframe
Iframe — это, безусловно, самый быстрый способ достичь цели: полный маршрут темы, редактор постов, модерация — всё это бесплатно. Цена, на которую указывает вопрос @nicolsdennis о MessageBus, заключается в том, что у вас на странице оказываются две живые экземпляры Discourse: два post-stream, два подключения MessageBus, два редактора постов.
Я пошёл другим путём. Модальное окно — это DModal, рендерящий настоящие компоненты Post/PostSmallAction против postStream, созданного через store.createRecord("topic", …). То же самое приложение, то же самое подключение MessageBus, тот же самый редактор постов. Это означает, что нескольким основным синглетон-сервисам (modal, bookmarkApi, DiscourseURL.routeTo, контекстный controller:topic) нужно указывать на тему модального окна на протяжении всего времени его открытия:
// Основные компоненты (например, PostBookmarkManager) читают/записывают topic.bookmarks
// из controller:topic. Укажем на нашу topicModel, пока модальное окно открыто.
this.topicController = getOwner(this).lookup("controller:topic");
if (this.topicController) {
this.originalTopicControllerModel = this.topicController.model;
}
Каждое изменение отменяется идемпотентным вызовом restoreServicePatches() при закрытии, уничтожении или переходе по навигации, поэтому после закрытия модального окна ничего не утекает в остальную часть приложения.
Прямые ссылки и внутренняя маршрутизация
Две части. Во-первых, модальное окно должно открываться там, где пользователь действительно остановился, а не всегда на первом посте:
const lastRead = this.topic.last_read_post_number ?? 0;
const highestPostNumber = this.topic.highest_post_number ?? 1;
const initialPostNumber = Math.max(
1,
Math.min(lastRead + 1, highestPostNumber)
);
Во-вторых, ссылки внутри постов, ведущие обратно в ту же тему, не должны выводить пользователя за пределы модального окна. DiscourseURL.routeTo переопределяется на время его работы:
this.originalRouteTo = DiscourseURL.routeTo.bind(DiscourseURL);
DiscourseURL.routeTo = (path, opts) => {
if (typeof path === "string") {
const match = path.match(/^\/t\/(?:[^/]+\/)?(\d+)(?:\/(\d+))?/);
if (match && parseInt(match[1], 10) === this.topicId) {
this.jumpToPost(match[2] ? parseInt(match[2], 10) : 1);
return Promise.resolve();
}
}
this.restoreServicePatches();
return this.originalRouteTo(path, opts);
};
Любые ссылки, ведущие в другое место, сначала восстанавливают изменения и передают управление обычной навигации, поэтому выход из модального окна никогда не оставляет после себя устаревшую маршрутизацию.
Случай с ответами (20+ ответов)
Бесконечная прокрутка вниз через sentinel + IntersectionObserver, симметричная функция «загрузить предыдущие» сверху (превью может открываться в середине потока), обе используют те же самые методы postStream.appendMore() / prependMore(), что и настоящий маршрут темы:
sentinel = modifier((element) => {
const obs = new IntersectionObserver(
(entries) => {
if (entries.some((e) => e.isIntersecting)) {
this.loadBelow();
}
},
{
root: document.querySelector(".topic-preview-modal .d-modal__body"),
rootMargin: "200px",
}
);
obs.observe(element);
return () => obs.disconnect();
});
Первый рендеринг
Скелет (заглушки для аватара и строк, эффект мерцания) заполняет первый рендеринг вместо спиннера, а сам список постов рендерится поэтапно, а не все сразу: сначала достаточно постов, чтобы достичь целевого поста, затем ещё по пять через requestIdleCallback, пока не догонит остальные:
const renderRemainingPosts = () => {
const totalPosts = this.postStream?.posts?.length || 0;
if (this.renderLimit < totalPosts) {
this.renderLimit = Math.min(this.renderLimit + 5, totalPosts);
if (this.renderLimit < totalPosts) {
if (window.requestIdleCallback) {
window.requestIdleCallback(renderRemainingPosts, { timeout: 300 });
} else {
setTimeout(renderRemainingPosts, 30);
}
return;
}
}
this.renderLimit = Number.MAX_SAFE_INTEGER;
};
Каждый обёрточный элемент поста также получает content-visibility: auto с приблизительным contain-intrinsic-size, чтобы браузер полностью пропускал вычисление макета для постов, находящихся за пределами экрана:
.topic-post {
contain: layout;
content-visibility: auto;
contain-intrinsic-size: 1px 180px;
}
Это та часть, на которой я бы настоял сильнее всего. Вместо повторного использования экземпляра после клика, я выполняю предзагрузку до клика. Каждая строка списка тем отслеживает свою видимость, использует debounce 200 мс, чтобы быстрая прокрутка не запускала десяток запросов, и передаёт задачу в небольшую общую очередь, ограниченную 2 одновременными запросами:
function createTopicPrefetchQueue(maxConcurrent) {
const pending = [];
const activeIds = new Set();
function drain() {
while (activeIds.size < maxConcurrent && pending.length) {
const { id, task } = pending.shift();
activeIds.add(id);
Promise.resolve()
.then(task)
.finally(() => {
activeIds.delete(id);
drain();
});
}
}
return {
schedule(id, task) {
if (activeIds.has(id) || pending.some((item) => item.id === id)) {
return;
}
pending.push({ id, task });
drain();
},
cancel(id) {
const index = pending.findIndex((item) => item.id === id);
if (index !== -1) {
pending.splice(index, 1);
}
},
};
}
Результат предзагрузки сохраняется под приватным ключом PreloadStore, намеренно не под основным ключом topic_${id}, так как запись туда привела бы к утечке частичного JSON, ограниченного last_read+1, в реальную навигацию по полной теме. При фактическом клике он перемещается в ключ, который ожидает загрузчик модального окна:
promoteTopicPrefetch(topic) {
if (!topic?.id) {
return;
}
try {
const key = this.prefetchStoreKey(topic.id);
const prefetched = PreloadStore.get?.(key);
if (prefetched != null) {
PreloadStore.remove?.(key);
PreloadStore.store(`topic_${topic.id}`, prefetched);
}
} catch {
// best-effort - модальное окно откатится к новой загрузке
}
}
Если строка прокручивается за пределы видимости до того, как по ней кликнули, ожидающий запрос отменяется, а любой кэшированный результат отбрасывается, поэтому в памяти хранятся только данные для тем, которые сейчас действительно видны на экране.
Ещё один момент, не поднятый в теме: вложенные модальные окна
Некоторые потоки модальных окон (те, которые ядро открывает самостоятельно, а не через явный callback-проп, подключенный к showSubModal()) напрямую вызывают сервис modal. Чтобы не позволять им закрывать модальное окно превью из-под пользователя, modal.show() переопределяется для избирательного перенаправления целевых модальных окон в небольшой локальный слот для подмодальных окон на время работы превью:
this.originalModalShow = this.modal.show.bind(this.modal);
this.modal.show = (component, opts = {}) => {
return this.showSubModal(component, opts.model);
};
Таким образом, эти целевые потоки наслаиваются поверх превью, вместо того чтобы заменять его, в то время как отслеживание прочитанного (/topics/timings) продолжает работать на фоне через периодическую отправку набора видимых номеров постов, отслеживаемого IntersectionObserver, поэтому счётчики непрочитанных сообщений остаются корректными после закрытия, так же, как и при обычном посещении темы.
Это довольно большой объём кода, распределённый между модальным окном, триггером списка тем и стилями (~1700 строк всего), это покрывает примерно треть из них. Самыми сложными частями, безусловно, были патчинг сервисов и заставление content-visibility вести себя корректно после изменения высоты постов при завершении загрузки изображений. Готов рассказать больше деталей по любому из пунктов или открыть исходный код, если есть интерес.