🙌

Mastodonを改造してみた

2023/11/13に公開

1. はじめに


経緯

まず初めに今回Mastodonをいじってみることになった経緯を説明します。
私たちは、東京大学工学部電子情報工学科・電気電子工学科に所属する三人の大学三年生で、「大規模ソフトウェアを手探る」というテーマの実験で、オープンソースのソフトウェアである、Mastodonをいじってみることにしました。

概要

ざっくりとMastodonとはどのようなソフトウェアなのかについて説明します。簡単にいうとMastodonはTwitter(現在はX)と同じようなサービスです。

基本的な使い方はTwitterとほぼ同じで、500文字までの文章を投稿でき、他人の投稿に返信やお気に入り、ブックマークなどができます。またハッシュタグなども存在しており、機能としてはTwitterと遜色ありません。それではTwitterとなにが違うのでしょうか。
それはMastodonがオープンソースとして提供されており、各自が独自のサーバでインスタンスと呼ばれるネットワーク(自分のサーバで実現できる、小さいMastodonのネットワークみたいなイメージ)を作成できることです。
それによって、サーバが分散化され、一社が全サーバを握るTwitterと大きな違いを生んでいます。

それでは次章以降、Mastodonを実際にいじっていきます。

2. ビルド・デバッグの方法

今回のビルドではVScodeのdevcontainerの拡張機能を用いてビルドを行いました。
手順は以下のとおりです。

① Dockerのインストール

Dockerをまず簡単に説明すると、Mastodonを起動するためには仮想サーバーを立てる必要があります。今回後に明らかになりますが、Dockerを用いて仮想サーバーを立てることで簡単にMastodonを起動することができます。Dockerのインストールは以下のとおりです。

$sudo su -
$apt update
$apt install -y docker.io

と書くことでドッカーをインストールすることができます。このときにそのまま実行しようとすると、root権限が要求される可能性があります。その際には以下の通りに書いて実行してください。

sudo usermod -aG docker your_username
sudo systemctl restart docker

インストールしたらdockerが起動できているか確認してください。もし起動できていなければ起動してみましょう。
$sudo systemctl status docker
$sudo sysyemctl start docker

② mastodonのOSSのダウンロード

以上まで終わったらmastodonのオープンソースをダウンロードしてきましょう。Mastodonのオープンソースはgithubにおいてあります。リンクはこちら

ページの緑になっているボタンをクリックするとLocalとCodespaceの選択肢が出てきます。どちらでも共同作業をすることはできますが、今回はVScodeを用いるので、Localの方を選択し、HTTPSのところからリンクを取得します。githubにあるフォルダのように共同作業するためにおいてあるフォルダのことをリモートリポジトリといいます。対して、個人で作業するために自分のパソコンにダウンロードしてきたものをローカルリポジトリといいます。

作業している個人がすべてgithub上に存在するファイルに書き込んでしまうと、変更が干渉しあってしまうことが考えられるためこのように作業します。

以下に作業する際に出てくる用語を説明しておきます。

クローン(clone):リモートリポジトリからローカル(自分のパソコン)に持ってくること(ダウンロードとほぼ同じ)その際のコマンドは、

$git clone <ダウンロードするリンク>

先に緑のボタンをクリックして取得したリンクを使うとmastodonのOSSをローカルに持ってくることができます。

ステージ(stage):ローカルのファイルに変更を加えたときにどの変更をリモートリポジトリに上げるのか選ぶことができます。その際のコマンドが

$git add <選びたいファイル>

もしすべての変更をステージしたいのであれば<選びたいファイル>を「.」(コンマ)に置き換えると可能です。

コミット(commit):ステージしたいものをステージしたらその変更をコミットすることでリモートリポジトリにあげることができます。その際のコマンドは、

$git commit -m "好きなコメント"

しかしステージとコミットだけでは本体に変更を加えることになるのでもとに戻すことができなくなってしまいます。

ブランチ(branch):このときに登場するのがブランチというものです。例えばこのリンクを参考にしてください。本体のコピーのようなものを作成し、そこに変更を加えていきます。みんなの作業が終了したときにそれらのコンフリクトを解消しながら組み合わせて(マージして)いきます。

③ devcontainerでmastodonを立ち上げる。

まずVScodeが自分の環境に入っていることを確認してください。VScodeが入っていることを確認したらその拡張機能としてdevcontainerをインストールしてください。

devcontainerとは何か、よくわからない人に向けて少し説明しておきます。詳しくはここを参照してください。
devcontainerを用いる最大のメリットはバージョンの違いによってバグが起きないことだと思います。devcontainerで環境構築すると必要なものを勝手にダウンロードしてくれるので、手順がかなり単純化されます。

devcontainerの拡張機能をインストールしたら、mastodonのフォルダを開き、一番左下の青いボタンをクリックしてください

ここをクリックすると、

このようにReopen in devcontainerという選択肢が現れます。クリックするとそのフォルダがコンテナの中で開かれます。初めて構築するときは時間がかかるはずです。

ここまでできたらmastodonの構築は完成!あとは実際にサーバーを立ててログインしてみましょう。

devcontainerでフォルダを開くと写真のようなターミナルが現れるはずです。そこに

$foreman start

と打ってみましょう。エラーが出るかもしれません。その時は

$gem install foreman

と打ってみましょう。それからもう一度打ってみてください。サーバーが立つはずです。このとき、ポートのところから実際にブラウザを開くことができます。その際のIDとpasswordについては、

id: admin@localhost
pass: mastodonadmin

です。このパスワード等は変更できるのでもし開発したものを世の中に出すのであれば変更しておきましょう。これでmastodonの立ち上げは終了です!

続いてデバッグのやり方を紹介しておきます。先に言っておくと、mastodonはフロントエンド、バックエンド、データベースから成り立っており、デバッグはそのうちのフロントエンドの動きについてのみ観察することができます。なのでデバッグをしても裏で何が起こっているのか知ることはできません。もし知りたくなったら、自分でコードを漁ってみてください。

VScodeを見ると左の方に再生ボタンのようなものがあります。それをクリックすると

ここに「launch.jsonファイルを作成します。」という文字が出てくるはずです。これをクリックすると実際にファイルが作られます。その際にWebアプリの選択を要求されますがchromeを選択しておきましょう。すると

このようなファイルが現れます。そのurlの部分の番号、最初の時点では8000になっているかもしれませんがその数字を自分のサーバーのポート番号に変更してください。筆者の環境では3000と変更しました。すると左上に「localhostに対してchromeを起動する」と現れるのでそれをクリックしましょう。するとchromeが立ち上がるはずです。

起動ができたらコード中の怪しいところにブレイクポイントを設置しましょう。chrome上でやりたい操作をしたときにターミナル上で止まってくれるはずです。ブレイクポイントで止まったら1ステップずつ探索していくことが可能です。

