一部のユーザープロフィールに背景動画を追加しますか?

特定のページにコンテンツを追加したい場合、最適な方法は plugin-outlet(プラグイン・アウトレット)を使用することです。簡単に言うと、plugin-outlet は Discourse のテンプレートに予約されたスペースで、ここに新しいコンテンツを追加できます。

まず初めに、ターゲットとするページに plugin-outlet が存在するかどうかを確認する必要があります。その際に役立つテーマコンポーネントがあります。

(deprecated) Plugin outlet locations theme component

このコンポーネントをインストールし、有効化したら、ターゲットとするページに移動して利用可能なアウトレットを確認してください。今回の場合、plugin-outlet が存在します(緑色でハイライト)。

したがって、必要なものは above-user-profile です。

もしこれが存在しなかったらどうなるでしょうか?その場合の最善策は、追加を依頼するか、コアに追加するための PR(プルリクエスト)を送ることです。ユースケースが妥当であれば、ほとんどの場合承認されます。

とにかく、今回のケースでは既に存在しています。では、どのようにマークアップを追加するか見ていきましょう。残りの手順では上記のコンポーネントは不要なので、plugin outlet の名前がわかった時点で無効にできます。

必要なことは、テーマのヘッダータブに以下のようなコードを追加することだけです。

<script type="text/x-handlebars" data-template-name="/connectors/OUTLET_NAME/SOME_NAME">
  ここにマークアップを記述...
</script>

OUTLET_NAME をターゲットとする outlet の名前に変更し、SOME_NAME をこのカスタマイズに与えたい名前に変更してください。名前は任意ですが、可能であれば説明的な名前にするのが良い慣習です。これにより、以下のようなコードになります。

<script type="text/x-handlebars" data-template-name="/connectors/above-user-profile/add-profile-videos">
  ここにマークアップを記述...例えば
  <h1>Hello World!!</h1>
</script>

これを試して結果を見てみましょう… 覚えておいてください、上記のスニペットはテーマの common > header タブに記述します。

そして…

ここまでは順調ですが、さらに深く掘り下げてみましょう。

すべてのプロフィールで動画を表示したくはないはずです。ある条件に基づいてのみ表示したいでしょう。では、どうすればよいでしょうか?そのためには、2 つの要素が必要です。消費するデータと、少しの JavaScript です。

データを探しましょう。plugin-outlet が予約されたスペースだと述べたことを思い出してください。では、文脈(コンテキスト)なしにそれらを設ける意味は何でしょうか?そのため、Discourse は関連するコンテキストの断片を各 plugin outlet に渡します… まず一歩引いて考えましょう。以下のようなコードを追加すると

<script type="text/x-handlebars" data-template-name="/connectors/above-user-profile/add-profile-videos">
  ここにマークアップを記述...例えば...
  <h1>Hello World!!</h1>
</script>

これは HTML のように見えます(script タグ自体は HTML ですが)、その中身は handlebars コードとして扱われます。

つまり、以下のような処理も可能です。

<script type="text/x-handlebars" data-template-name="/connectors/above-user-profile/add-profile-videos">
  {{log this}}
</script>

そしてブラウザのコンソールを確認してください。Outlet がレンダリングされるたびに、つまりユーザーページにいるときに、以下のような出力が見られます。

これらの情報が役立つかというと、現時点ではそうではありません。後で戻りましょう。もう一歩引いて、Discourse がどのようにコンテキストを outlet に渡しているか見てみましょう。GitHub またはローカル環境で outlet 名を検索すると、以下のような結果が得られます。

Repository search results · GitHub

そのファイルを開いてください。最初に表示されるのはこの行です。

その行の最後の部分を見てみましょう

args=(hash model=model)

Discourse が model を引数として outlet に渡していることがわかります。実用上の目的と単純化のため、model = data と考えて問題ありません。

つまり、outlet の引数の一つが model であり、欲しいデータはそこに入っています。では、スニペットに戻りましょう。

<script type="text/x-handlebars" data-template-name="/connectors/above-user-profile/add-profile-videos">
  {{log this}}
</script>

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

<script type="text/x-handlebars" data-template-name="/connectors/above-user-profile/add-profile-videos">
-  {{log this}}
+  {{log args}}
</script>

