| 概要 | トピックプレビューモーダル – トピックリストを離れずにトピックを開いて操作できます | |
| プレビュー | Theme Creator | |
| リポジトリ | GitHub - VaperinaDEV/discourse-topic-preview-modal: Open a topic directly from the topic list in a native Discourse modal, read and interact with the topic, and then continue browsing the list without navigating away from it. · GitHub | |
| 役に立ちましたか? | > ./support --coffee | |
| インストールガイド | テーマまたはテーマコンポーネントのインストール方法 | |
| Discourse Themesは初めてですか? | Discourse Themesの使用に関する初心者ガイド |
このテーマコンポーネントをインストール
Topic Preview Modal – トピックリストを離れずにトピックを開いて操作する
新しいDiscourseテーマコンポーネント Topic Preview Modal を作成しました。
アイデアは比較的シンプルです。
トピックリストからトピックをネイティブなDiscourseモーダル内で直接開き、トピックを読み、操作し、その後リストから離れずに閲覧を続行します。
この機能は Facebook-style Topic Modal - Is it better? から始まりましたが、最終的にはDiscourseのトピック、投稿ストリーム、コンポーザー、モーダル、ブックマーク、ルーティング、プレゼンス、読み取り追跡、プリフェッチシステムとのかなりの統合が必要になりました。
なぜ必要なのか?
通常のDiscourseの流れは以下の通りです。
- トピックリストを閲覧しています。
- トピックをクリックします。
- Discourseが
/t/...に遷移します。 - トピックを読み、返信し、操作します。
- トピックリストに戻ります。
多くのワークフローでは、これで問題ありません。
しかし、忙しいトピックリストを閲覧している際、トピックを簡単に確認し、いくつかの投稿を読み、最新の返信をチェックし、何かに対してリアクションしたり、簡単な質問に答えたりしたいだけの場合があります。
そのようなユースケースでは、トピックリストを離れるのは不必要にコストが高いと感じます。
したがって、このコンポーネントの目標は、トピックリストをより 受信トレイ のように動作させることでした。
トピックリスト → プレビュー → 操作 → クローズ → ちょうど同じ場所から続行。
機能
プレビューは単なる静的な抜粋ではありません。
ネイティブな DModal 内で実際のDiscourse投稿コンポーネントをレンダリングします。
つまり、ユーザーは以下を行うことができます。
- 投稿を読む
- トピックをスクロールする
- 以前の投稿を読み込む
- 下の追加投稿を読み込む
- 投稿にリアクションする
- 投稿をブックマークする
- テキストを引用する
- トピックに返信する
- 個別の投稿に返信する
- 許可されている場合、投稿を編集する
- 許可されている場合、投稿を削除/復元する
- 投稿をフラグする
- 投稿履歴を表示する
- 通常の投稿アクションを実行する
- トピックのプレゼンスを確認する
- 同じトピック内の他の投稿へのリンクを開く
- 該当する投稿に直接ジャンプする
- 必要に応じて完全なトピックを開く
プレビューは、実際にトピックを開いたものにできるだけ近い感覚になることが意図されています。
2つのトリガーモード
プレビューを開く方法は2つあります。
1. トピックリストの行全体
これがデフォルトです。
トピックリストの行全体がクリック可能になりますが、以下の一般的なインタラクティブ要素はモーダルのトリガーから除外されます。
- ユーザーカード
- 参加者
- カテゴリリンク
- タグ
- トピックステータスリンク
- 一括選択
これにより、トピックリストを閲覧する際の体験が非常に高速になります。
2. 明示的な展開ボタン
代替として、コンポーネントはDiscourseプラグインアウトレットを通じて小さな展開アイコンをレンダリングできます。カスタムテーマは、トリガーを表示するために新しい <PluginOutlet /> を簡単に作成できます。
このモードでは、通常のトピックリストの動作は完全に維持されます。
ユーザーは展開アイコンをクリックしてプレビューを開き、トピックタイトルをクリックすると通常のDiscourse遷移が行われます。
これは、サイトが標準のトピックリストインタラクションモデルを保持したい場合に有用です。
設定は以下の通りです。
trigger_style:
row
または:
trigger_style:
button
ボタンモードを使用する場合、アウトレットも設定可能です。
プレビューはユーザーの未読位置から始まります
重要な詳細の一つは、モーダルが 単に最初の投稿を読み込むわけではない ことです。
トピックが部分的に読み取られている場合、プレビューは以下を計算します。
last_read_post_number + 1
そして、その投稿の周りで開きます。
つまり、トピックに200件の投稿があり、ユーザーが165番目の投稿まで読んでいる場合、プレビューを開くと166番目付近から始まります。
これにより、プレビューは実際の閲覧においてはるかに有用になります。
また、コンポーネントは投稿ストリームの両側を処理する必要があります。
- 必要に応じて以前の投稿を読み込む
- 下の新しい投稿を読み込む
以前の投稿 ボタンは、現在読み込まれている範囲の上に投稿がある場合に表示され、IntersectionObserver センチネルがユーザーが下部に到達したときに自動的に追加の投稿を読み込みます。
プリフェッチ
コンポーネントの最大の一つの部分は、そのプリフェッチシステムです。
このようなモーダルの問題は、ユーザーが即座に感じることを期待していることです。
ユーザーがクリックした後にのみトピックの読み込みを開始すると、モーダルはネットワークを待っている間に目立つ時間を費やす可能性があります。
代わりに、コンポーネントはユーザーがリストを閲覧している間に能動的にトピックをプリフェッチできます。
トピックの行がビューポートに近づくと、IntersectionObserver がプリフェッチをスケジュールできます。
これが制御不能なバックグラウンドトラフィックに変わらないように、いくつかのセーフガードがあります。
デバウンス
トピックがビューポートに一瞬表示されただけで、すぐにリクエストをトリガーするわけではありません。
コンポーネントは設定されたデバウンス期間を待機します。
デフォルト:
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
プレビューは下層にマウントされたままです。
コンポーネントはアクティブな間に関連するモーダルサービスメソッドを一時的にパッチし、破棄されたときにそれらを復元します。
モーダル内のルーティング
もう一つの重要な詳細は、同じトピック内の投稿へのリンクです。
例えば、投稿に以下へのリンクが含まれている場合:
/t/my-topic/123
プレビューは閉じて離れる必要はありません。
代わりに、コンポーネントは同じトピック内のナビゲーションをインターセプトし、モーダル内の要求された投稿にジャンプします。
特定の投稿番号なしにトピックをターゲットとするリンクにも同じことが適用されます。
これにより、ユーザーはプレビュー内に留まることができます。
リンクが本当に別のトピックを指している場合、コンポーネントはまず一時的なサービスパッチを復元し、通常のDiscourseルート遷移を許可する前に自身を閉じます。
そのクリーンアップは重要です。そうでなければ、実際のトピックルートが初期化されている間に、プレビューのサブスクリプションとタイミングトラッカーが生きてしまう可能性があるためです。
読み取り追跡と時間追跡
また、プレビューがDiscourseの観点から正しく動作することを望みました。
プレビューを開くことは、読み取り追跡が完全にバイパスされることを意味するべきではありません。
したがって、コンポーネントは以下を処理します。
- トピック訪問の追跡
- 可視投稿の追跡
- トピックのタイミング
- 最終読取投稿の更新
タイミングトラッカーは、実際にどの投稿が可視かを決定するために IntersectionObserver を使用します。
5秒ごとに、可視投稿のタイミングが以下にフラッシュされます。
/topics/timings
モーダルが閉じられると、最後の数秒が失われないように、最終的なフラッシュが実行されます。
実装はまた、単一のタイミング区間を60秒に制限しています。
トピックリストの未読状態の同期
ここにも微妙な問題がありました。
Discourseのトピック追跡状態を更新するだけでは、トピックリストの行に直接表示される未読バッジを更新するには十分ではありません。
したがって、コンポーネントはタイミング情報がフラッシュされた後、行に関連付けられた実際のトピックオブジェクトを更新します。
適切であれば、以下のような値を更新します。
last_read_post_number
unread_posts
unread
new_posts
これにより、モーダル内でトピックを読んだ後、トピックリストはフルページリフレッシュを必要とせずに、新しい読み取り状態を即座に反映できます。
投稿の可視性
プレビューは、個別の投稿が可視になるタイミングを決定するために共有された IntersectionObserver を使用します。
また、オブザーバーがアタッチされたときにも同期可視性チェックがあります。
これにより、投稿がマウントされた時点ですでに可視だが、非同期の最初の IntersectionObserver コールバックがまだ発火していないというエッジケースを処理します。
これは、モーダルが開いた時点でトピック全体がすでに可視になりうる非常に短いトピックに対して特に関連があります。
パフォーマンスに関する考慮事項
主要な目標の一つは、モーダルをパフォーマンスに重いミニチュアトピックページにすることでした。
それのために、いくつかのことが特に行われています。
段階的レンダリング
初期ロードでは、すべての投稿が即座にレンダリングされるわけではありません。
コンポーネントはまず、ターゲット位置に到達するのに十分な数の投稿をレンダリングします。
残りの投稿は、利用可能な場合 requestIdleCallback を使用し、そうでない場合は setTimeout にフォールバックして、段階的にレンダリングされます。
これは、ストリームのかなり下にある投稿の周りで長いトピックを開く場合に特に有用です。
CSS包含
投稿は以下を使用します。
contain: layout;
content-visibility: auto;
contain-intrinsic-size: 1px 180px;
これにより、ブラウザは現在可視でない投稿に対して不要なレンダリング作業を行うのを避けることができます。
遅延画像
まだ読み込みモードが指定されていない画像には、自動的に以下が与えられます。
loading="lazy"
decoding="async"
これにより、多くの画像を含む長いトピックがすべてを即座に読み込むのを防ぎます。
ローディング状態
リクエストが行われている間、モーダルは単に空白の白い/空の領域を表示するわけではありません。
以下を持つスケルトンUIがあります。
- アバタープレースホルダー
- ユーザー名/名前プレースホルダー
- 投稿本文プレースホルダー
- シマーアニメーション
シマーは以下を尊重します。
prefers-reduced-motion
したがって、モーションの削減を要求したユーザーにはアニメーションが無効になります。
スクロール位置の安定維持
コンポーネントがスクロール位置を手動で操作する必要がある場所がいくつかあります。
例えば、以前の投稿を読み込む場合、新しく挿入されたコンテンツによりスクロール高さが増加します。
単に投稿を先頭に追加すると、ユーザーの現在の位置がジャンプしてしまいます。
したがって、コンポーネントは以前のスクロール高さを記録し、投稿が挿入された後に差分を補償します。
これにより、現在可視のコンテンツがほぼ同じ場所に保たれます。
特定の投稿にジャンプする場合にも同じことが適用されます。
コンポーネントはレンダリング後の位置決めステップを実行し、まだ安定していない可能性があるコンテンツに対応するために、後続のフレームで位置を再確認します。
トピックプレゼンス
関連するトピックデータが利用可能な場合、プレビューはモーダルの下部にDiscourseのトピックプレゼンス情報を表示することもできます。
これにより、ユーザーはプレビューを離れることなく、現在誰がトピックを表示しているかを確認できます。
モバイルメニューとフォーカスとのインタラクション
モバイルはもう一つの問題のカテゴリを導入しました。
一部のDiscourse UI要素は共有されたモーダル/メニューサービスを使用し、それらのサービスはトピックプレビューが現在ネストされたブラウジングコンテキストとして動作していることを必ずしも認識していません。
したがって、コンポーネントには以下の周囲の追加処理があります。
modal.close()- Float Kitメニュー
- フォーカス復元
- コンポーザー
- ライトボックスのキーボード制御
- ボディスクロールロック
例えば、メニューが内部でグローバルモーダルのクローズメソッドを呼び出そうとしても、それは誤ってトピックプレビュー全体を閉じてはなりません。
同様に、コンポーザーが開いている場合、フォーカスはプレビューのフォーカスコンテキストに引き戻されるのではなく、コンポーザー内に留まる必要があります。
設定
コンポーネントは現在、以下の設定を公開しています。
| 設定 | デフォルト | 説明 |
|---|---|---|
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 |
1分あたりの最大投機的リクエスト数 |
プリフェッチ制御は意図的に設定可能にされています。コミュニティによってトラフィックパターンやホスティング/ネットワーク特性が非常に異なる可能性があるためです。
主要な設計目標の一つ: 通常のDiscourseを壊さない
コンポーネントをDiscourseの既存アーキテクチャにできるだけ近づけるよう試みました。
独自の投稿レンダラー、独自のコンポーザー、独自のトピックモデル、または完全に独立した投稿ストリームを実装していません。
代わりに、Discourseの既存のコンポーネントとサービスの周りに一時的なブラウジングコンテキストを構築しています。
そのため、実装の一部は、最初に思われるよりも複雑です。
より興味深い課題は以下でした。
トピックが別のUIコンテキスト内で表示されている間、ほぼ通常のDiscourseトピックのように動作することは可能か?
それには、Discourseのグローバルサービスとローカルプレビューの境界を処理する必要がありました。