デバッグについてはこれで以上です。これでフロントエンドの仕組みはわかるはずです。

3. Mastodonの構造

今回、全体を掴むというのはなかなか難しく、ほんの一部分にしか踏み込んでいけませんでしたが簡単にMastodonの構造でわかったことを紹介します。

mastodonに用いられている言語を並べると、
・Ruby
・Javascript
・Haml
・SCSS
・TypeScript
・HTML

以上のものが含まれています。(他にも含みきれていないものもあります)

今回初めて見たものについて少し書きます。

Haml:これは他にもerb(embedded Ruby)も同じような役割を担うものとして存在していますが、これはHTMLなどのテキストにRubyのスクリプトを埋め込むためのライブラリです。Hamlはerbよりもシンプルに書くことができますが、なれるのには少し時間がかかるかもしれません。

SCSS:名前の通り、CSSと近いものになっています。インターネットのページの色やレイアウトを決めるCSSをより手軽にかけるようにしたものです。mastodonのホーム画面についてもこれが用いられています。

Ruby on Rails:ファイルとは少しずれてしまいますが、これはWeb開発用のフレームワークで関数を定義する手間をなくしてくれます。より簡単に書けるようにしてくれている一方でファイル同士のつながりが見えづらくなってしまうように感じました。今回の実験でもこれによって関数を探るのが難しかった印象があります。また、Railsのおかげで開発ブラウザを開きながら、コードの更新が行われると自動的にリロードが行われ、コードの変更が反映されるようになっています。開発をしていく中ではとてもありがたい機能になっています。ここを参照しました。

react:Railsと同様にこれもフレームワークですがこれはjavascriptのものです。UIのパーツを作るためのライブラリとなっていて、ボタンやテキストボックスを多用するWebアプリケーションに有効なフレームワークと言われています。今回扱っているMastodonについても当てはまると言えます。ここを参照しました。

フロントエンドに関してはjavascriptによって書かれており、対してバックエンドはRuby on Railsで書かれています。

例えば、ボタンを定義する関数だったり、そのボタンがついたり消えたりするような表面的な部分についてはJavasciptで書かれています。しかしそのボタンを扱う上でそのボタンがついているのかついていないのかを知るためのデータをとってくる必要があります。さらには誰がそのスタンプを押しているのか合計するとどれくらいの人が押してくれているのかを知るためにはやはりデータを集めておく必要があります。

そのデータベースの処理を扱っているのがバックエンドの部分です。これはRubyによって書かれています。ボタンが押されたあとにRubyの関数が呼び出されて、データベースが書き換えられるということです。

またこのデータベースに関してはymlファイルによって保存されています。ymlファイルというのはtxtファイルに近いもので文字を保存しておいてくれるものですが、データの保存に使われることが多いものです。

これらを結びつけているのがapiであり、mastodonのフォルダの中にも'api.js'といったファイルが存在しています。

ファイルが収まっている領域についてわかる範囲で説明しておくと、基本的に大部分のソースコードはappフォルダに入っています。その中のjavascriptフォルダがありフロントの部分はここに集約されています。調べていくときはjavascriptからとっかかると始めやすいかもしれません。

その中を探ってみると、actionsというフォルダが見つかります。これを見ると基本的なmastodonの機能について知ることができます。今回扱ったスタンプについても'favourite.js'というファイルが存在しています。

ここにはfavouriteスタンプを押すときの基本的な関数が定義されてます。これらの関数がどこで使われているのか調べてみると、'status_lists.js'で使われていることがわかります。このファイル名を見てもわかるように、favouriteだけではなく様々なスタンプの情報が集約されてきていることがわかります。

ここにインポートされてきている関数やエクスポートしている関数を調べてみてもjavascriptのフォルダからなかなか出てくることができませんでした。つながりがほとんど見えません。javascript側からつながりを見つけることができなければ、Ruby側からつながりを探していくことにします。

appというフォルダの中にはserviceというフォルダがあります。怪しいので調べてみましょう。mastodonの機能を扱っているだろうRubyファイルが多く見つかります。例えば'account_search_service.rb'や'delete_account_service.rb'のようにファイルが見つかります。この中にfavorite_service.rbもあります。これらのファイルの中にはcall関数がそれぞれ定義されており、これらのrubyファイルが関係する中で呼び出しが発生しているのではないかと推測することができます。

'favourite_service.rb'のファイルを覗いていると'status_id'なるものが扱われています。これについて検索をかけてみると膨大な量の検索結果が現れますがファイルにすると146ファイルであり、重要そうな部分を探してみると、'schema.rb'というファイルが見つかります。

このファイルを覗いてみると英語でこのファイルはデータベースの現在の状況から自動的に更新されると書いてあり、さらにはrailsがスキーマを定義するために使用するソースとも書いています。データベースについて変更を加えると勝手に更新してくれるということです。つまり必要なものを更新しておけばここにも反映されるということで、ここに必要なものは定義されているようです。

このように収拾つかない状況になりますが、他のフォルダも覗いてみるとcomponentsというフォルダの中にも必要な要素が含まれており、機能を追加していく上で共通している部分。例えばブックマークもいいねボタンもすべて同じボタンとしての役割を持っています。テキストボックスも色んな場面で用いるのであって共通している部分を担うファイルがここに含まれています。

このように全貌が全くつかめないとしても少しではありますがなんとなくMastodonのコードを読み解くことができました。

4. 改善の方針

mastodonでは、サーバが分散しており、それぞれのサーバ内でのコミュニティは比較的小規模であるという観点に着目して、Google Slackのような機能を追加してみるとおもしろいのではないかと考え、具体的に今回の実験では以下の2つの機能を実装しようとしてみました。

  • 新しいスタンプをユーザが作成し、利用できるようにする
  • 投稿する文章中にリンクを埋め込むようにする

しかし、新しく画像をスタンプとして登録するという機能の実装は、データベースへのアクセスや変数の設定や、新しくreactのコンポーネントを定義し反映させるというところが難しく、時間がかかりそうだったので、既存のコンポーネントを用いて、今あるスタンプに加えて新たなスタンプを追加して押せるようにするのみの実装となりました。

5. スタンプ機能

変更を加える前のmastodonには、ある投稿に対してリアクションするには以下の写真のように返信、共有、お気に入り、ブックマークの機能があります。

変更する前のmastodonの投稿とリアクションボタン

初めの目標ではスタンプを自作して使えるようにすることを考えたが、難しそうだったので既存の関数を参考にしてグッドボタンとバッドボタンを追加してみました。

スタンプを押した時の挙動について

スタンプを追加する上で、まず初めにタイムライン上でお気に入り(星型)のスタンプをクリックした時にどのような挙動になるのか探索しました。以下ではお気に入り登録をした時の挙動や関数の呼び出しを追います。

フロントエンド

初めにreactで書かれたフロントエンドの挙動を追います。
探索する時は、手探りで関数を探したり、デバッガを用いたりしました。

