Help us understand the problem. What is going on with this article?

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

■はじめに

これはフレカフェ&きりたん丼合同 Advent Calendar 2019 16日目の記事 です。

前回の続きっぽい感じのマストドンのカスタマイズのお話です。
今回はユーザ絵文字を使えるようにするやつをやります。

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

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

■おおまかな変更概要

ざっくりとした仕様

ユーザIDを:〜:で挟むと、ユーザアイコンが絵文字のように表示される感じです。

:@user_id:cooking_syouyu.png

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

:@user2_id@example.com:sushi_aburi_hotate.png

絵文字が表示されるようになる大雑把な仕組み

投稿内容の中にある:@user_id:を見つけて、それをアイコンの画像urlを指定した<img>タグに置き換えるイメージです。

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

ふんわり実装イメージ

imgタグへ置き換える処理

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

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

画像のURLの取得方法

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

次のトゥート例ではカスタム絵文字を使用しています。
image.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時に使われる静止画像)が格納されているのがわかります。フロント側は、:コード値: となっている部分を探してタグで置き換えているようです。

■実際の変更内容(v3.0.1ベース)

本家とベスフレのdiffをベースに変更内容を見ていきます。
裏方(サーバ側)→表舞台(フロント側)の順に、MVC的に言うと、Model→View の順に見ていくことにします。(今回はControllerの変更はなさそうです)

Model の修正

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

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 --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はテキスト情報に含まれるユーザ絵文字に対応するアカウント情報を検索し、返却するような感じのやつです。

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

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

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 --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 --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 --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_emojisall_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 --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で共通化されているので1箇所、投票内容はpoll.jsに記述されているのでその1箇所、計2箇所の修正)

これで、カスタム絵文字と同様にユーザ絵文字も画像に置き換わって表示されるはずです。
image.png

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

オートサジェストのユーザ絵文字対応

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

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

■まとめ

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

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした