Mastodonのホットキーの挙動を修正した

<!---


--->



2017年から1年ほどMastodonを使っていますが、初めてWeb UIを修正する機会があったので、そのときに読んだコードと修正した内容の解説を残して置こうと思います。

この修正はPRを出して無事mergeされました。 https://github.com/tootsuite/mastodon/pull/7202

# 経緯

Mastodon v2.0.0 でホットキーの機能が実装されました。ホットキーはWeb UIの機能でフォーカスのあるトゥートをfでファボしたりbでブーストしたりするキーボードショートカット機能です。フォーカスの移動もj(下にフォーカスを移動)やk(上にフォーカスを移動)でできます。しかしMastodon v2.3.0でピン留めトゥートがアカウントタイムラインに表示されるようになったことにより、jやｋのフォーカス移動が正しく移動しなくなっていました。せっかく見つけたバグなので自分で直すことにしました。

# コードと修正の解説

## forcusの移動

[mastodon/app/javascript/mastodon/components/status_list.js](https://github.com/TakesxiSximada/mastodon/blob/8def64f529c7aef5e72e3a9b48a1644575ff7ce8/app/javascript/mastodon/components/status_list.js#L59-L65)

移動したいトゥートの一番上からのindexを_selectChild()に渡すと、そのindexの要素にforcusを移動します。

```:変更なし
  _selectChild (index) {
    const element = this.node.node.querySelector(`article:nth-of-type(${index + 1}) .focusable`);

    if (element) {
      element.focus();
    }
  }
```

ここは変更していません。

## 移動したい要素のindexの計算

[mastodon/app/javascript/mastodon/components/status_list.js](https://github.com/TakesxiSximada/mastodon/blob/8def64f529c7aef5e72e3a9b48a1644575ff7ce8/app/javascript/mastodon/components/status_list.js#L45-L53)

this.props.statusIdsはListで、statusのidが文字列として入っています。例えばstatusIdsの0番目のトゥートは一番上に表示されています。ただしピン留めされたトゥートが2つある場合、statusIdsの0番目のトゥートは上から3番目に表示されています。


```:修正前
  handleMoveUp = id => {
    const elementIndex = this.props.statusIds.indexOf(id) - 1;
    this._selectChild(elementIndex);
  }

  handleMoveDown = id => {
    const elementIndex = this.props.statusIds.indexOf(id) + 1;
    this._selectChild(elementIndex);
  }
```

修正前ではこのピン留めされたトゥートが考慮されていませんでした。単純にstatusIdsからindexを取得していたため、このピン留めされたトゥートが2つある場合、フォーカスが2ずつずれて移動されていました。
`-1` は上、 `+1` は下の方向にフォーカスが移動することになります。

```:修正後
  getFeaturedStatusCount = () => {
    return this.props.featuredStatusIds ? this.props.featuredStatusIds.size : 0;
  }${}

  getCurrentStatusIndex = (id, featured) => {
    if (featured) {
      return this.props.featuredStatusIds.indexOf(id);
    } else {
      return this.props.statusIds.indexOf(id) + this.getFeaturedStatusCount();
    }
  }

  handleMoveUp = (id, featured) => {
    const elementIndex = this.getCurrentStatusIndex(id, featured) - 1;
    this._selectChild(elementIndex);
  }

  handleMoveDown = (id, featured) => {
    const elementIndex = this.getCurrentStatusIndex(id, featured) + 1;
    this._selectChild(elementIndex);
  }
```

(ピン留めでない)通常のトゥートの場合、this.pros.statusIdsのindexにピン留めされたトゥート数を加算したものを使う必要があります。(アカウントタイムラインの上部に表示される)ピン留めされたトゥートのidはthis.props.featuredStatusIdsにidが保持されいるので、その中でのindexを使う必要があります。

アカウントタイムラインではピン留めされたトゥートは2つ表示されます。ひとつはアカウントタイムライン上部にピン留めされたものとして表示されます。もう一つはトゥートされた順番どおりに通常トゥートとして表示されています。
status objectはピン留めされているかどうかの状態を持っています。しかしstatusオブジェクトを複数持っているわけではなく1つです。つまりピン留めとして表示されているのか、通常トゥートとして表示されているのかは判別できません。フォーカスの移動にはホットキーイベントが発火した要素がピン留めとして表示されていたかどうかで、indexの算出方法を変える必要があります。


そこで変更後はhandlerの呼び出し元でそのフラグをfeaturedとして渡すことで、indexの算出方法を変更できるようにしました。getCurrentStatusIndex()でstatusIdとホットキーイベントが発火した要素がピン留めとして表示されていたかどうかをあらわすfeaturedフラグを渡すことで現在のフォーカス位置を取得できるようにしています。通常トゥートの場合はthis.props.statusIds内のindexにピン留めトゥート数を足したものを返しています。ピン留めトゥートの場合は、this.props.statusIds内でのindexを返しています。


## hotkeyで呼ばれるhandlerの設定

[app/javascript/mastodon/components/status.js](https://github.com/TakesxiSximada/mastodon/blob/8def64f529c7aef5e72e3a9b48a1644575ff7ce8/app/javascript/mastodon/components/status.js#L222-L235)

```:変更なし
    const handlers = this.props.muted ? {} : {
      reply: this.handleHotkeyReply,
      favourite: this.handleHotkeyFavourite,
      boost: this.handleHotkeyBoost,
      mention: this.handleHotkeyMention,
      open: this.handleHotkeyOpen,
      openProfile: this.handleHotkeyOpenProfile,
      moveUp: this.handleHotkeyMoveUp,
      moveDown: this.handleHotkeyMoveDown,
      toggleHidden: this.handleHotkeyToggleHidden,
    };

    return (
      <HotKeys handlers={handlers}>
```

ここでHotKeysにhandlersを渡すことで呼ばれる関数を設定している。hotkeyの実装は [react-hotkeys](https://github.com/greena13/react-hotkeys) を利用しています。



## ピン留めのトゥートかどうかを属性として持たせる

### element側

[mastodon/app/javascript/mastodon/components/status.js](https://github.com/TakesxiSximada/mastodon/blob/8def64f529c7aef5e72e3a9b48a1644575ff7ce8/app/javascript/mastodon/components/status.js#L236)

ピン留めされたトゥートかどうかをhandlerに渡す必要があったので、属性として持つようにしました。ピン留め表示のトゥートの要素にはdata-featuredという属性が要素につくように変更しました。


```:変更前
        <div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, { focusable: !this.props.muted })} tabIndex={this.props.muted ? null : 0}>
```

```:変更後
        <div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, { focusable: !this.props.muted })} tabIndex={this.props.muted ? null : 0} data-featured={featured ? 'true' : null}>
```

### handler側

[mastodon/app/javascript/mastodon/components/status.js](https://github.com/TakesxiSximada/mastodon/blob/8def64f529c7aef5e72e3a9b48a1644575ff7ce8/app/javascript/mastodon/components/status.js#L117-L123)

handler側は要素に追加したdata-featured属性を取得してあるかどうかを引数として渡すように変更しました。

```:変更前
  handleHotkeyMoveUp = () => {
    this.props.onMoveUp(this.props.status.get('id'));
  }

  handleHotkeyMoveDown = () => {
    this.props.onMoveDown(this.props.status.get('id'));
  }
```

```:変更後
  handleHotkeyMoveUp = e => {
    this.props.onMoveUp(this.props.status.get('id'), e.target.getAttribute('data-featured'));
  }

  handleHotkeyMoveDown = e => {
    this.props.onMoveDown(this.props.status.get('id'), e.target.getAttribute('data-featured'));
  }
```

## 変更後のピン留めの状態保持とindex計算までの流れ

上記の内容をざっとまとめると以下のようになります。

1. トゥート描画時にピン留めの状態を保持 (status.js)
1. Hotkeyのイベント発火
1. イベント発火した要素からidとピン留めの状態を取得し、index計算&フォーカス移動に渡す (status.js)
1. 渡されたidとピン留め状態からindexを計算 (status_list.js)
  - 通常トゥートの場合、通常トゥートのindex + ピン留めトゥート数
  - ピン留めトゥートの場合、ピン留めトゥートのindex
1. 計算したindexを使ってフォーカス移動 (status_list.js)


# 所感

今回目を通したのは一部frontendだけでしたが、わりと慣れてくるとそれほどややこしくはないというのか感想です。ホットキーのハンドラーの箇所などは初見でもわりとすぐにたどり着けました。ただどのobjectにどういう形式で何が入っているのかは若干見えにくく、あちこちにconsole.log()を仕込んでどんな値が入っているのかを確認していました。

あとこの記事を残した理由は、上記の内容を1ヶ月後の自分が覚えていられる自信がなかったからです。せっかく読んだのにわすれちゃうのはもったいないなあと思ったので書きました。以前にもコードリーディングの記事を少しだけ書いたりしてましたが、読んだときに何を考えていたかなどが思い出しやすくなるし、結構いなあと感じました。