Mastodon フロントエンド改造入門

こんにちは! theboss.tech というインスタンスを運営している @the_boss です。

これは Mastodon Advent Calendar 2017 の 17 日目の記事です。昨日は にし さんの まちトドンの7ヶ月とこれから でした。

この記事では、 Mastodon インスタンスに独自機能を追加してみたいという方に向けて、フロントエンドへの機能追加の実例と、 Mastodon で使われている関連技術を紹介します。

はじめに

Mastodon は、 Rails と Node.js によるサーバサイドと、 JavaScript (React, Redux) によるフロントエンドという構成になっています。

筆者の印象では、サーバサイドは連合や認証などの仕組みが難しく、気軽に変な改造をできる雰囲気ではなさそうです。一方、フロントエンドはブラウザ上で動く範囲で好きにでき、変更が見た目にわかりやすいので、こちらから入るのが簡単だと思います (ただしスマホアプリ等には改造が反映されませんが…) 。

ここでは、フロントエンドへの機能追加の方法を解説します。また、インスタンスを運用していない方でもできるよう「どこかのインスタンスのソースコードを改造してプルリクエストを出す」ことを想定して記述します。筆者のインスタンス theboss.tech はプルリク歓迎です!

前提となる知識

  • JavaScript, HTML の基本的な知識 (もしくは他のプログラミング言語の経験)
  • Git, GitHub の基本的な使い方
    • clone, add, commit, pull, push あたりが使えると良いです。

開発環境の準備

まずは GitHub 上で、改造したいインスタンスをフォークしてリポジトリを作成してください。リポジトリができたら clone しましょう。

GitHub Fork button

図: GitHub の Fork ボタン (画面は theboss/mastodon のページ)

clone したら、 Development guide に従って開発に必要なライブラリをインストールし、 Mastodon を起動してください。なお起動手順に、 bundle exec rails server./bin/webpack-dev-server を実行、もしくは “Another, optional approach” として foreman を使う方法が書かれていますが、筆者はいつも後者の方法で起動しています。

Windows で Rails を動かすのは何かと面倒なので(※)、 Windows をお使いならば VirtualBox で Linux 環境を入れてしまうことをお勧めします。 Ubuntu 日本語 Remix イメージ をインストールするのが簡単でしょう。

(※) nokogiri のような native ライブラリを含む gem 周りでハマることが多い気がする

スタンプ機能の追加

サーバを起動でき、ブラウザで表示が確認できたら、機能を追加してみましょう。簡単な機能として、トゥートの「5000兆円」という文字列をスタンプ画像化してみます(※)。

(※) インスタンス knzk.me (通称: 神崎丼) が実装したことで知られる機能です。オリジナル版はアニメーションしたりしますが、今回は簡易版として普通の画像にします。

Web 画面の表示処理のうち、 emoji.js というファイルに絵文字関連の処理があり、 shortcode を絵文字画像に変換するなどが行われています。スタンプ化処理を入れるのに丁度良さそうです。

/app/javascript/mastodon/features/emoji/emoji.js

1
2
3
4
5
6
7
8
9
const emojify = (str, customEmojis = {}) => {
const tagCharsWithoutEmojis = '<&';
/* ... */
return rtn + str;
};
/* ... */
export default emojify;

/* ... */ は省略箇所

emojify という処理が文字列を return しています。ここで文字列を img タグに置換してみましょう。

1
2
3
4
5
6
7
8
9
const emojify = (str, customEmojis = {}) => {
const tagCharsWithoutEmojis = '<&';
/* ... */
return (rtn + str).replace(/5000兆円/g, '<img src="/stamps/5000chouen.png" style="height: 2em;" />');
};
/* ... */
export default emojify;

src に指定した/stamps/5000chouen.png は、 /public 配下のファイルが参照されます。 stamps ディレクトリを作成し、画像ファイルを置きましょう。

/public/stamps/5000chouen.png

5000兆円スタンプ

図: 5000兆円スタンプ (画像は 5000兆円ジェネレーター super で生成したもの)

ブラウザで「5000兆円」を含む文字列をトゥートし、反映されていることを確認しましょう (ファイルの変更を検知して自動的にブラウザがリロードされます) 。

5000兆円スタンプトゥート

図: スタンプ機能の完成図

これでスタンプ機能が実装できました。ベースラインがちょっとズレてますがまあ良いでしょう。

Mastodon フロントエンドの関連技術

Mastodon フロントエンドで使われている技術の概要と、それを学ぶためのドキュメントを見ていきましょう。

