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

他のサイトとは異なる外観を目指して試行錯誤し、Facebook風のトピックモーダルを作成しました。私には読み込みが速く感じられ、デスクトップ環境でもよりスムーズに動作するようです。

ご意見をお聞かせください。

テスト用リンクは https://www.carptalk-online.co.uk/ です(デスクトップのみ対応)

上手にできました!Facebookユーザーではない私でも、かなり気に入りました。あなたのサイト、かっこいいですね。:star_struck:

あと、カープ(大きな魚)がこんなに巨大になるなんて知りませんでした!:fishing_pole:

すごくクール!返信が20件以上のトピックでテストしましたか?スクロールすると投稿のさらに多くの「ページ」を読み込むのがそのタイミングで、私の経験では、この種の代替ビューの最大の複雑さの一つです

いいえ、今これをしてから報告します

問題なく動作します - URL 削除済み

ページが読み込まれる様子はあなたのおっしゃる通りに見えますが、期待通りに動作しています

ダイレクトリンクをクリックしても、トピックはモーダルウィンドウに表示されません。ただし、トピックリストからは正常に動作します。

ああ、それは次に調べないといけないことだけど、ずっと考えてたんだ。それって重要なの? Facebookの動作と同じだよ。直接リンクをクリックすると実際の投稿に飛び、ブラウジング中にクリックするとモーダルが開く。

面白い概念ですね。古いメッセージを読み込みながら、新しいメッセージのためにメッセージバスとの接続を安定して維持できますか?

はい、その通りです。モーダルはトピックのカスタムコピーをレンダリングするのではなく、iframe内に通常のDiscourseトピックアプリケーションを読み込むためです。iframeは独自のMessageBus接続を確立し、Discourseは古い投稿の読み込みや新規投稿の受信を含む投稿ストリームを通常通り処理し続けます。

また、親ページもその背後でアクティブな状態を維持するため、技術的にはモーダルが開いている間、2つのDiscourseインスタンスと2つのMessageBus接続が存在することになります。これは機能しますが、完全にネイティブな実装に比べて少しリソース消費が大きくなります。モーダルを閉じると、iframeとその接続が削除されます。

それが問題になるかどうかはわかりませんが、私はFacebookを使っていません :stuck_out_tongue:

ただ、リンクをクリックしてもトピックがモーダルで表示されないことに少し驚きました。ちょうどモーダルで動作することを実証するために投稿されたはずなのにです。

私の最初の投稿はホームページへのリンクでしたよ。クリスにトピックをテストしてもらうための私の返信をクリックしました?haha。ところで、どうしていますか?しばらく会っていませんね

モバイル版の作成が完了し、動作に満足しています。次は、より良い方法を見つけてロード速度を改善する必要があります。何かアドバイスはありますか?

より多くのテストを行った結果、Googleが認識する非JavaScript版を使用しました。これにより、モーダル内での読み込みが瞬時に行われます。もしそのコンテンツを何らかの方法で活用できれば、この処理はさらに高速化できるでしょう。

PC版では非常に美しく見えます。

モバイル版の読み込み速度がやや遅く、クリックするとコンテンツのないページと右上隅の閉じるボタンしか表示されず、空白の画面を長時間待つ必要があるようです。

これが今取り組んでいることです :slight_smile: 現時点では、まだ現実化させたいコンセプトに過ぎません。ただし、私のデバイスでは、Discourseのスピナーローディングアイコンが表示されながら、モバイルで2秒で読み込まれるので、何が起こっているのかよくわかりません。おそらく、サーバーがロンドン(英国)にあり、CDNがまだ設定されていないためでしょう。

確かに距離が遠すぎるので、ネットワークの伝送には時間がかかります

これでモーダル内でのトピックの読み込みが即座に完了するようになりました。次は、返信などで最初の Discourse 読み込みインスタンスを使用する方法を見つける必要があります(これはテストサーバー上です)。

メインサイトでこの機能をオフにしました。現在、ユーザーが登録するトラフィックが増えているため、テストサーバーに移動しました。モーダルに興味のある方がいる場合、8月中旬までに完了する予定です。

こんにちは :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 を動作させることでした。興味があれば、いずれかについてさらに詳細に説明したり、オープンソース化したりできます。