トピックプレビューモーダル

このテーマコンポーネントをインストール

トピックプレビューモーダル – トピック一覧を離れずにトピックを開き、操作を行う

トピックプレビューモーダルという新しいDiscourseテーマコンポーネントを作成しました。

アイデアは比較的シンプルです。

トピック一覧から直接トピックをDiscourse標準のモーダルで開き、トピックを読み、操作を行い、その後、一覧から離れずに閲覧を続行する。

これは Facebook-style Topic Modal - Is it better? から始まりましたが、最終的にはDiscourseのトピック、投稿ストリーム、コンポーザー、モーダル、ブックマーク、ルーティング、プレゼンス、読み取り追跡、プリフェッチシステムとのかなりの統合が必要となりました。


なぜ?

通常のDiscourseのフローは以下の通りです。

  1. トピック一覧を閲覧中。
  2. トピックをクリック。
  3. Discourseが /t/... にナビゲート。
  4. トピックを読み、返信し、操作を行う。
  5. トピック一覧に戻る。

多くのワークフローでは、これで問題ありません。

しかし、忙しいトピック一覧を閲覧している際、単にトピックを素早く確認し、いくつかの投稿を読み、最新の返信を確認し、リアクションを行い、あるいは素早い質問に答えたいことがあります。

そのようなユースケースでは、トピック一覧を離れるのは不必要にコストがかかります。

したがって、このコンポーネントの目標は、トピック一覧を 受信トレイ のように動作させることでした。

トピック一覧 → プレビュー → 操作 → 閉じる → ちょうど同じ場所から再開。


機能

プレビューは単なる静的な抜粋ではありません。

Discourse標準の DModal 内に実際のDiscourse投稿コンポーネントをレンダリングします。

つまり、ユーザーは以下を行うことができます。

  • 投稿を読む
  • トピックをスクロールする
  • 以前の投稿を読み込む
  • 下の投稿をさらに読み込む
  • 投稿にリアクションする
  • 投稿をブックマークする
  • テキストを引用する
  • トピックに返信する
  • 個々の投稿に返信する
  • 許可されている場合、投稿を編集する
  • 許可されている場合、投稿を削除/復元する
  • 投稿をフラグする
  • 投稿履歴を表示する
  • 各種の通常の投稿アクションを実行する
  • トピックのプレゼンスを表示する
  • 同じトピック内の他の投稿へのリンクをフォローする
  • 関連する投稿に直接ジャンプする
  • 必要に応じて完全なトピックを開く

プレビューは、実際にトピックを開いたのと同じように感じられることを意図しています。


2つのトリガーモード

プレビューを開く方法が2つあります。

1. トピック一覧の行全体

これがデフォルトです。

トピック一覧の行全体がクリック可能になりますが、以下の一般的なインタラクティブ要素はモーダルトリガーから除外されます。

  • ユーザーカード
  • 参加者
  • カテゴリリンク
  • タグ
  • トピックステータスリンク
  • 一括選択

これにより、トピック一覧を閲覧する際の体験が非常に高速になります。

2. 明示的な展開ボタン

あるいは、コンポーネントはDiscourseプラグインアウトレットを通じて小さな展開アイコンをレンダリングできます。カスタムテーマは、トリガーを表示するために新しい <PluginOutlet /> を簡単に作成できます。

このモードでは、通常のトピック一覧の動作は完全に維持されます。

ユーザーは展開アイコンをクリックしてプレビューを開き、トピックタイトルをクリックすると依然として通常のDiscourseナビゲーションが実行されます。

これは、標準のトピック一覧のインタラクションモデルを維持したいサイトにとって有用です。

設定は以下の通りです。

trigger_style:
  row

または:

trigger_style:
  button

ボタンモードを使用する場合、アウトレットも設定可能です。


プレビューはユーザーの未読位置から開始

重要な詳細の一つは、モーダルが 単に最初の投稿を読み込まない ことです。

トピックがすでに部分的に読み込まれている場合、プレビューは以下を計算します。