これでコンソールに以下のような出力が得られます。

そのデータを参照して、必要なものが含まれているか確認してください。そのページ上の他の要素で使用されているユーザーに関するすべてのデータが含まれているため、含まれているはずです。これは、その特定のユーザーのユーザーページの「モデル」です。

利用可能なプロパティの一つは… ドラムロール :drum: … ユーザーが所属するグループです。

したがって、以下のようにすると

{{log args.model.groups}}

コンソールにユーザーが所属するすべてのグループが表示されます。

さて、必要なデータが手に入ったので、残っているのはそれに基づいて条件を追加することだけです。

同じスニペット内で処理できると考えたいかもしれませんが、残念ながらそれはできません。Handlebars はテンプレート言語であり、ロジックのサポートは非常に、非常に 限定的です。単純な真偽条件とループ以上のことはできません。比較処理などは行えません。

では、どこでそれを行えばよいのでしょうか?「connector class(コネクタークラス)」です。少し派手に聞こえますね… 分かります。

簡単に言うと、connector class は outlet に付随する JavaScript の一部です。実際にはもっと複雑ですが、現時点ではそれだけ知っていれば十分です。

では、作成してみましょう。以下のようにします

<script type="text/discourse-plugin" version="0.8">
api.registerConnectorClass('OUTLET_NAME', 'SOME_NAME', {

});
</script>

ここで OUTLET_NAMESOME_NAME上記で使用したものと同じである必要があります。したがって、変更しましょう

<script type="text/discourse-plugin" version="0.8">
api.registerConnectorClass('above-user-profile', 'add-profile-videos', {

});
</script>

このスニペットもテーマの common > header タブに記述します。これで以下のような状態になっているはずです

<script type="text/x-handlebars" data-template-name="/connectors/above-user-profile/add-profile-videos">
  {{log args.model.groups}}
</script>

<script type="text/discourse-plugin" version="0.8">
  api.registerConnectorClass('above-user-profile', 'add-profile-videos', {

  });
</script>

connector class の内部で処理を行えますが… 注意が必要です。これは単なる JavaScript ファイルとは異なります。より適切な表現が見つからないため… 痩身した Ember コンポーネントだと考えてください。これ以上の詳細は範囲外ですので、先に進みましょう。

デフォルトで 4 つのメソッドが接続されています

actions を使用して、以下のようにアクションを定義できます

api.registerConnectorClass("above-user-profile", "add-profile-videos", {
  actions: {
    myAction() {
      // 何かを行う
    }
  }
});

その後、ボタンが押された際などに、outlet 内でそのアクションを呼び出すことができます。今回は必要ありませんので、先に進みましょう。

api.registerConnectorClass("above-user-profile", "add-profile-videos", {
  shouldRender(args, component) {
    // ここで true または false を返す
  }
});

これも使用しません。outlet はプロフィールページでのみレンダリングされ、現時点では他の要件がないためです。ただし、outlet がレンダリングされる前にテストしたい条件(現在のユーザーの信頼レベルなど)を追加するために使用できます。次に…

api.registerConnectorClass("above-user-profile", "add-profile-videos", {
  setupComponent(args, component) {
    // 何かを行う
  }
});

これが焦点を当てるべきメソッドです。設定したい JavaScript の条件や変数はここに入ります。このメソッドをさらに掘り下げる前に、完全性の観点から最後のメソッドを説明しましょう

api.registerConnectorClass("above-user-profile", "add-profile-videos", {
  teardownComponent(args, component) {
    // 何かを行う
  }
});

これは outlet が削除されるときに発火します。イベントリスナーの削除など、必要なクリーンアップを行うことができます。

さて、setupComponent に戻りましょう

api.registerConnectorClass("above-user-profile", "add-profile-videos", {
  setupComponent(args, component) {
    // 何かを行う
  }
});

2 つの引数が渡されていることがわかります。まず args、次に component です。

ここで args は先ほど見たものと同じです。Discourse が outlet に渡したコンテキストデータです。したがって、以下のようにすると

api.registerConnectorClass("above-user-profile", "add-profile-videos", {
  setupComponent(args, component) {
    console.log(args.model.groups);
  }
});