まず、お気に入りボタンの表示はstatus_action_bar.jsx内で定義されており、

<IconButton className='status__action-bar__button star-icon' animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} counter={withCounters ? status.get('favourites_count') : undefined} />

上のような定義によって画面に表示されています。IconButtonというのは'icon_button.jsx'で定義された型である。この型ではFontAwesomeというサイトのアイコンを使用して、ボタンとしてのさまざまな動作を定義しています。status.get(favourited)には、バックエンドからjson形式で送られたデータでお気に入りかどうかを示すbool値が入り、ボタンがクリックされるとhandleFavouriteClick関数が呼び出されます。この関数は同じファイル内に定義されています。(以下)

handleFavouriteClick = () => {
const { signedIn } = this.context.identity;

if (signedIn) {
    this.props.onFavourite(this.props.status);
} else {
    this.props.onInteractionModal('favourite', this.props.status);
}
};

お気に入りに登録した時はは'status_container.jsx'で定義されたonFavourite関数が呼び出されます。

この関数では、お気に入りの状態であればunfavourite関数を、そうでなければfavourite関数を呼び出し、クリックするたびにオンオフを切り替えています。お気に入り登録する場合はfavourite関数が呼び出されます。
favourite関数は'interaction.js'で定義され、以下のような内容です。

export function favourite(status) {
  return function (dispatch, getState) {
    dispatch(favouriteRequest(status));
    api(getState).post(`/api/v1/statuses/${status.get('id')}/favourite`).
      then(function (response) {
	dispatch(importFetchedStatus(response.data));
	dispatch(favouriteSuccess(status));
      }).catch(function (error) {
	dispatch(favouriteFail(status, error));
      });
  };
}

簡単に説明すると、このfavourite関数はバックエンドにお気に入り登録をするという情報をパスを指定して送り、バックエンドの関数を呼び出して、その返り値を受け取って処理するというものです。ちなみに、unfavourite関数も同じファイルで定義され、これはお気に入りを削除するようにバックエンドの関数を呼び出す関数です。

バックエンドの関数を呼び出す際はさらに'api.rb'というファイルに書かれているようにパスを変換して読み出しています。api.rbの今回関連する部分を抜き出すと以下のようになっています。

namespace :api, format: false do
  # OEmbed
  get '/oembed', to: 'oembed#show', as: :oembed

  # JSON / REST API
  namespace :v1 do
    resources :statuses, only: [:create, :show, :update, :destroy] do
      scope module: :statuses do
        resource :favourite, only: :create

このルーティングによってフロントエンドのfavourite関数は、'favourites_controller.rb'に書かれたApi::V1::FavouritesControllerクラスのcreate関数というバックエンドの関数を呼び出します。

バックエンド

ここからはバックエンドの関連するコードを示します。

フロントエンドからApi::V1::FavouritesControllerクラスのcreate関数を呼び出されます。関数の内容は以下のようです。

def create
    set_status
    FavouriteService.new.call(current_account, @status)
    render json: @status, serializer: REST::StatusSerializer
end

関数内の1行目のset_status関数では、@statusにお気に入りをした投稿の情報を代入しています。2行目のFaviuriteServiceを呼び出している部分は後ほど詳しく示しますが、データベースに情報を追加するという挙動をします。3行目ではフロントエンドに@statusの情報をjson形式で返しています。おそらくフロントエンドのfavourite関数がこの返されたデータを受け取っているのではないかと思います。

2行目のクラスや関数の定義は'favourite_service.rb'に書かれています。関数の内容は以下のようで、

def call(account, status)
    authorize_with account, status, :favourite?
    favourite = Favourite.find_by(account: account, status: status)
    return favourite unless favourite.nil?

    favourite = Favourite.create!(account: account, status: status)
    Trends.statuses.register(status)
    create_notification(favourite)
    bump_potential_friendship(account, status)
    favourite
end

call関数自体は長く、様々な操作をしているが、今回着目するのは

favourite = Favourite.create!(account: account, status: status)

この部分でFavouriteクラスのcreate!関数(この関数は今回明示的に定義されているのではなく、Favouriteの継承元ののApplicationRecordクラスのもの)を呼び出すことで、データベースに新しくお気に入り登録した情報を追加できるようになります。

お気に入りを削除した時

削除した時の関数も概ね定義のされ方はお気に入り登録した時と同様の関数で定義されており、挙動が似ています。以下ではお気に入りを削除した時の挙動をバックエンドのみ示します。

バックエンドでは、フロントエンドからApi::V1::FavouritesControllerクラスのdestroy関数が呼び出され、(以下)

  def destroy
    fav = current_account.favourites.find_by(status_id: params[:status_id])

    if fav
      @status = fav.status
      count = [@status.favourites_count - 1, 0].max
      UnfavouriteWorker.perform_async(current_account.id, @status.id)
    else
      @status = Status.find(params[:status_id])
      count = @status.favourites_count
      authorize @status, :show?
    end

    relationships = StatusRelationshipsPresenter.new([@status], current_account.id, favourites_map: { @status.id => false }, attributes_map: { @status.id => { favourites_count: count } })
    render json: @status, serializer: REST::StatusSerializer, relationships: relationships
  rescue Mastodon::NotPermittedError
    not_found
  end

お気に入りを削除する場合はUnfavouriteWorkerクラスのperform関数(unfavourite_service.rb)が呼び出され、

class UnfavouriteWorker
  include Sidekiq::Worker

  def perform(account_id, status_id)
    UnfavouriteService.new.call(Account.find(account_id), Status.find(status_id))
  rescue ActiveRecord::RecordNotFound
    true
  end
end

次にUnfavouriteServiceクラスのcall関数('unfavourite_service.rb')が呼び出されます。

  def call(account, status)
    favourite = Favourite.find_by!(account: account, status: status)
    favourite.destroy!
    create_notification(favourite) if !status.account.local? && status.account.activitypub?
    favourite
  end

call関数では消したいお気に入りの情報を取得し、

favourite.destroy!

の部分でデータベースからお気に入り登録をした履歴を消去します。

スタンプの作成

以上に書いたスタンプの挙動を参考にしてグッドボタン、バッドボタンに用いる関数やクラスを定義してスタンプを押し、情報を登録できるようにしました。

スタンプに用いる画像はお気に入りのスタンプと同様にFontAwesomeの'thumbs-up'、'thumbs-down'というアイコンを使用しました。

追加したファイルや関数については多少機能に応じて異なる部分もあるが概ね上述したお気に入りボタンの'favourite'を'thumbsup'や'thumbsdown'に書き換えて作成しただけで、書き出すと長くなるので示しません。

ただ、データベースに情報を登録するところでは新しい操作があったのでその部分については詳しく示します。

データベースへの情報登録

お気に入りボタンについてはデータベースはすでに作成してあり、'schema.rb'に