last_read_post_number + 1
```\n
そしてその投稿の周りで開きます。

したがって、トピックに200の投稿があり、ユーザーが投稿 #165 まで読み進めている場合、プレビューを開くと #166 の周りで開始されます。

これにより、プレビューは実際の閲覧においてはるかに有用になります。

また、これはコンポーネントが投稿ストリームの両側を処理する必要があることを意味します。

* 必要に応じて以前の投稿を読み込む
* 下の新しい投稿を読み込む

**以前の投稿** ボタンは、現在読み込まれている範囲の上に投稿がある場合に表示され、 `IntersectionObserver` センティネルはユーザーが底部に到達したときに自動的にさらに投稿を読み込みます。

---

# プリフェッチ

コンポーネントの大きな部分の一つは、そのプリフェッチシステムです。

このようなモーダルの問題は、ユーザーがそれを瞬時に感じること期待することです。

ユーザーがクリックした後にのみトピックの読み込みを開始する場合、モーダルはネットワークを待機する目に見える時間を費やす可能性があります。

代わりに、コンポーネントはユーザーが一覧を閲覧している間に積極的にトピックをプリフェッチできます。

トピック行がビューポートに近づくと、 `IntersectionObserver` はプリフェッチをスケジュールできます。

これが制御不能なバックグラウンドトラフィックにならないようにするためのいくつかの保護措置があります。

### デバウンス

トピックは、ビューポートに一時的に表示されただけで、すぐにリクエストをトリガーしません。

コンポーネントは設定されたデバウンス期間を待機します。

デフォルト:

```text
400 ms

これは、長いトピック一覧を素早くスクロールする場合に特に有用です。

ルートマージン

プリフェッチは、トピックが実際にビューポートに入る少し前に開始できます。

デフォルト:

50 px

これにより、リクエストに少しの先行時間を与えます。

同時リクエスト制限

同時プリフェッチの数は制限されています。

デフォルト:

2

設定により、1から6の同時プリフェッチを許可できます。

毎分予算

また、2番目の保護メカニズムもあります。

max_prefetches_per_minute

デフォルトは:

15

したがって、ユーザーが数百のトピックをスクロールし続けた場合でも、コンポーネントは継続的に投機的なリクエストを生成しません。

0 は制限を無効にします。

プリフェッチは完全に無効にできます

サイトに投機的なネットワークトラフィックを一切望まない場合:

enable_prefetch = false

コンポーネントは通常通り動作し続けます。トピックは単にプレビューが開かれたときに読み込まれます。


プリフェッチデータは通常のトピックナビゲーションから分離されます

ここには重要な実装詳細があります。

プリフェッチされたレスポンスは Discourseの通常の topic_<id> プリロードキーにすぐに書き込まれません

代わりに、コンポーネントは独自のネームスペースを使用します。

topic-preview-modal:prefetch:<topicId>

ユーザーが実際にプレビューを開いたときにのみ、プリフェッチされたプロミスはコアトピックプリロードキーに昇格されます。

これは意図的なものです。

プレビューは last_read_post_number + 1 からトピックを読み込んでいる可能性があり、そのプレビュー固有のレスポンスが通常のトピックルートナビゲーションに漏れ出さないようにしたいのです。

したがって、ライフサイクルは本質的に以下の通りです。

topic enters viewport
        ↓
prefetch
        ↓
private preload storage
        ↓
user opens preview
        ↓
promote preload
        ↓
Topic.find()/PostStream uses the same promise

これにより、モーダルはプリフェッチリクエストが完了するのを待たずに開くことができます。

モーダルはスケルトンで即座に開き、同じプロミスが引き続き解決されます。


モバイルサポート

これは実際、実装に相当な時間を費やした理由の一つでした。

初期のアイデアはデスクトップでは比較的よく動作していましたが、モバイルでは以下の問題が露見しました。

  • タッチインタラクション
  • モーダルのスクロール
  • フォーカス
  • ネストされたメニュー
  • コンポーザー
  • 投稿の可視性
  • 画像の読み込み
  • パフォーマンス

したがって、最終的な実装では、モーダルを完全に独立したミニチュアフォーラムとして扱わないようにしています。

代わりに、Discourseの既存のインフラストラクチャを可能な限り再利用します。


実際のDiscourse投稿コンポーネント

モーダルは、簡略化されたカスタムテンプレートを使用して投稿を再作成しません。

Discourseの実際の

Post
PostSmallAction

コンポーネントをレンダリングします。

これは重要です。なぜなら、そうしないとプレビューはすぐに投稿UIの2番目の実装になってしまうからです。

コンポーネントは関連するアクションを通常の投稿コンポーネントに渡します。これには以下が含まれます。

  • 返信
  • 編集
  • 削除
  • 復元
  • フラグ
  • 履歴
  • ブックマーク
  • ウィキ
  • ロック/アンロック
  • 投稿タイプ
  • 所有権の変更
  • バッジ
  • 非表示の投稿
  • 引用
  • など

