Модальное окно тем в стиле Facebook — лучше ли это?

Привет :waving_hand:

Подход отличается от подхода @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 вести себя корректно после изменения высоты постов при завершении загрузки изображений. Готов рассказать больше деталей по любому из пунктов или открыть исходный код, если есть интерес.