create_table "favourites", force: :cascade do |t|
    t.datetime "created_at", precision: nil, null: false
    t.datetime "updated_at", precision: nil, null: false
    t.bigint "account_id", null: false
    t.bigint "status_id", null: false
    t.index ["account_id", "id"], name: "index_favourites_on_account_id_and_id"
    t.index ["account_id", "status_id"], name: "index_favourites_on_account_id_and_status_id", unique: true
    t.index ["status_id"], name: "index_favourites_on_status_id"
end

このようにテーブルを作るように指示されています。このテーブルでは以下のような情報を持っています。

名前 内容
created_at 作成された日付、定義しなくても自動で作られる
updated_at 更新された日付、定義しなくても自動で作られる
account_id お気に入りをしたアカウントのID、一意である
status_id お気に入りをされた投稿のID、一意である

新しくスタンプを定義したらこのようにデータベースのテーブルを作るように定義しなければいけないですが、'schema.rb'に直接書き込むことはあまりよくないようで、以下に示すような操作をしてマイグレーションファイルを作成し、変更を適用しました。

マイグレーションファイルとは、データベースに変更を加える時に作成するファイルのことである。railsを用いてマイグレーションファイルを作る場合、ターミナルで

rails generate migration 'クラス名'

とするとdb/migrateディレクトリに作成でき、ファイルを編集して

rails db:migrate

とすると変更が適用され、'schema.rb'の内容が書き換わります。

今回の実験では、使用しているコンテナの都合上(?)、このコマンドでは動かず、
それぞれ頭に'bundle exec'をつけて

bundle exec rails generate migration 'クラス名'
bundle exec rails db:migrate

というコマンドで操作しました。

実際にグッドボタンを作成する時を例にすると、

bundle exec rails generate migration CreateThumbsup

とすると、20231022124735_create_thumbsup.rbというファイルが作成され(前の数字は自動でついてきます)、このファイルの内容を

# frozen_string_literal: true

class CreateThumbsup < ActiveRecord::Migration[7.0]
  def change
    create_table :thumbsups do |t|
      t.bigint :account_id, null: false
      t.bigint :status_id, null: false

      t.timestamps
    end
  end
end

と書き込んでaccount_idとstatus_idを持つthumbsupsというテーブルを上で記したコマンド操作をして作成しました。このままマイグレーションをaccout_idとstatus_idは一意でなければいけないというエラーが出たので、今度は

bundle exec rails generate migration uniqueThumbsup

として再び'20231023070913_thumbsupunique.rb'を作成し、以下のように

# frozen_string_literal: true

class Thumbsupunique < ActiveRecord::Migration[7.0]
  disable_ddl_transaction!

  def change
    add_index :thumbsups, [:status_id, :account_id], unique: true, algorithm: :concurrently
  end
end

と書き込み、

bundle exec rails db:migrate

と変更を適用するとエラーが解消され、'schema.rb'内を確認すると自動でthumbsupsというテーブルができており(以下)、データを登録できるようになっていた。

create_table "thumbsups", force: :cascade do |t|
    t.bigint "account_id", null: false
    t.bigint "status_id", null: false
    t.datetime "created_at", null: false
    t.datetime "updated_at", null: false
    t.index ["status_id", "account_id"], name: "index_thumbsups_on_status_id_and_account_id", unique: true
end

表示の変更と機能追加

新しくスタンプの機能を追加できたが、表示的な機能がまだ実装できていなかったので定義しました。具体的には以下のような内容です。

  • 色の変化(グッドボタン→水色、バッドボタン→赤色)
  • クリックするとスタンプが並んでいるコンテナが開くようにする
  • グッドボタンとバッドボタンの排他性(片方がオンになったらもう片方は強制的にオフになる)

色の変化

この時もお気に入りのスタンプを参考に探索し、色や配置の定義は'component.scss'というファイルで行われていることを突き止めました。また、chrome上で検証を行い、クリックした時の挙動を調べると、オンになっている時にはアイコンには'active'クラスがついており、またグッドボタン、バッドボタンそれぞれに'thumbsup-icon'、'thumbsdown-icon'というhtmlのクラスが定義されていることがわかりました。なのでグッドボタンを水色、バッドボタンを赤色に変化するように、'components.scss'には以下のように付け足しました。

.icon-button.thumbsup-icon.active {
  color: #19cdf1;
}

.icon-button.thumbsdown-icon.active {
  color: #ff3a78;
}

これでそれぞれのボタンがオンになった時に色が変化するようになります。

スタンプが入ったコンテナを作る

今は追加で二つしかスタンプを作成していないので煩わしくないが、スタンプが増えた時に一つ一つの投稿に対して全てのスタンプを表示していたら、表示を圧迫してしまうので、クリックしたらスタンプが入っているコンテナを開くようなボタンを作成した。ボタンのデザインは形は下向きの三角形として、FontAwesomeの'caret-down'というアイコンを用いました。

ボタンの定義は、'status_action_bar.jsx'に書き、

<IconButton className='status__action-bar__button caret-down-icon' icon='caret-down' onClick={this.handleCaretdownClick} />

となっており、クリックすると

  handleCaretdownClick = () => {
    this.setState({ isDivVisible: !this.state.isDivVisible });
  };

この関数が呼び出され、isDivVisibleというブール値が入れ替わり、見えなくなっているdivが見えるようになるようにしました。また、見えるようになっている時にクリックすると、今度は見えなくなります。

このようにしてクリックすると反応して色が変わるボタンが完成しました。スタンプを押した時の様子を下の写真に示します。


変更後のリアクションボタンの様子

グッドボタンとバッドボタンの排他性

グッドとバッドが両方とも押されているという状態がない方が好ましいので、片方がオンになっている状態でもう片方をオンにすると、先にオンになっている方はオフになるようにしました。

バッドボタンが押されてる時にグッドボタンをオンにした時の挙動を考え、変更を加えました。以下に'status_container.jsx'の対応する部分のコードを示します。

  onThumbsup (status) {
    if (status.get('thumbsuped')) {
      dispatch(unthumbsup(status));
    } else {
      if(status.get('thumbsdowned')){
        dispatch(unthumbsdown(status));
      }
      dispatch(thumbsup(status));
    }
  },

バッドボタンの方を後から登録した場合も同様に反応するように改良しました。

しかし、何度かボタンを付けたり消したりしてると、表示上では両方ついてしまっていることがありました。原因は突き止め切れていないので、今のコードだと両方ついてしまう不具合があります。ただ、他の操作をしたり、再読み込みをしてページの更新を行うと本来消えるべきスタンプが消えるので、上に示したコードは機能しているが、表示についてはまた他の関数の影響もあるのではないかと思います。

その他

これまでの変更はタイムラインを開いている時だったので、一つの投稿のみをクリックして表示した時のスタンプは増えていない状態でした。なので、そのファイルにもフロントエンドの関数を同じように定義し、適切にスタンプを追加した。バックエンドの方は何も追加する必要はありませんでした。