その結果、プレビューは従来の「プレビュー」コンポーネントよりも、通常のトピックのように動作できるようになります。


返信とコンポーザー

コンポーザーはより複雑な部分の一つです。

プレビューは、以下のために通常のDiscourseコンポーザーを開くことができます。

トピックへの返信

トピックコンポーザーは、トピックモデルと正しいドラフト情報で開かれます。

特定の投稿への返信

投稿はコンポーザーに渡され、返信が通常の投稿返信のように動作します。

選択したテキストの引用

コンポーネントは PostTextSelection と統合されています。

つまり、ユーザーはプレビュー内でテキストを選択し、Discourseの通常の引用/返信フローを使用できます。


ネストされたモーダル

もう一つの難しい部分は、Discourseのモーダルシステムでした。

投稿は他のモーダルやダイアログを開くことができます。

  • フラグ
  • 履歴
  • バッジ関連のダイアログ
  • 所有権の変更
  • 削除の確認
  • など

これらが通常どおりグローバルモーダルサービスと相互作用することを許可すると、それらの一つを開くことで、トピックプレビュー全体が閉じる可能性があります。

これを避けるために、コンポーネントはローカルなサブモーダルメカニズムを作成します。

概念的には:

Topic Preview Modal
        │
        ├── Flag modal
        ├── History modal
        ├── Delete confirmation
        ├── Badge modal
        └── other post-related modal

プレビューは下面にマウントされたままです。

コンポーネントは、アクティブな間に関連するモーダルサービスメソッドを一時的にパッチし、破棄されたときにそれらを復元します。


モーダル内のルーティング

n
もう一つの重要な詳細は、同じトピック内の投稿へのリンクです。

例えば、投稿に以下のリンクが含まれている場合。

/t/my-topic/123

プレビューは閉じてナビゲートする必要性はありません。

代わりに、コンポーネントは同じトピックのナビゲーションをインターセプトし、モーダル内の要求された投稿にジャンプします。

これは、特定の投稿番号なしにトピックをターゲットにするリンクにも適用されます。

これにより、ユーザーはプレビュー内に留まります。

リンクが実際に異なるトピックを指している場合、コンポーネントは一時的なサービスパッチを復元し、通常のDiscourseルート遷移を許可する前に自身を閉じます。

このクリーンアップは重要です。なぜなら、そうしないと、実際のトピックルートが初期化されている間に、プレビューのサブスクリプションとタイミングトラッカーが存続したままになる可能性があるからです。


読み取り追跡と時間追跡

また、プレビューがDiscourseの観点から正しく動作するようにしたいと思っていました。

プレビューを開くことは、読み取り追跡が完全にバイパスされることを意味すべきではありません。

したがって、コンポーネントは以下を処理します。

  • トピック訪問追跡
  • 可視投稿追跡
  • トピックタイミング
  • 最終読み取り投稿の更新

タイミングトラッカーは、 IntersectionObserver を使用して、どの投稿が実際に可視であるかを判断します。

5秒ごとに、可視投稿のタイミングは以下にフラッシュされます。

/topics/timings

モーダルが閉じると、最後の数秒が失われないように、最終的なフラッシュが実行されます。

実装は、単一のタイミング間隔を60秒に制限しています。


トピック一覧の未読状態を同期させる

ここにもう一つの微妙な問題がありました。

Discourseのトピック追跡状態を更新するだけでは、トピック一覧の行に直接表示される未読バッジを更新するには不十分です。

したがって、コンポーネントはタイミング情報がフラッシュされた後、行に関連付けられた実際のトピックオブジェクトを更新します。

必要に応じて、以下のような値を更新します。

last_read_post_number
unread_posts
unread
new_posts

これにより、モーダル内でトピックを読んだ後、トピック一覧は完全なページリフレッシュを必要とせずに、新しい読み取り状態を即座に反映できます。


投稿の可視性

プレビューは、個々の投稿がいつ可視になるかを判断するために共有の IntersectionObserver を使用します。

また、オブザーバーがアタッチされたときに同期的な可視性チェックもあります。

これにより、投稿がマウントされたときにすでに可視であるが、非同期の最初の IntersectionObserver コールバックがまだ発火していないというエッジケースを処理します。

これは、モーダルが開いたときにトピック全体がすでに可視である可能性のある非常に短いトピックにとって特に重要です。


パフォーマンス上の考慮事項

