マストドンカスタマイズ〜ユーザ絵文字〜

# ■はじめに
これは[フレカフェ＆きりたん丼合同 Advent Calendar 2019 16日目の記事](https://adventar.org/calendars/4470) です。

[前回](https://qiita.com/kiritan/items/bc43d73ca44e2e8a13bd)の続きっぽい感じのマストドンのカスタマイズのお話です。
今回はユーザ絵文字を使えるようにするやつをやります。

※ ユーザ絵文字とは、ここでは各ユーザのアイコンを絵文字のように使えるやつ、としています

ユーザ絵文字はfriends.nico（ニコフレ）で独自に実装してました。今ではbest-friends.chat（ベスフレ）をはじめユーザ絵文字を実装しているインスタンスがいくつかあります。うちではベスフレの実装を真似しているので、ベスフレ方式のユーザ絵文字対応方法について解説できたらいいな〜、と思いました。


# ■おおまかな変更概要
## ざっくりとした仕様
ユーザIDを:〜:で挟むと、ユーザアイコンが絵文字のように表示される感じです。

``` :@user_id: ``` → ![cooking_syouyu.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/252429/4b888bc7-c9df-8db1-0c88-ee3c3437da9c.png)

また、他インスタンスのユーザIDにも対応します。

``` :@user2_id@example.com: ``` → ![sushi_aburi_hotate.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/252429/a58b8d1a-b5f2-294c-1504-830c6cc50ff3.png)

## 絵文字が表示されるようになる大雑把な仕組み
投稿内容の中にある```:@user_id:```を見つけて、それをアイコンの画像urlを指定した```<img>```タグに置き換えるイメージです。

一方、マストドンでは標準でカスタム絵文字という各インスタンス独自の絵文字を使用することができます。カスタム絵文字は ```:hogehoge:``` のように:〜:で登録されたコードを挟むことで画像として表示（```<img>```タグに置き換え）されます。ユーザ絵文字でもこれと同じ仕組みを利用できそうです。

## ふんわり実装イメージ

### imgタグへ置き換える処理
カスタム絵文字の```:hogehoge:``` を```<img>```タグに置き換える処理は、フロント側（javascript※）で行っているので、ユーザ絵文字も同じところでやります。

※ javascriptを使わないページでは、サーバ側で```<img>```タグに置き換えたhtmlを生成して配信しています

### 画像のURLの取得方法
アイコン画像のURLは、サーバ側でセットしてフロント側に返す感じです。
フロントとサーバ側のやり取りはAPIを通してJSON形式のデータでやり取りされます。

次のトゥート例ではカスタム絵文字を使用しています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/252429/77a5262e-1655-96fc-75ac-341f0a2c1e6b.png)

この中身は以下のような感じのJSON形式データになっています。（一部省略）
このJSONデータがサーバ→フロントに渡されています。

```status.json
{
    "id": "103277770631028865",
    "created_at": "2019-12-09T12:15:16.912Z",
    "content": "<p>っ :meito_nameraka_purin: どうぞ〜</p>",
    "application": {
        "name": "Web",
        "website": null
    },
    "emojis": [
        {
            "shortcode": "meito_nameraka_purin",
            "url": "https://kiritan.work/system/custom_emojis/images/000/010/846/original/290b23c60711bb9b.png?1558692688",
            "static_url": "https://kiritan.work/system/custom_emojis/images/000/010/846/static/290b23c60711bb9b.png?1558692688",
            "visible_in_picker": true,
        }
    ]
}
```

”emojis" にコード値と画像URL（static_urlはアニメーションoff時に使われる静止画像）が格納されているのがわかります。フロント側は、:コード値: となっている部分を探して<img>タグで置き換えているようです。

# ■実際の変更内容(v3.0.1ベース）
本家とベスフレのdiffをベースに変更内容を見ていきます。
裏方（サーバ側）→表舞台（フロント側）の順に、[MVC](https://www.javadrive.jp/rails/ini/index7.html)的に言うと、**M**odel→**V**iew の順に見ていくことにします。（今回は**C**ontrollerの変更はなさそうです）

## Model の修正

ユーザ絵文字対応に関係のない修正箇所は省略しています。

```diff

diff --git a/app/models/status.rb b/app/models/status.rb
index 0c01a5389..91848fc85 100644
--- a/app/models/status.rb
+++ b/app/models/status.rb
@@ -486,4 +486,6 @@ class Status < ApplicationRecord
       AccountConversation.remove_status(inbox_owner, self)
     end
   end
+
+  include Friends::ProfileEmoji::StatusExtension
 end
```

```diff

diff --git a/app/lib/friends/profile_emoji/status_extension.rb b/app/lib/friends/profile_emoji/status_extension.rb
new file mode 100644
index 000000000..8770c60fa
--- /dev/null
+++ b/app/lib/friends/profile_emoji/status_extension.rb
@@ -0,0 +1,20 @@
+module Friends
+  module ProfileEmoji
+    module StatusExtension
+      extend ActiveSupport::Concern
+
+      def profile_emojis
+        return @profile_emojis if defined?(@profile_emojis)
+
+        fields = [spoiler_text, text]
+        fields += preloadable_poll.options unless preloadable_poll.nil?
+
+        @profile_emojis = Friends::ProfileEmoji::Emoji.from_text(fields.join(' '), account.domain)
+      end
+
+      def all_emojis
+        emojis + profile_emojis
+      end
+    end
+  end
+end
```

※ ```Friends::ProfileEmoji::Emoji```はテキスト情報に含まれるユーザ絵文字に対応するアカウント情報を検索し、返却するような感じのやつです。

- データベースのテーブルの変更はなさそうです。（マイグレーション処理不要）
- ```status```model（トゥート内容を保持するモデル）を拡張して、```profile_emojis ``` と ``` all_emojis ``` を追加しています。
- ``` profile_emojis ``` は、```status``` modelに保持しているテキスト情報（トゥート内容やタイトル等）に含まれるユーザ絵文字```:@user_id:```に指定されてるアカウントの情報を保持します。
- ``` all_emojis ``` は、従来から持っているカスタム絵文字```emojis```の情報とユーザ絵文字情報を合わせて保持します。こうしておけば、フロント側で```emojis```を参照している箇所を```all_emojis```に変更するだけで、ユーザ絵文字を同じ仕組みで画像表示できるようになるはずです。

これでトゥート内容に関するテキスト部分のユーザ絵文字対応ができました。
続いて、アカウント情報（```account```model）や投票内容（```poll```model）にも同様に```profile_emojis ``` と ``` all_emojis ``` を追加して、プロフィールや表示名、投票内容もユーザ絵文字に対応します。

```diff

diff --git a/app/models/account.rb b/app/models/account.rb
index 05936def3..aaea30e25 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -50,7 +50,9 @@
 
 class Account < ApplicationRecord
   USERNAME_RE = /[a-z0-9_]+([a-z0-9_\.-]+[a-z0-9_]+)?/i
-  MENTION_RE  = /(?<=^|[^\/[:word:]])@((#{USERNAME_RE})(?:@[a-z0-9\.\-]+[a-z0-9]+)?)/i
+  MENTION_RE  = /(?<=^|[^\/[:word:]:])@((#{USERNAME_RE})(?:@[a-z0-9\.\-]+[a-z0-9]+)?)/i
 
   scope :remote, -> { where.not(domain: nil) }
@@ -530,4 +532,6 @@ class Account < ApplicationRecord
       end
     end
   end
+
+  include Friends::ProfileEmoji::AccountExtension
 end
```

```diff

diff --git a/app/lib/friends/profile_emoji/account_extension.rb b/app/lib/friends/profile_emoji/account_extension.rb
new file mode 100644
index 000000000..5dddae2a5
--- /dev/null
+++ b/app/lib/friends/profile_emoji/account_extension.rb
@@ -0,0 +1,25 @@
+module Friends
+  module ProfileEmoji
+    module AccountExtension
+      extend ActiveSupport::Concern
+
+      included do
+        after_commit :clear_avatar_cache
+      end
+
+      def profile_emojis
+        @profile_emojis ||= Friends::ProfileEmoji::Emoji.from_text(emojifiable_text, domain)
+      end
+
+      def all_emojis
+        emojis + profile_emojis
+      end
+
+      private
+
+      def clear_avatar_cache
+        EntityCache.instance.clear_avatar(username, domain)
+      end
+    end
+  end
+end
```

```diff

diff --git a/app/models/poll.rb b/app/models/poll.rb
index 5427368fd..b2d01bc8f 100644
--- a/app/models/poll.rb
+++ b/app/models/poll.rb
@@ -110,4 +110,6 @@ class Poll < ApplicationRecord
   def show_totals_now?
     expired? || !hide_totals?
   end
+
+  include Friends::ProfileEmoji::PollExtension
 end
```

```diff

diff --git a/app/lib/friends/profile_emoji/poll_extension.rb b/app/lib/friends/profile_emoji/poll_extension.rb
new file mode 100644
index 000000000..9a8b31566
--- /dev/null
+++ b/app/lib/friends/profile_emoji/poll_extension.rb
@@ -0,0 +1,15 @@
+module Friends
+  module ProfileEmoji
+    module PollExtension
+      extend ActiveSupport::Concern
+
+      def profile_emojis
+        @profile_emojis ||= Friends::ProfileEmoji::Emoji.from_text(options.join(' '), account.domain)
+      end
+
+      def all_emojis
+        emojis + profile_emojis
+      end
+    end
+  end
+end
```

Modelの修正に続いて、Serializerの修正があります（SerializerはModelをJSON形式に変換するための仕組み）
Modelに追加した```profile_emojis```、```all_emojis```に既存のカスタム絵文字用のSerializer（REST::CustomEmojiSerializer）を指定するだけです。（詳細は割愛）

これで、APIが返すJSONデータに、ユーザ絵文字変換に必要な情報が追加されたことになります。

```status.json
{
    "id": "103308550471532911",
    "created_at": "2019-12-14T22:43:00.010Z",
    "content": "<p>:@kiritan: きりたん :mercari:<br />おはよ〜！ ( •᷄ὤ•᷅)و<br /> <a href=\"https://kiritan.work/tags/%E6%8C%A8%E6%8B%B6%E9%83%A8\" class=\"mention hashtag\" rel=\"tag\">#<span>挨拶部</span></a></p>",
    "application": {
        "name": "kiribot",
        "website": ""
    },
    "emojis": [
        {
            "shortcode": "mercari",
            "url": "https://kiritan.work/system/custom_emojis/images/000/020/285/original/03a152e179439a7b.png?1575632603",
            "static_url": "https://kiritan.work/system/custom_emojis/images/000/020/285/static/03a152e179439a7b.png?1575632603",
            "visible_in_picker": true,
        }
    ],
    "profile_emojis": [
        {
            "shortcode": "@kiritan",
            "url": "https://kiritan.work/system/accounts/avatars/000/000/001/original/6d1ad72417892d85.png",
            "static_url": "https://kiritan.work/system/accounts/avatars/000/000/001/original/6d1ad72417892d85.png",
            "visible_in_picker": false,
        }
    ],
    "all_emojis": [
        {
            "shortcode": "mercari",
            "url": "https://kiritan.work/system/custom_emojis/images/000/020/285/original/03a152e179439a7b.png?1575632603",
            "static_url": "https://kiritan.work/system/custom_emojis/images/000/020/285/static/03a152e179439a7b.png?1575632603",
            "visible_in_picker": true,
        },
        {
            "shortcode": "@kiritan",
            "url": "https://kiritan.work/system/accounts/avatars/000/000/001/original/6d1ad72417892d85.png",
            "static_url": "https://kiritan.work/system/accounts/avatars/000/000/001/original/6d1ad72417892d85.png",
            "visible_in_picker": false,
        }
    ]
}
```

## View側の修正
あとはJSONデータを受け取ったフロント側で```<img>```タグに置き換える処理を修正すればいいだけです。
ここではjavascript部分の説明をします。

```diff
diff --git a/app/javascript/mastodon/actions/importer/normalizer.js b/app/javascript/mastodon/actions/importer/normalizer.js
index f7108fdb9..f31f72a98 100644
--- a/app/javascript/mastodon/actions/importer/normalizer.js
+++ b/app/javascript/mastodon/actions/importer/normalizer.js
@@ -5,7 +5,7 @@ import { expandSpoilers } from '../../initial_state';
 
 const domParser = new DOMParser();
 
-const makeEmojiMap = record => record.emojis.reduce((obj, emoji) => {
+const makeEmojiMap = record => record.all_emojis.reduce((obj, emoji) => {
   obj[`:${emoji.shortcode}:`] = emoji;
   return obj;
 }, {});
diff --git a/app/javascript/mastodon/components/poll.js b/app/javascript/mastodon/components/poll.js
index cdbcf8f70..61ac7ed0e 100644
--- a/app/javascript/mastodon/components/poll.js
+++ b/app/javascript/mastodon/components/poll.js
@@ -17,7 +17,7 @@ const messages = defineMessages({
   voted: { id: 'poll.voted', defaultMessage: 'You voted for this answer', description: 'Tooltip of the "voted" checkmark in polls' },
 });
 
-const makeEmojiMap = record => record.get('emojis').reduce((obj, emoji) => {
+const makeEmojiMap = record => record.get('all_emojis').reduce((obj, emoji) => {
   obj[`:${emoji.get('shortcode')}:`] = emoji.toJS();
   return obj;
 }, {});
```

既存のカスタム絵文字を表示する仕組みをそのまま利用し、JSONデータの```emojis```を参照している箇所を```all_emojis```に変更するだけです。（アカウント情報とトゥート内容部分は```normalizer.js```で共通化されているので１箇所、投票内容は```poll.js```に記述されているのでその１箇所、計２箇所の修正）

これで、カスタム絵文字と同様にユーザ絵文字も画像に置き換わって表示されるはずです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/252429/9c8441a1-6fbc-77cb-57fa-220bdd264668.png)


※ javascriptを使わないページ（アカウントやトゥートの詳細表示みたいな画面のやつ）は、サーバ側（ruby on rails側）の ```/app/lib/formatter.rb```を同様に修正していますが、割愛します

### オートサジェストのユーザ絵文字対応
カスタム絵文字やタグ、メンション時の宛先入力時に候補が自動で出てくるやつ（オートサジェスト）がありますが、これもユーザ絵文字対応します。

```diff
diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js
index 8e7906c73..2f4366814 100644
--- a/app/javascript/mastodon/actions/compose.js
+++ b/app/javascript/mastodon/actions/compose.js
@@ -384,7 +384,11 @@ export function fetchComposeSuggestions(token) {
   return (dispatch, getState) => {
     switch (token[0]) {
     case ':':
-      fetchComposeSuggestionsEmojis(dispatch, getState, token);
+      if (token[1] == '@') {
+        fetchComposeSuggestionsAccounts(dispatch, getState, token.substr(1, token.length));
+      } else {
+        fetchComposeSuggestionsEmojis(dispatch, getState, token);
+      }
       break;
     case '#':
       fetchComposeSuggestionsTags(dispatch, getState, token);
@@ -433,6 +437,9 @@ export function selectComposeSuggestion(position, token, suggestion, path) {
     } else if (suggestion.type === 'account') {
       completion    = getState().getIn(['accounts', suggestion.id, 'acct']);
       startPosition = position;
+      if (token[0] == ':') {
+        completion  = `@${completion}:`;
+      }
     }
 
     dispatch({
```

前半の追加箇所では、```:@~~```と続いたらアカウント検索するように修正しています。
後半の追加箇所では、オートサジェストで選択した結果を反映する処理を修正しています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/252429/59dbe602-fafd-df8f-0b2e-77e705984cab.png)


# ■まとめ
公開予定日まで時間がなくなってきてしまったのと、不勉強のせいで駆け足＆説明不足等になってしまいました。
機会があればこの記事をちょこちょこ修正するかもしれません、というのと自分のインスタンスの独自機能の解説もできたらいいな〜と思いました。