パスについて

本文の内容でのコードが入っているファイルのパスを以下に示します。(コード内では異なるディレクトリに同じファイル名で定義されているものもある可能性があるため)

ファイル名 パス
status_action_bar.jsx app/javascript/mastodon/components/status_action_bar.jsx
status_container.jsx app/javascript/mastodon/containers/status_container.jsx
interactions.js app/javascript/mastodon/actions/interactions.js
api.rb config/routes/api.rb
favourites_controller.rb app/controllers/api/v1/statuses/favourites_controller.rb
favourite_service.rb app/services/favourite_service.rb
favourite.rb app/models/favourite.rb
unfavourite_worker.rb app/workers/unfavourite_worker.rb
unfavourite_service.rb app/services/unfavourite_service.rb
schema.rb db/schema.rb
20231022124735_create_thumbsup.rb db/migrate/20231022124735_create_thumbsup.rb
20231023070913_thumbsupunique.rb db/migrate/20231023070913_thumbsupunique.rb

6. URL埋め込み機能

ここではURL埋め込み機能の実装について説明していきます。

実装方針

1. リンク埋め込み機能について

リンク埋め込みとはそもそもどういう機能なのでしょうか。
URLはそのまま表示すると以下のようになります。(Googleの初期ページ)
https://www.google.co.jp/

このくらいの短さのリンクなら良いのですが、もっと長いリンクだったらどうでしょうか。(ChromeでMastodonと検索したときのページ)
https://www.google.com/search?q=mastodon&rlz=1C5CHFA_enJP991JP1007&oq=mastodo&gs_lcrp=EgZjaHJvbWUqBwgAEAAYgAQyBwgAEAAYgAQyBggBEEUYOTIHCAIQABiABDIHCAMQABiABDIHCAQQABiABDIGCAUQRRg9MgYIBhBFGD0yBggHEEUYPagCALACAA&sourceid=chrome&ie=UTF-8

こんな長いリンクなんて出てきた際には文章全体がわかりにくくなってしまいます。
そんな時Slackでは以下のようにリンクを文字に埋め込む表示方法ができます。
このリンクにアクセス

それではMastodonの場合はどうでしょうか。
長いURLをトゥート(投稿のこと)すると以下のように短縮された形で表示されます。

ただしSlackのようにリンクを文字に埋め込むことはできません。この章ではこの機能をMastodonに実装していきます。

2. 実装のために変更が必要な箇所

リンクを埋め込む機能の実装にはプログラムのどこを変更する必要があるでしょうか。
以下が最初に浮かんだ変更必要箇所です。

  1. 投稿するフォームのUI
    リンクを埋め込むには、埋め込むリンクとリンクを埋め込むテキストの取得が必要です。そのためには投稿フォームのUIの変更が必要ですね。フロントエンドをいじることになりそうです。

  2. UIで受け取ったデータの処理
    投稿フォームのUIで受け取ったリンクとテキストのデータを、投稿を表示するUIに渡す必要があります。この部分はデータを管理することになるので、バックエンドに入ってくる可能性があります。

  3. 投稿を表示するUI
    リンクとテキストを受け取っただけでは、表示できていないので、受け取ったリンクとテキストを実際の画面に反映する必要があります。こちらもUIの変更にあたるので、フロントエンドですね。



次節以降でそれぞれの機能についてプログラムを手探りつつ修正を加えていきます!

投稿するフォームのUI

1. 方針

まずは投稿するフォームです!
Mastodonでは投稿フォームは以下のように表示されています。

ここでのテーマはこの投稿フォームにおいて指定した箇所に、リンクを埋め込めるように適切な情報を受け取ることです。今回は、リンクを埋め込みたいテキストをドラッグして選択すると、下にリンクを埋め込むテキストボックスが出てきて、そこにリンクをペーストすることで、リンクを埋め込めるようにしようと思います。

まずは投稿フォームがどのファイルで書かれいているかを見つけなければいけません。今回は投稿フォームに書かれている、「今なにしてる」というキーワードに注目して、VSCode上の左に出てくる虫眼鏡マークから検索をかけてみます。

ja.jsonの146行目の

"compose_form.placeholder": "今なにしてる?",

がヒットしました。
さらに"compose_form.placeholder"で検索すると、compose_form.jsxがヒットしました!compose_form.jsxでは"ComposeForm"というクラスが定義されています。このクラスが投稿フォームを構成しているようですね。
さらに"ComposeForm"の中を見ていくと、実際にテキストを打ち込む部分は"AutosuggestTextarea"というコンポーネントで定義されていることがわかります。
今回はテキストエリアにおける選択を検知する必要があるので、"AutosuggestTextarea"を定義しているファイルをいじる必要があります。VSCodeではコンポーネントなどにカーソルを合わせて右クリックすると、それを定義している部分に飛ぶことができます。
"AutosuggestTextarea"はautosuggest_textarea.jsxで定義されていることがわかります。
次項では実際にautosuggest_textarea.jsxcompose_form.jsxに変更を加えていきます。

2.コードの変更

それでは実際のコードの変更部分に入っていきます。
今回いじる関数はどちらもjsxファイルで、Reactで書かれています。

1. autosuggest_textarea.jsx

"AutosuggestTextarea"では"onChange"や"onKeydown"などの、テキストエリア内での動きに対応する関数と、"textarea"という、実際のテキストエリアに対応するコンポーネントから構成されています。またその際に必要な情報はstateを使って管理しています。
ここでは、テキストの選択と選択解除の検知をする関数"onSelect","onDeselect"、リンクを埋め込む上で必要な情報をstateに代入する関数"setLink"、そして選択状態に応じて画面に表示されるコンポーネント"LinkInsertArea"を新たに作ることで、リンクを埋め込めるようにします。

まずはじめに、機能を実装する上で必要になるであろうstateを追加しておきます。

stateの追加のコード
state = {
suggestionsHidden: true,
focused: false,
selectedSuggestion: 0,
lastToken: null,
tokenStart: 0,

//ここから追加
selectedText: "",
selectionStart: 0,
selectionEnd: 0,
burryingtext: "",
burryingtextStart: 0,
burryingtextEnd: 0,
burriedlink: "",
//ここまで

BoxsuggestionsHidden: true,
Boxfocused: false,
BoxselectedSuggestion: 0,
BoxlastToken: null,
BoxtokenStart: 0,
};

