Facebook風トピックモーダル - これは良い?

こんにちは :waving_hand:

@Damian_Boon のアプローチとは少し異なります:iframe を使用せず、すべてを Discourse 独自の Post/post-stream メカニズムを再利用するネイティブな Glimmer コンポーネントとして実行します。スレッドでいくつかの具体的な質問が上がったので、それぞれをどのように解決したかをご紹介します。


iframe を使用しない理由

iframe はこれを達成する最も速い方法です。フルなトピックルート、コンポーザー、モデレーション機能がすべて「無料」で提供されます。@nicolsdennis の MessageBus に関する質問が指摘しているコストとは、ページ上に 2 つのライブ Discourse インスタンスが存在することです。つまり、2 つの post-stream、2 つの MessageBus 接続、2 つのコンポーザーが同時に動作することになります。

私は逆のアプローチを取りました。モーダルは DModal で、store.createRecord("topic", …) から構築された postStream に対して、本物の Post/PostSmallAction コンポーネントをレンダリングします。同じアプリ、同じ MessageBus 接続、同じコンポーザーが使用されます。つまり、モーダルが開いている間、いくつかのコアなシングルトンサービス(modalbookmarkApiDiscourseURL.routeTo、環境変数の controller:topic)をモーダルのトピックに向けなければなりません:

// コアコンポーネント(例:PostBookmarkManager)は controller:topic から topic.bookmarks を読み書きします。
// モーダルが開いている間、これを私たちの topicModel に向けます。
this.topicController = getOwner(this).lookup("controller:topic");
if (this.topicController) {
  this.originalTopicControllerModel = this.topicController.model;
}

すべてのパッチは、クローズ、破棄、またはナビゲーション移動時に、冪等な restoreServicePatches() によって元に戻されるため、モーダルが消えた後、アプリの他の部分に何らかの漏れが生じることはありません。


ダイレクトリンクと内部ルーティング

2 つの要素があります。まず、モーダルは常に投稿 1 からではなく、読者が実際に中断した場所から開く必要があります:

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 件以上の返信)

下方向への無限スクロールは、センチネルと 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 を介して 5 つずつ残りを追いつかせます:

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;
}

これは私が最も強く推したい部分です。クリック後にインスタンスを再利用するのではなく、クリックにプリフェッチします。各トピックリストの行は、自身の可視性を監視し、高速スクロールで多数のリクエストが発火しないように 200ms デバウンスし、最大 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} キーとは異なります。そこに書き込むと、last_read+1 スコープの部分的な JSON が本物のフルトピックナビゲーションに漏れてしまいます。実際のクリック時、モーダルのローダーが期待するキーに昇格されます:

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 {
    // ベストエフォート - モーダルは新しい読み込みにフォールバック
  }
}

行がクリックされる前に画面外にスクロールアウトした場合、保留中のフェッチはキャンセルされ、キャッシュされた結果は破棄されるため、現在画面上にあるトピックのデータのみを保持します。


もう一つ、スレッドではまだ提起されていない:ネストされたモーダル

いくつかのモーダルフロー(明示的なコールバックプロップを介して 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 行)であり、そのうちの約 3 分の 1 をカバーしています。最もトリッキーな部分は、サービスパッチと、画像の読み込み完了後に投稿の高さが変更された後に content-visibility を動作させることでした。興味があれば、いずれかについてさらに詳細に説明したり、オープンソース化したりできます。