先ほどブラウザのコンソールで見たのと同じ情報、つまりプロフィール所有者が所属するグループが表示されます。ここからが本番です。データも正しいフックも手に入りました。したがって、ここで好きな処理を行えます。つまり、特定のグループに所属するメンバーのプロフィールでのみ動画を表示したい場合、以下のようにできます

  api.registerConnectorClass('above-user-profile', 'add-profile-videos', {
    setupComponent(args, component) {
      const inGroup = [...args.model.groups].filter(g => g.name === TARGET_GROUP)
      const showVideo = inGroup.length ? true : false;

      console.log(showVideo);
    }
  });

staff グループに所属するユーザーのプロフィールページでこれを試すと、コンソールに true が出力されます。これで残っているのは、それを outlet テンプレートに渡すことです。その方法は以下の通りです。

setupComponent に渡される component は、connector と outlet で共有されます。component のプロパティとして設定することで、outlet に値を渡すことができます

  const TARGET_GROUP = "staff"

  api.registerConnectorClass('above-user-profile', 'add-profile-videos', {
    setupComponent(args, component) {
      const inGroup = [...args.model.groups].filter(g => g.name === TARGET_GROUP)
      const showVideo = inGroup.length ? true : false;
-     console.log(showVideo);
+     component.setProperties({showVideo})
    }
  });

これで、テンプレートに戻って以下のような処理を行います

{{log showVideo}}

同じ結果が出力されます。これを Handlebars の条件文に入れ、その中にマークアップを追加します

<script
  type="text/x-handlebars"
  data-template-name="/connectors/above-user-profile/add-profile-videos"
>
  {{#if showVideo}}
    <video playsinline autoplay muted loop id="myVideo" poster="[INSERT LINK]">
      <source src="[INSERT LINK]" type="video/webm">
      <source src="[INSERT LINK]" type="video/mp4">
    </video>
  {{/if}}
</script>

staff ユーザーのプロフィールページを確認してください。動画が読み込まれることが確認できるはずです。

staff メンバーのプロフィールから移動すると、動画は消えます。staff グループに所属しないユーザーのプロフィールでは動画は表示されません。

では、これらすべてをまとめましょう。上記と同じ内容です。

以下は使用した CSS です。common > css タブ

#myVideo {
  position: fixed;
  top: var(--header-offset);
  min-height: 100vh;
  left: 0;
  z-index: -1;
}

.user-content {
  background: none;
}

.user-main {
  padding: 0.5em;
  background: rgba(var(--secondary-rgb), 0.8);
}

// モバイル版でも表示させたい場合
.mobile-view {
  body[class*="user-"] {
    background: none;
    .user-main,
    .user-content {
      padding: 0.5em;
      background: rgba(var(--secondary-rgb), 0.8);
    }
  }
}

HTML / JavaScript / Handlebars。これはテーマの common > header タブに記述します

<script
  type="text/x-handlebars"
  data-template-name="/connectors/above-user-profile/add-profile-videos"
>
  {{#if showVideo}}
    <video playsinline autoplay muted loop id="myVideo" poster="[INSERT LINK]">
      <source src="[INSERT LINK]" type="video/webm">
      <source src="[INSERT LINK]" type="video/mp4">
    </video>
  {{/if}}
</script>

<script type="text/discourse-plugin" version="0.8">
  const TARGET_GROUP = "staff"

  api.registerConnectorClass('above-user-profile', 'add-profile-videos', {
    setupComponent(args, component) {
      const inGroup = [...args.model.groups].filter(g => g.name === TARGET_GROUP)
      const showVideo = inGroup.length ? true : false;
      component.setProperties({showVideo})
    }
  });
</script>

TARGET_GROUP をターゲットとするグループ名に変更し、動画の src 属性を追加してください。

この記事は長めになりました… 気にしないでください。概念を理解すれば、上記の作業はすべて 3〜5 分以内に完了します。

良い点は、ここで話したことはほぼすべての plugin outlet に当てはまることです。変わるのは名前だけです。つまり、将来行うあらゆる plugin-outlet の修正に応用できます。

  1. outlet 名を見つける
  2. データを取得する
  3. connector でデータを処理する
  4. プロパティをテンプレートに渡す