各ステートの役割は以下のようになります。

  • selectedText
    選択しているテキスト(選択しているテキストによってリアルタイムで変化)を保持する。
  • selectionStart
    選択しているテキスト(選択しているテキストによってリアルタイムで変化)の最初の文字が全体のテキストの中での何番目に位置するかを保持する。
  • selectionEnd
    選択しているテキスト(選択しているテキストによってリアルタイムで変化)の最後の文字が全体のテキストの中での何番目に位置するかを保持する。
  • burryingtext
    リンクを埋め込む対象となるテキストを保持する。
  • burryingtextStart
    リンクを埋め込む対象となるテキストの最初の文字が全体の投稿文の中での何番目に位置するかを保持する。
  • burryingtextEnd
    リンクを埋め込む対象となるテキストの最後の文字が全体の投稿文の中での何番目に位置するかを保持する。
  • burriedlink
    埋め込むリンクを保持する。

次に"onSelect", "onDeselect"関数を追加していきます。

"onSelect", "onDeselect"の追加のコード
onPaste = (e) => {
if (e.clipboardData && e.clipboardData.files.length === 1) {
  this.props.onPaste(e.clipboardData.files);
  e.preventDefault();
}
};

//ここから追加
onSelect = (e) => {
    const selectedText = window.getSelection().toString();
    this.setState({ selectedText: selectedText });
    this.setState({ selectionStart: e.target.selectionStart });
    this.setState({ selectionEnd: e.target.selectionEnd });
};

onDeselect = () => {//追加
    this.setState({ selectedText: "" });
};
//ここまで

BoxonChange = (e) => {

各関数の役割は以下のようになります。

  • onSelect
    投稿文の中の一部のテキストを選択したときに、"selectedText", "selectionStart", "selectionEnd"の各ステートに値を代入する。
  • onDeselect
    投稿文の中の一部のテキストの選択を解除したときに、"selectedText", のステートを初期化する。("selectionStart", "selectionEnd"については、更新する必要なし。実は"selectedText"を更新する必要もないが見栄えのため追加しておく。)

次にsetLink関数を追加していきます。

"setLink"の追加のコード
setLink = (text) => {
    this.setState({ burryingtext: this.state.selectedText });
    this.setState({ burriedlink: text });
    this.setState({ selectedText: "" });
    this.setState({ burryingtextStart: this.state.selectionStart});
    this.setState({ burryingtextEnd: this.state.selectionEnd});
};

この関数は呼び出された時に、"textarea"にて選択されているテキストの情報を"burryingtext", "burryingtextStart", "burryingtextEnd"に代入し、"LinkInsertArea"内のテキストを"burriedlink"に代入することで、リンクを埋め込む上で必要な情報を全てstateで取得することができます。

次に"LinkInsertArea"コンポーネントを追加していきます。
"LinkInsertArea"コンポーネントはautosuggest_textarea.jsxにおいてのみ使用するので、このファイルの一番最後の部分に単体で追加します。

"LinkInsertArea"の追加のコード
//追加
const LinkInsertArea = ({setLink}) => {
LinkInsertArea.propTypes = {
    setLink: PropTypes.func.isRequired
};
const [text, setText] = useState("");
const ontextchange = useCallback((e)=>{
    setText(e.target.value);
}, []);
const onClick = useCallback(()=>{
    setLink(text);
}, [text]);
return (
    <div>
    <label>
        <input
        type='text'
        value={text}
        onChange={ontextchange}
        placeholder={"リンクを入力"}
        />
    </label>
    <button onClick={onClick}>完了</button>
    </div>
);
};

またreactを使う必要があるので、二行目に以下のように追加しておきます。

import PropTypes from 'prop-types';
import{useCallback, useState} from 'react';//追加

"LinkInsertArea"では、inputとしてtextを受け取り、onClickに応じて、"setLink"を呼び出すようにしています。これにより、inputから受け取ったリンクを"setLink"によりstateに渡すことができます。

さて、ここまで来たらあとはreturnの中身において、"textarea"コンポーネントを修正し、さらにstateに応じて"LinkInsertArea"が表示されるようにすれば完了です。

returnの中身の修正コード
<div className='autosuggest-textarea'>
    <label>
    <span style={{ display: 'none' }}>{placeholder}</span>

    <Textarea
        ref={this.setTextarea}
        className='autosuggest-textarea__textarea'
        disabled={disabled}
        placeholder={placeholder}
        autoFocus={autoFocus}
        value={value}
        onChange={this.onChange}
        onKeyDown={this.onKeyDown}
        onKeyUp={onKeyUp}
        onFocus={this.onFocus}
        onBlur={this.onBlur}
        onPaste={this.onPaste}

        //ここから追加
        onSelect={this.onSelect} 
        onDeselect={this.onDeselect}
        //ここまで

        dir='auto'
        aria-autocomplete='list'
        lang={lang}
    />
    </label>
</div>

//ここから追加
<div>
    {this.state.selectedText && ( // 選択テキストがある場合に表示
    <LinkInsertArea
        setLink={this.setLink}
    />
    )}
</div>
//ここまで

ここまでで、"burryingtext", "burryingtextStart", "burryingtextEnd", "burriedlink"の各ステートにリンクを埋め込み表示する上で必要な情報を代入することができました。

2. compose_form.jsx

compose_form.jsxにも少しだけ手を加える必要があります。
今のままだと、"burryingtext", "burryingtextStart", "burryingtextEnd", "burriedlink"のステートの初期化が行えていないので、新しく投稿しようとすると、前の投稿におけるステートが残ったままになってしまいます。autosuggest_textarea.jsxでは一つの投稿内で完結する操作しか行えないので、一つレイヤが上のcompose_form.jsxにて初期化を行う必要があります。

ここでは"ComposeForm"クラスの中の、投稿を実際にサブミットする操作を管理する関数、"handleSubmit"に変更を加えていきます。

"handleSubmit"の変更のコード
//ここから追加
//ここでstateのリセット
this.autosuggestTextarea.setState({ burryingtext: "" });
this.autosuggestTextarea.setState({ burriedlink: "" });
this.autosuggestTextarea.setState({ burryingtextStart: 0});
this.autosuggestTextarea.setState({ burryingtextEnd: 0});
//ここまで追加

if (e) {
  e.preventDefault();
}

};

これにより、投稿した後に各stateを初期化することができました。

以上により、autosuggest_textarea.jsxcompose_form.jsxが適切に変更され、以下のように投稿フォームからリンクと埋め込むテキストの情報を受け取ることができました。

UIで受け取ったデータの処理

1. 方針

次は投稿を表示する部分です!
前節までで、リンクと埋め込むテキストの情報を得ることができました。ここでのテーマは受け取った情報を投稿を表示するUIにどのように伝達するかです。

伝達する方法として思いついたものは二つほどありました。
一つ目は、新しく受け取ったリンクや埋め込むテキストの情報を格納するデータベースを作成し、そこにデータを保持する方法です。
二つ目は、受け取ったテキストと埋め込むリンクをMarkdown形式に変換して、投稿文の情報の中にリンク埋め込みを含めた全ての情報を入れ込んでしまう方法です。例えば以下の文の"ここ"にリンクを埋め込む場合は、stateに保持する情報を使って、上の元の投稿文を下のMarkdown式に変更します。