n
主要な目標の一つは、モーダルをパフォーマンス負荷の高いミニチュアトピックページに変えないことでした。

そのために特別に行われているいくつかのこと。

段階的レンダリング

初期読み込みは、すべての投稿を即座にレンダリングしません。

コンポーネントはまず、ターゲット位置に達するのに十分な投稿をレンダリングします。

残りの投稿は、以下を使用して段階的にレンダリングされます。

requestIdleCallback

利用可能な場合、 setTimeout にフォールバックします。

これは、ストリームの下部にある投稿の周りで長いトピックを開く場合に特に有用です。

CSSコンテインメント

投稿は以下を使用します。

contain: layout;
content-visibility: auto;
contain-intrinsic-size: 1px 180px;

これにより、ブラウザは現在可視ではない投稿に対する不要なレンダリング作業を回避できます。

レイジー画像

すでに読み込みモードを指定していない画像には、自動的に以下が与えられます。

loading="lazy"
decoding="async"

これにより、多くの画像を含む長いトピックがすべてを即座に読み込まないようにします。


読み込み状態

モーダルは、リクエストが行われている間に単に空白の白/空の領域を表示するわけではありません。

スケルトンUIがあります。

  • アバタープレースホルダー
  • ユーザー名/名前プレースホルダー
  • 投稿本文プレースホルダー
  • シimmerアニメーション

シimmerは以下を尊重します。

prefers-reduced-motion

したがって、アニメーションは減衰した動きを要求したユーザーに対して無効になります。


スクロール位置を安定させる

n
コンポーネントがスクロール位置を手動で操作する必要があるいくつかの場所があります。

例えば、以前の投稿を読み込む場合、新しく挿入されたコンテンツはスクロール高さを増加させます。

投稿を単に先頭に追加すると、ユーザーの現在の位置がジャンプしてしまいます。

したがって、コンポーネントは以前のスクロール高さを記録し、投稿が挿入された後、差分を補正します。

これにより、現在可視のコンテンツは概ね同じ場所に保たれます。

これは、特定の投稿にジャンプする場合にも適用されます。

コンポーネントはレンダリング後の位置決めステップを実行し、まだ安定していない可能性のあるコンテンツを考慮して、後続のフレームで位置を再検証します。


トピックプレゼンス

n
関連するトピックデータが利用可能な場合、プレビューはモーダルの底部にDiscourseのトピックプレゼンス情報も表示できます。

したがって、ユーザーはプレビューを離れずに、現在トピックを閲覧している他のユーザーを確認できます。


モバイルメニューとフォーカスとのインタラクション

n
モバイルは別のカテゴリの問題を導入しました。

一部のDiscourse UI要素は共有モーダル/メニューサービスを使用しており、それらのサービスはトピックプレビューが現在ネストされたブラウジングコンテキストとして動作していることを必ずしも知りません。

したがって、コンポーネントは以下に関する追加の処理を持っています。

  • modal.close()
  • Float Kitメニュー
  • フォーカス復元
  • コンポーザー
  • ライトボックスキーボードコントロール
  • ボディスクロールロック

例えば、メニューが内部的にグローバルモーダルクローズメソッドを呼び出そうとすると、それが偶然にもトピックプレビュー全体を閉じてはいけません。

同様に、コンポーザーが開いている場合、フォーカスはプレビューのフォーカスコンテキストに引き戻されるのではなく、コンポーザー内に留まる必要があります。


設定

n
コンポーネントは現在、以下の設定を公開しています。

設定 デフォルト 説明
trigger_style row 行全体をクリック可能にするか、明示的なボタンを使用するか
plugin_outlet topic-list-after-title ボタントリガーによって使用されるアウトレット
enable_prefetch true バックグラウンドトピックプリフェッチの有効/無効
max_concurrent_prefetches 2 最大同時プリフェッチリクエスト数
prefetch_debounce_ms 400 プリフェッチ開始前の遅延
prefetch_root_margin_px 50 行がビューポートに入る前にこのピクセル数でプリフェッチを開始
max_prefetches_per_minute 15 毎分の最大投機的リクエスト数

プリフェッチ制御は意図的に設定可能です。なぜなら、異なるコミュニティは非常に異なるトラフィックパターンとホスティング/ネットワーク特性を持つ可能性があるからです。


主な設計目標の一つ:通常のDiscourseを壊さない

n
私は、コンポーネントをDiscourseの既存のアーキテクチャにできるだけ近づけるように努めました。