ES2015 (ECMAScript2015, 通称 ES6)

JavaScript の構文は ECMAScript として標準化されており、 ES2015 (ECMAScript2015, 通称 ES6) というバージョンで新たな構文がたくさん追加されました。以前の仕様は区別して ES5 と呼ばれています。 2015 以降は年次リリースとなり、 ES2016 (ES7), ES2017 (ES8) とリリースされています。

Mastodon では新しい構文が積極的に使われており、 React や Redux のサンプルコードでもよく登場します。特に以下の構文はよく目にします。

  • アロー関数
    • 関数を (引数) => { return ... } と記述する
    • 引数が 1 個の場合、丸括弧を省略して 引数 => { return ... } と記述できる
    • 本文の波括弧を省略して x => x * 2 と書くと、暗黙的に return となる (簡潔文体と呼ぶ)
    • 簡潔文体でオブジェクトリテラルを記述する場合は value => ({ key: value }) のように丸括弧で囲む
  • デフォルト引数
    • (a, b = 1) => a * b と書くと、第二引数を指定しない場合にデフォルト値が使われる
  • 簡略表現プロパティ名 (Shorthand property names)
    • オブジェクトリテラルにおいて { a } と書くと、キーが a で、値は変数 a の内容となる
  • スプレッド演算子
    • { ...obj, key: value } と書くと、 obj の内容の参照をコピーしつつ新たなオブジェクトを生成する
    • オブジェクトリテラル以外に、配列や関数の引数にも使える
  • テンプレート文字列
    • ダブルクオートやシングルクオートの代わりにバッククオート ` で文字列を囲うと、変数の展開などが行われる
  • importexport
    • 他の JS ファイルを読み込む構文

これら以外に、古くからある構文ですが、三項演算子もよく登場します。

なお、各ブラウザは ES2015 以降に完全対応しているわけではないため、これらの文法をフルに使いたい場合は Babel というライブラリでソースコードを変換 (トランスパイル) します。 Mastodon でも Babel が使われています。

React

GUI を構築するための JavaScript ライブラリです。 公式のチュートリアル を一通り読みながらサンプルを実装すると、 React の基本を習得できます。 CodePen (ブラウザでソース編集・実行できるサイト) にサンプルコードが用意されているので、環境用意の手間も省けます。

筆者は英語が苦手なので、日本語の解説記事も読んだり、 Chrome の Google 翻訳拡張 を併用して読んでいました。

Redux

GUI の状態を管理し、状態変化の流れを規定するライブラリです。 React 同様、 公式のチュートリアル がおすすめです。こちらは CodeSandbox というサイトにあるサンプルコード で練習できます。

なお、チュートリアルの説明と完成版の Todos アプリでソースがちょっと違う (説明には無い todo.id が登場するなど) ので注意が必要です。

同ドキュメントには Mastodon で使われているその他のライブラリとの組み合わせも解説されており、 redux-thunk, Reselect, Immutable.JS の章も参考になります。

余談: JavaScript の行末セミコロン

ES2015 以降のコーディングスタイルで行末セミコロンを使うべきか/省略すべきかという議論があり、派閥が分かれているようです。前述の React ドキュメントではセミコロンが使われ、 Redux ドキュメントでは省略されています。 Mastodon はセミコロン使用派のようで、省略すると ESLint でエラーになります。

翻訳機能の追加

続いて、もう少し複雑な処理として、トゥートの翻訳機能を追加してみましょう。

英語のトゥートが流れてきたときに、メニューから翻訳を選ぶと、英→日翻訳した結果が表示されるような機能を考えてみます。翻訳処理は無料で利用できる Microsoft Translator Text API を使うことにします。 (Google Translation API の方が和訳の精度が良いですが、有料になってしまうため)

Translator Text API の準備

Microsoft Cognitive Services のドキュメントに従って、 Azure アカウント登録、サブスクリプション作成を済ませましょう。 subscription key という 32 文字のキーが得られれば OK です。

以下のようなコマンドで動作を確認してください。

1
curl -H 'Ocp-Apim-Subscription-Key: YOUR_SUBSCRIPTION_KEY' 'https://api.microsofttranslator.com/V2/Http.svc/Translate?text=hello&to=ja'

コマンド実行後、 <string xmlns="http://schemas.microsoft.com/2003/10/Serialization/">こんにちは</string> と返ってくれば OK です。

メニューへの項目追加

まず目に見えるところの変更として、メニューへの項目追加から行いましょう。

status_action_bar.js というファイルがタイムライン上のメニューボタン (••• のアイコン) の処理になっています。ここに翻訳メニューを追加しましょう。

ここからは変更差分を unified diff 形式で掲載します。

/app/javascript/mastodon/components/status_action_bar.js

1
2
3
4
5
6
7
const messages = defineMessages({
delete: { id: 'status.delete', defaultMessage: 'Delete' },
/* ... */
embed: { id: 'status.embed', defaultMessage: 'Embed' },
+ translate: { id: 'status.translate', defaultMessage: 'Translate' },
decode_naraku: { id: 'status.decode_naraku', defaultMessage: 'Decode Naraku-moji' },
});
1
2
3
4
5
6
7
static propTypes = {
status: ImmutablePropTypes.map.isRequired,
/* ... */
intl: PropTypes.object.isRequired,
+ onTranslate: PropTypes.func,
onDecodeNaraku: PropTypes.func,
};
1
2
3
4
5
6
7
8
9
10
11
handleOpen = () => {
this.context.router.history.push(`/statuses/${this.props.status.get('id')}`);
}
+ handleTranslate = () => {
+ this.props.onTranslate(this.props.status);
+ }
+
handleDecodeNaraku = () => {
this.props.onDecodeNaraku(this.props.status);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
render () {
const { status, intl, withDismiss } = this.props;
/* ... */
menu.push({ text: intl.formatMessage(messages.open), action: this.handleOpen });
+ menu.push({ text: intl.formatMessage(messages.translate), action: this.handleTranslate });
+
menu.push({ text: intl.formatMessage(messages.decode_naraku), action: this.handleDecodeNaraku });
if (publicStatus) {
menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed });
}

下から見ていきます。 render メソッドの中で menu.push の処理を追加しています。これにより「詳細を開く」の直後にメニューが追加されます。この追加に合わせて、上の messages, propTypes, handleTranslate を追加しています。

decode_naraku奈落文字解読 という別の改造です。

status.translate というメッセージ ID が、画面に表示される文言の ID になります。メッセージファイル ja.json に追加します。

/app/javascript/mastodon/locales/ja.json

1
2
3
"status.show_more": "もっと見る",
+ "status.translate": "翻訳",
"status.unmute_conversation": "会話のミュートを解除",

ここまでの変更を画面で確認してみましょう。メニューに「翻訳」が表示されました。

翻訳メニュー

図: 翻訳メニュー追加後

なお、この時点ではクリックしてもエラーになります (ブラウザの「デベロッパーツール」でコンソールを開き、メニューをクリックして、エラーメッセージを確認してみてください) 。

トゥート詳細表示時のメニューボタンは

/app/javascript/mastodon/features/status/components/action_bar.js

にあります。こちらにも同様の追加を行いましょう (変更内容はほぼ同じのため割愛) 。

翻訳処理の実装

翻訳処理を実装するにあたり、考慮すべきことがあります。

フロントエンドで翻訳 API を呼ぶのは、ブラウザの同一オリジンポリシーの制約により不可能です。仮に CORS が使えたとしても、サブスクリプションキーをクライアントサイドに持たせることになってしまいます(※)。

このため、サーバサイドに翻訳 API を呼び出す API を実装し、フロントエンドからはそれを呼び出すことにします。

(※) 筆者が知らないだけで、これらの問題は Microsoft Azure の設定により回避可能かもしれません。

サーバサイド

サーバサイドに API を追加します。 routes.rb に、翻訳 API のルートを追加しましょう。

/config/routes.rb

1
2
3
4
5
6
7
8
9
# JSON / REST API
namespace :v1 do
resources :statuses, only: [:create, :show, :destroy] do
scope module: :statuses do
# ...
post :unpin, to: 'pins#destroy'
+
+ resources :translate, only: :index
end

これにより /api/v1/statuses/:id/translate というパスで API が呼べるようになります。

次に、このパスにアクセスした時に実行されるコントローラを実装します。サーバサイドとはいえ、 DB の更新や、連合に関わる処理は持たないため、複雑なことはありません。他の statuses 系のコントローラを真似て実装しましょう。

/app/controllers/api/v1/statuses/translate_controller.rb (新規ファイル)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# frozen_string_literal: true
require 'http'
require 'nokogiri'
class Api::V1::Statuses::TranslateController < Api::BaseController
before_action -> { doorkeeper_authorize! :read }
before_action :require_user!
respond_to :json
def index
@status = requested_status
res = HTTP.headers('Ocp-Apim-Subscription-Key': 'YOUR_SUBSCRIPTION_KEY')
.get("https://api.microsofttranslator.com/V2/Http.svc/Translate", params: {text: @status.text, to: 'ja'})
@status.text << ' / ' + Nokogiri::XML.parse(res.to_s).text
render json: @status, serializer: REST::StatusSerializer
end
private
def requested_status
Status.find(params[:status_id])
end
end

この処理では、渡された status_id で DB からトゥートを取得し、翻訳 API の結果をトゥートの text に “元のテキスト / 翻訳後のテキスト” となるよう追記しています。レスポンスとして、トゥートの情報を JSON で返します。

ここではサブスクリプションキーをソース上に記述してしまっていますが、実際には .env などのファイルに記述して、コンフィグとして読み込むべきでしょう。また、和訳に固定となっていますが、ログイン中のユーザのロケールに合わせるなどするとより良さそうです。今回は説明の都合上、この状態で進めます。

フロントエンド

サーバの API を呼び出すため、非同期的に処理を実行する必要があります。処理の流れは以下のようになります。

  • container, component 側でメニューがクリックされたら actiondispatch する
  • action 側は、以下のような関数を返す
    • リクエスト開始アクションを dispatch してから非同期的に翻訳 API を呼び出す
    • レスポンスが返ってきたらトゥート情報をもつアクションを dispatch する
  • reducer は、アクションからトゥート情報を取り出し、画面を更新する

ややこしいですが、周りの処理を真似しながら追加してみましょう。

修正が必要なのは以下の 4 ファイルです。 container, component, action, reducer という Redux の標準的な要素です。

  • /app/javascript/mastodon/containers/status_container.js
  • /app/javascript/mastodon/features/status/index.js
  • /app/javascript/mastodon/actions/interactions.js
  • /app/javascript/mastodon/reducers/statuses.js

まずは、 container である status_container.js と、 component である index.js です。これらは、 container がタイムライン表示の処理、 component がトゥート詳細表示の処理となっています。

/app/javascript/mastodon/containers/status_container.js

1
2
3
4
5
6
7
8
9
10
import {
reblog,
favourite,
unreblog,
unfavourite,
pin,
unpin,
+ translate,
decodeNaraku,
} from '../actions/interactions';
1
2
3
4
5
6
7
8
9
10
11
12
13
onEmbed (status) {
dispatch(openModal('EMBED', { url: status.get('url') }));
},
+ onTranslate (status) {
+ dispatch(translate(status));
+ },
+
onDecodeNaraku (status) {
dispatch(decodeNaraku(status));
},
onDelete (status) {

/app/javascript/mastodon/features/status/index.js

1
2
3
4
5
6
7
8
9
10
import {
favourite,
unfavourite,
reblog,
unreblog,
pin,
unpin,
+ translate,
decodeNaraku,
} from '../../actions/interactions';
1
2
3
4
5
6
7
8
9
10
11
12
13
handleEmbed = (status) => {
this.props.dispatch(openModal('EMBED', { url: status.get('url') }));
}
+ handleTranslate = (status) => {
+ this.props.dispatch(translate(status));
+ }
+
handleDecodeNaraku = (status) => {
this.props.dispatch(decodeNaraku(status));
}
handleHotkeyMoveUp = () => {
1
2
3
4
5
6
7
8
9
10
11
12
13
<ActionBar
status={status}
onReply={this.handleReplyClick}
onFavourite={this.handleFavouriteClick}
onReblog={this.handleReblogClick}
onDelete={this.handleDeleteClick}
onMention={this.handleMentionClick}
onReport={this.handleReport}
onPin={this.handlePin}
onEmbed={this.handleEmbed}
+ onTranslate={this.handleTranslate}
onDecodeNaraku={this.handleDecodeNaraku}
/>

いずれも、翻訳処理が呼ばれた際に dispatch(translate(status)) するメソッドを追加しています。 translate が、この後 action で定義する関数です。

action には、 3 種類の action type と、それに対応する action creator 関数、それらを呼び出す async action creator 関数を追加します。

/app/javascript/mastodon/actions/interactions.js

1
2
3
4
5
6
7
8
9
export const UNPIN_FAIL = 'UNPIN_FAIL';
+export const TRANSLATE_REQUEST = 'TRANSLATE_REQUEST';
+export const TRANSLATE_SUCCESS = 'TRANSLATE_SUCCESS';
+export const TRANSLATE_FAIL = 'TRANSLATE_FAIL';
+
export const DECODE_NARAKU = 'DECODE_NARAKU';
export function reblog(status) {
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
export function unpinFail(status, error) {
return {
type: UNPIN_FAIL,
status,
error,
};
};
+export function translate(status) {
+ return (dispatch, getState) => {
+ dispatch(translateRequest(status));
+
+ api(getState).get(`/api/v1/statuses/${status.get('id')}/translate`).then(response => {
+ dispatch(translateSuccess(response.data));
+ }).catch(error => {
+ dispatch(translateFail(error));
+ });
+ };
+};
+
+export function translateRequest(status) {
+ return {
+ type: TRANSLATE_REQUEST,
+ status,
+ };
+};
+
+export function translateSuccess(status) {
+ return {
+ type: TRANSLATE_SUCCESS,
+ status,
+ };
+};
+
+export function translateFail(error) {
+ return {
+ type: TRANSLATE_FAIL,
+ error,
+ };
+};

translate という関数が async action creator です。先に述べた「リクエスト開始 actiondispatch して、非同期的に API を呼び出し、完了後にレスポンスをもつ actiondispatch する」という関数を返しています。

3 つの action creator 関数は、 status または error を引数にとり、 action にセットして返すという、既存の処理 favouritereblog などと共通の構造です。これは、 XXX_REQUESTXXX_FAIL といった action type を見て、ロード中のプログレスバー表示をしたりエラー表示をする Redux Middleware が存在するためです。

最後に reducer です。

/app/javascript/mastodon/reducers/statuses.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import {
REBLOG_REQUEST,
REBLOG_SUCCESS,
REBLOG_FAIL,
UNREBLOG_SUCCESS,
FAVOURITE_REQUEST,
FAVOURITE_SUCCESS,
FAVOURITE_FAIL,
UNFAVOURITE_SUCCESS,
PIN_SUCCESS,
UNPIN_SUCCESS,
+ TRANSLATE_SUCCESS,
DECODE_NARAKU,
} from '../actions/interactions';
1
2
3
4
5
6
7
8
case TIMELINE_DELETE:
return deleteStatus(state, action.id, action.references);
+ case TRANSLATE_SUCCESS:
+ return normalizeStatus(state, action.status);
case DECODE_NARAKU:
return decodeNaraku(state, action.status);
default:
return state;

TRANSLATE_SUCCESS という action type が来た時に normalizeStatus を呼ぶだけです。これを呼ぶことにより、画面上のトゥートが更新されます。 action.status で渡ってくるのは、先ほど async action creator で取得した、 “元のテキスト / 翻訳後のテキスト” という状態のトゥートなので、これを呼ぶだけで良いわけです。

動作を確認してみましょう。適当な英文をトゥートして、メニューから翻訳を選んでみます。

翻訳の実行結果

図: 翻訳の実行結果

想定通りに “元のテキスト / 翻訳後のテキスト” と表示することができました。

改造をプルリクエストする

スタンプ化機能と翻訳機能を実装したので、フォーク元のインスタンスのリポジトリに取り込んでもらえるよう、プルリクエストを出しましょう。プルリクエストを出す際には、以下の点に注意する必要があります。

  • ひとつの機能につきひとつのプルリクエストとすること
  • 本家に入れることのない変な改造は、可能ならば別ファイルに切り出すこと
    • Ruby は良いのですが、 JavaScript の変更はうまく切り出せないことが多いです。諦めて気にしないことにしましょう。
  • テストが通るよう、テストコードの修正も含めること

プルリクエストを出すには、自分のリポジトリに push したのち、 GitHub のページ上で「Pull Request」をクリックし、その後の案内に従います。今回は説明の都合上、ブランチの作成などは解説しませんでしたが、実際には機能ごとにブランチを作成しつつ、元リポジトリの変更を適当なタイミングで取り込みながら開発すべきでしょう。

おわりに

Mastodon フロントエンドの改造について解説しました。 Mastodon のソースコードは筆者もまだ理解できていないことだらけですが、今後は積極的に変な改造や便利な改造をして、理解を深めていきたいと思っています。

インスタンスに独自の機能を持たせることについては、中央集権型 SNS への逆戻りを促進するため Mastodon の設計思想に反する、という意見もあるかもしれません。個人的な思いとしては、各ユーザがおのおの勝手に改造を楽しむ過程で、オリジナルの Mastodon に貢献できる機会がある可能性も無くはなく、やっていきましょうという気持ちでいます。

Mastodon Advent Calendar 2017 、明日は なきか さんの記事です。