元の投稿文:
"ここからgoogleのトップページに飛べます。"  

Markdown形式変更後の投稿文:
"[ここ](https://www.google.co.jp/)からgoogleのトップページに飛べます。"

一つ目の方法に関しては、データベースなどプログラムの深部まで改変を行う必要があり、それに付随してよりたくさんのファイルに変更を加える必要があります。それに対し二つ目の方法に関しては、実質的には投稿文に手を加えているだけなので、データベースなどはなにも変わりません。よって今回は二つ目の選択肢を採用することにしました。

具体的な手法としては前節でも少し手を加えたcompose_form.jsxに変更を加えていきます。

2.コードの変更

compose_form.jsx

今回はcompose_form.jsxのみの変更で良さそうです。
ここでは前節でも変更を加えた、"handleSubmit"関数において、投稿文をMarkdown式で書き直してfinaltextに代入し、submitするようにします。

"handleSubmit"の変更のコード
handleSubmit = (e) => {
    if (this.props.text !== this.autosuggestTextarea.textarea.value ) {
    // Something changed the text inside the textarea (e.g. browser extensions like Grammarly)
    // Update the state to match the current text
    this.props.onChange(this.autosuggestTextarea.textarea.value);
    }

    if (!this.canSubmit()) {
    return;
    }

    //ここから変更
    const finaltext =
    this.autosuggestTextarea.textarea.value.slice(0, this.autosuggestTextarea.state.burryingtextStart)
    + '[' +  this.autosuggestTextarea.state.burryingtext + ']'
    + '(' + this.autosuggestTextarea.state.burriedlink + ')'
    + this.autosuggestTextarea.textarea.value.slice(this.autosuggestTextarea.state.burryingtextEnd);
    //ここでtextの内容をつなげてmarkdown形式にして送る

    this.props.onChange(finaltext);
    this.props.onSubmit(this.context.router ? this.context.router.history : null);
    //ここまで

    //ここから前節の追加分
    //ここでstateのリセット
    this.autosuggestTextarea.setState({ burryingtext: "" });
    this.autosuggestTextarea.setState({ burriedlink: "" });
    this.autosuggestTextarea.setState({ burryingtextStart: 0});
    this.autosuggestTextarea.setState({ burryingtextEnd: 0});
    //ここまで前節の追加分

    if (e) {
    e.preventDefault();
    }
};

以上で投稿フォームで受け取った情報をMarkdown形式として投稿文に落とし込むことができました。

投稿を表示するUI

1. 方針

最後に投稿を表示する部分です!
前節までで、リンクと埋め込むテキストの情報をMarkdown形式で得ることができました。ここでは、実際にリンクが埋め込まれた状態で、投稿が表示されるようにしたいと思います。

Mastodonでは通常、長いリンクをトゥートした場合、短縮して表示されます。ということはURLを短縮して表示するための関数があるはずですね。
今回もVSCodeの検索で色々調べてみます。いくつかのワードを試した後、"shortened"と検索するとtext_formatter.rb内に"shortened_link"という関数が定義されていることがわかりました。この関数内でURLの短縮が行われているようです。
さらにtext_formatter.rbを調べると、このファイルでは投稿文のテキストからリンクやハッシュタグなどを検出して、実際に表示するhtml形式に変換していることがわかりました。このファイルに主に変更を加えていけば目的を達成できそうです!!

さて、text_formatter.rb内ではurlのハイパーリンク化に関わっていそうな関数を以下のように三つ発見しました。

  • to_s
    urlやhashtagなどの有無に応じて、link_to_urlなどのhtmlの変換を加える関数を呼び出し、投稿文に変更を加え、最終的にhtml形式の投稿文を完成させる。
  • link_to_url
    オブジェクトを引数にとる。渡されたオブジェクトのurl部分(すでに別の関数で正規表現により抽出されている)をshortened_linkに渡す。
  • shortened_link
    urlを引数にとる。受け取ったurlの長さに応じて、urlを短縮表示するか判断し、適切にhtml形式に落とし込む。

これらの関数に変更を加えていけば、うまく投稿文を整形できそうです。


今回は大まかには以下の作戦でMarkdown形式を変更していきます。

  1. "link_to_url"において正規表現でtextとurlを抽出し、textとurlの両方を"shortened_link"に渡す。
  2. "shortened_link"にtextとurlを渡すことでtextにurlを埋め込んだ状態のhtmlを生成する。
  3. "to_s"において正規表現により、text以外の部分を削除する。



イメージとしては以下のように変更が加えられていきます。

  1. 最初のMarkdown形式:

     "[ここ](https://www.google.co.jp/)からgoogleのトップページに飛べます。"
    

    ※この記法でないとハイパーリンク化のエスケープができないのでご了承ください。

  2. "ここ"の部分がハイパーリンク化した状態:
    "[ここ](https://www.google.co.jp/)からgoogleのトップページに飛べます。"

  3. "ここ"の部分がハイパーリンク化した状態で他を削除:
    "ここからgoogleのトップページに飛べます。"

2.コードの変更

1. text_formatter.jsx

それでは実際にコードに変更を加えていきます。

a. shortened_link

textに'0'が渡される場合は通常のリンク短縮を、それ以外の場合はtextにurlを埋め込んだ形にすることにしました。

"shortened_link"の変更のコード
    def shortened_link(url, text, rel_me: false)
        url = Addressable::URI.parse(url).to_s
        rel = rel_me ? (DEFAULT_REL + %w(me)) : DEFAULT_REL

        prefix = url.match(URL_PREFIX_REGEX).to_s
        if text == '0' 
            display_url = url[prefix.length, 30]
            suffix      = url[prefix.length + 30..]
            cutoff      = url[prefix.length..].length > 30
        else
            display_url = text
            suffix      = url[prefix.length + 30..]
            cutoff      = url[prefix.length].length > 30
        end

        <<~HTML.squish.html_safe # rubocop:disable Rails/OutputSafety
            <a href="#{h(url)}" target="_blank" rel="#{rel.join(' ')}" translate="no"><span class="invisible">#{h(prefix)}</span><span class="#{cutoff ? 'ellipsis' : ''}">#{h(display_url)}</span><span class="invisible">#{h(suffix)}</span></a>
        HTML
b. link_to_url

正規表現で抽出できた場合は抽出したtextをurlと一緒にshortened_linkに渡し、それ以外の場合は'0'をurlと一緒にshortened_linkに渡すことにしました。

"link_to_url"の変更後のコード
    def link_to_url(entity)
        pattern = /\[([^\]]+)\]\(([^)]+)\)/
        matches = text.scan(pattern)
        if matches.empty?
        TextFormatter.shortened_link(entity[:url], '0', rel_me: with_rel_me?)
        else
        word = matches[0][0]
        TextFormatter.shortened_link(entity[:url], word, rel_me: with_rel_me?)
        end
    end