それは、独自の投稿レンダラー、独自のコンポーザー、独自のトピックモデル、または完全に独立した投稿ストリームを実装しません。

代わりに、Discourseの既存のコンポーネントとサービスの周りに一時的なブラウジングコンテキストを構築します。

これが、実装の一部が最初に見えるよりも複雑である理由でもあります。

より興味深い課題は以下の通りでした。

トピックは、実際に別のUIコンテキスト内に表示されている間に、通常のDiscourseトピックのようにほぼ動作できますか?

これには、Discourseのグローバルサービスとローカルプレビューの境界を処理する必要がありました。

「いいね!」 5

参考までに:

数学処理に問題があります。ただし、これは別の境界ケースかもしれません。

本当に素晴らしい :slight_smile: さて、これを自分の環境で動作させる方法を探りましょう :slight_smile: @awesomerobot 行全体をクリックできるようにするために、あなたのテーマは何に依存していますか?

api.renderInOutlet("topic-list-before-link", TopicListItemClick);
「いいね!」 1

Reddit-ish テーマを使用している方のために、動作確認が取れた修正方法を共有します。

Reddit-ish テーマとの互換性

Reddit-ish テーマを使用している方への注意点です:モーダルのボタン自体は正常に動作しますが、デフォルトの行トリガーは機能しません。

問題の原因は、Reddit-ish が標準のトピック一覧の行の動作を置き換え、トピックカード全体でのクリックを処理している点にあります。そのため、モーダルの通常の行クリック処理が意図した通りに動作しません。

Topic Preview Modal の設定を以下に変更すると、

Trigger style: button
Plugin outlet: topic-list-after-title

正しく動作します。これは、Reddit-ish が既に topic-list-after-title アウトレットを含んでいるためです。

カード全体のクリック動作を維持するために、Topic Preview Modal をボタンモードのままにし、Reddit-ish の既存の openTopic() アクションを変更して、動作するモーダルボタンをトリガーするようにしました。

元の Reddit-ish のアクションは以下の通りです:

@action
openTopic(event) {
  if (
    (event.target.nodeName === "A" && !event.target.closest(".raw-link")) ||
    event.target.closest(".badge-wrapper")
  ) {
    return;
  }

  const { navigateToTopic, topic } = this.args.outletArgs;

  if (wantsNewWindow(event)) {
    window.open(topic.lastUnreadUrl, "_blank");
  } else {
    navigateToTopic(topic, topic.lastUnreadUrl);
  }
}

これを以下のように変更しました:

@action
openTopic(event) {
  if (
    (event.target.nodeName === "A" && !event.target.closest(".raw-link")) ||
    event.target.closest(".badge-wrapper") ||
    event.target.closest(".topic-preview-modal__trigger-wrapper")
  ) {
    return;
  }

  const { navigateToTopic, topic } = this.args.outletArgs;

  if (wantsNewWindow(event)) {
    window.open(topic.lastUnreadUrl, "_blank");
    return;
  }

  const previewButton = event.currentTarget.querySelector(
    ".topic-preview-modal__trigger-wrapper--button"
  );

  if (previewButton) {
    event.preventDefault();
    event.stopPropagation();
    previewButton.click();
    return;
  }

  navigateToTopic(topic, topic.lastUnreadUrl);
}

モーダルのボタントリガーは以下のようにレンダリングされます:

<div class="topic-preview-modal__trigger-wrapper">
  <span
    role="button"
    class="topic-preview-modal__trigger-wrapper--button"
  >

したがって、これはモーダルのロジックを再作成するものではなく、単に Reddit-ish のカードクリックが既存の動作するプレビューボタンをトリガーするようになります。

その結果、以下のような動作になります:

  • トピックカードのクリックでプレビューモーダルが開きます。
  • トピックタイトルのクリックでプレビューモーダルが開きます。
  • プレビューボタンも引き続き動作します。
  • Cmd/Ctrl+クリックで、通常通り新しいタブでトピックが開きます。
  • カテゴリーやその他の通常のリンクは通常通り動作します。
  • プレビューボタンが存在しない場合、Reddit-ish は通常のトピックナビゲーションにフォールバックします。

つまり、基盤となるモーダルは Reddit-ish と正常に動作しますが、互換性の問題はデフォルトの行トリガーに特化しています。

また、以下の CSS を使用してボタンを非表示にしました:

.topic-preview-modal__trigger-wrapper {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  opacity: 0;
  pointer-events: none;
}
「いいね!」 1