c. to_s

正規表現での部分を削除するコードを追加しました。

"to_s"の変更後のコード
    def to_s
        return ''.html_safe if text.blank?

        html = rewrite do |entity|
        if entity[:url]
            link_to_url(entity)
        elsif entity[:hashtag]
            link_to_hashtag(entity)
        elsif entity[:screen_name]
            link_to_mention(entity)
        end
        end
        html = html.gsub(/\[(.*?)\]\((.*?)\)/, '\2')

        html = simple_format(html, {}, sanitize: false).delete("\n") if multiline?

        html.html_safe # rubocop:disable Rails/OutputSafety
    end


2. formatting_helper.jsx

formatting_helper内の関数である"account_field_value_format"においても、"shortened_link"が呼び出されているので、引数を変更しておきます。ここではリンクの埋め込みは関係なさそうなので、'0'を引数に入れておきます。

"account_field_value_format"の変更後のコード

def account_field_value_format(field, with_rel_me: true)
if field.verified? && !field.account.local?TextFormatter.shortened_link(field.value_for_verification, '0')
else
html_aware_format(field.value, field.account.local?, with_rel_me: with_rel_me, with_domains: true, multiline: false)
end
end


完成!!


以上により実装できました!!

現時点での課題

機能の実装はできましたが、現在のコードではまだいくつか不完全な部分があります。
最後にそれらに言及して、この章を終わりたいと思います。

  1. '0'にリンクを埋め込むことができない
    今のところ"shortened_link"に渡すtextが'0'の時はただリンクを短縮するのみになっているので、'0'そのものにリンクを埋め込むことはできなくなっています。解決策としては、より使用頻度の低い文字にする、もしくはtextとは別で、埋め込みの有無のみを渡すための引数を設定することがありそうです。
  2. 複数箇所にリンクを埋め込むことができない
    今のところはstateで受け取れる情報が一つのリンク分しか用意されていないため、複数箇所にリンクを埋め込むことはできません。解決策としては、あらかじめ複数stateを用意しておくことが挙げられますが、幾つまで用意するのかは難しいところです。
  3. Markdown形式そのものを投稿することができない
    これはMarkdown式そのものの問題でもあるのですが、投稿文内で()[]のように表そうとすると正規表現に引っかかってしまうため、意図していなくても埋め込みの形になってしまいます。これに関しては、簡単にURL埋め込み機能を実装できることとのトレードオフと考えているので、仕様がないのかなと思っています。もし良い解決策(データベースを使う以外で)があったらぜひ教えてください。

7.感想

Y.F

僕は新しいスタンプの作成を担当しましたが、アプリケーションを探索している中で気づいたことは、お気に入りボタンをクリックするという簡単な操作をするだけでも、アプリケーションが大規模であると関連する関数が多く、何重にも呼び出されているということです。スタンプ機能の作成のパートで書いたような機能はクリックするとボタンが反応するというだけの簡単なものであり、探索している中でお気に入りボタンに関する機能は他にも、

  • お気に入りの数をカウントする
  • 投稿がお気に入り登録されたら投稿者に通知がいく

などがありました。上のそれぞれの機能に対して多くの関数があり、アプリケーションの構造が複雑であると感じました。

実装していくなかで苦労した点は、お気に入りのスタンプを押した時の挙動を探索している際に関数や変数がどこで定義されているのかを見つけることです。そもそもファイル数が多く、関数名や変数名を検索してもヒットするものが多いことはもちろん、今回のアプリケーションを構成していたReactやRuby on railsは初めて使うフレームワークであり、Rubyについては言語から初めて見たので、構成や文法の表している意味を理解するのに時間がかかってしまいました。特に、railsの性質で外部ファイルの内容を参照する際に、明示的にインポートを記さなくても自動で他のファイルの内容にアクセスできたり、以下のように

resource :favourite, only: :create

このように書くだけでFavouriteControllerクラスのcreate関数が呼び出すことができるなど呼び出しの部分を省略しても自動で適切な関数を呼び出せたりする性質があったため、どこのファイルのどの関数が呼び出されているかを探索することに時間がかかりました。

スタンプ機能の説明をしているパートでは、順序立ててクリックされた時の挙動を示せたが、実際に探索している段階では、特にバックエンドの関数は断片的にしかわからず、全てのつながりを把握することに実験時間の大部分(2週間ほど)を使いました。なので、当たり前かもしれないですが、探索する前に先にフレームワークの勉強をあらかじめ時間をとってしておくことにより、トータルで実装するのに時間がかなり短縮されると思うので、探索する前にフレームワークの予習は行っておくべきであると実感しました。


K.U

私はURLの埋め込み機能を担当しました。
私が機能を実装していく上で感じたことは大きく分けて二点です。

一つ目は、大規模ソフトウェアに手を加えることの大変さです。
プログラムの規模が大きいので、一つの機能を変えるにしても、多数のファイル、関数が複雑に絡み合っていて、既存のファイルや関数の構成を理解し、それに沿った改良を考えるのはとても大変でした。
そもそも目的の関数がどこにあるのかを見つけるのが大変だった上、私はReact, Rubyなどの経験がなかったので、関数に変更を加えるために、これらの言語・フレームワークを理解するところからはじめました。
特にReactはステートやコンポーネントの概念が難しかったので苦労しました。
このようにプログラムの規模の大きさからくる複雑さと複数の言語を理解する必要性から、大規模ソフトウェアに手を加えるのは非常に大変だと感じました。
またこれらの機能を実際に実装している世のソフトウェアエンジニアへの尊敬も強まりました。

二つ目は、大規模ソフトウェア上で一つの機能を実装することで得られる達成感です。
前述したようなたくさんの大変なことがありましたが、それらを乗り越えて、一つの機能を実装することで得られる達成感は何にも変え難いものがありました。


H.O

私はY.Fと協力してスタンプの作成を担当していました。スタンプという一見シンプルな機能の実装であっても扱わなければいけないファイルの量は膨大でした。今回実装できたのはあくまで一部分であり、完全に実装するためにはより一層知識が必要になることがわかりました。

Mastodonには複数のフレームワークが用いられており、それを理解せずに闇雲に探してしまったのが苦労した原因ではないかなと思います。一方で実際にその機能についてネットで変更を加えるはじめの段階に検索を少しだけかけましたが、完全に説明してくれているサイトはなかなか見つからなかった覚えがあります。これらのフレームワークは日本でかなり普及しているかというとそうではなくその理解を深めるためには専門的な英語が必要になることも考えられます。だからこそ学ぶハードルが高くなっていると感じます。

このMastodonのソースコードには日本人も少なからず関わっていることを今回知ることができました。とても尊敬しました。自分もこのアプリではなくとも大規模なソフトウェアの開発に携われるような能力をつけていきたい所存です。

Discussion