[[Mastodonを読む]] #contents *はじめに [#c0102fb7] 新規トゥートについて見た際、送信したトゥートをフォロワーに伝搬する処理が行われていました。そのためにはそもそものフォロー設定が必要なので、今回はそのあたり(ユーザのフォロー周り)について見ていきましょう。 *フォロー処理トリガーの確認 [#ree518f7] まずはユーザのフォローがどのコンポーネントで行われており、サーバ側のどのコントローラ、メソッドが呼び出されて処理されているのかを確認します。 **開発環境でのユーザの作り方 [#s2fee665] でもその前に。開発環境ではデフォルトではadminユーザしかいません。フォローを行うためには別ユーザが必要になるので作らないといけません。 運用環境のMastodonの場合はユーザ登録すると入力したメールアドレスにメールが飛んできてURLをクリック、確認が行われるという手順になります。 一方、開発環境(Vagrant)ではletter_openerというgemが導入され設定されており、メールの送信は行われません。その代わり、「/letter_opener」にアクセスすると入力メールアドレスに対して送信されるメールが確認できます。表示されるメール本文の「メールアドレスの確認」をクリックすることで運用環境と同様にユーザの確認を行うことができます。(その他、「/admin」でアクセスできるサイト設定からアカウントの確認を行うことも可能です) **フォローするユーザの発見 [#b16763c3] では改めてフォローするためのユーザを作ったとして、まずはクライアント側のユーザフォローのためのコンポーネントを確認していきましょう。 そもそものユーザをどうやって見つけるのかのところから。方法としては、 -検索で探す -ローカルタイムラインで見かける などが考えられます。検索を追っかけていくとめんどくさそうなので今回はローカルタイムラインにユーザがいて、それをフォローするという流れで話を進めましょう。- **app/assets/javascripts/components/components/status.jsx [#lf147256] タイムライン表示の時に確認したように、ホーム、ローカル、連合の各タイムラインで使用されているコンポーネントは違いますが、最終的にひとつひとつのステータスを表示しているのはStatusクラスでcomponents/status.jsxに書かれています。StatusクラスのrenderメソッドはRTの場合、画像つきの場合で場合分けがされていますが普通のトゥートをしたとしてそこは省略、 #code{{ render () { let media = ''; const { status, ...other } = this.props; if (status === null) { return <div />; } if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') { 省略 } if (status.get('media_attachments').size > 0 && !this.props.muted) { 省略 } return ( <div className={this.props.muted ? 'status muted' : 'status'}> <div className='status__info'> <div className='status__info-time'> <a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a> </div> <a onClick={this.handleAccountClick.bind(this, status.getIn(['account', 'id']))} href={status.getIn(['account', 'url'])} className='status__display-name'> <div className='status__avatar'> <Avatar src={status.getIn(['account', 'avatar'])} staticSrc={status.getIn(['account', 'avatar_static'])} size={48} /> </div> <DisplayName account={status.get('account')} /> </a> </div> <StatusContent status={status} onClick={this.handleClick} /> {media} <StatusActionBar {...this.props} /> </div> ); } }} といいつつ長いですが、さらにピックアップするとアカウント名クリックで反応する部分のコードはここです。 &ref(status-account-click.png); #code{{ <a onClick={this.handleAccountClick.bind(this, status.getIn(['account', 'id']))} href={status.getIn(['account', 'url'])} className='status__display-name'> <div className='status__avatar'> <Avatar src={status.getIn(['account', 'avatar'])} staticSrc={status.getIn(['account', 'avatar_static'])} size={48} /> </div> <DisplayName account={status.get('account')} /> </a> }} というわけで、クリックするとhandleAccountClickメソッドが呼ばれています。このメソッドはrenderメソッドの上に書かれていて、 #code{{ handleAccountClick (id, e) { if (e.button === 0) { e.preventDefault(); this.context.router.push(`/accounts/${id}`); } } }} となっています。ふぅむ、アカウント部分をポイントすると「@ユーザ名」なのにクリックすると「/accounts/1」みたいにアドレスバーが変わるのはここで処理が行われているからなんですね。 routerというのは、React Routerのことでしょう。pushとあるので、指定したURLを積む、つまり、指定したURLに移動するという意味なのでしょう。 Mastodonクラスに戻って、「/accounts/id」に対応するクラスを調べます。すると、 #code{{ <Route path='accounts/:accountId' component={AccountTimeline} /> }} とAccountTimelineクラスが使われていることがわかります。 **app/assets/javascripts/components/features/account_timeline/index.jsx [#fb720ff0] というわけで、AccountTimelineに移動。enderメソッド #code{{ render () { const { statusIds, isLoading, hasMore, me } = this.props; if (!statusIds && isLoading) { return ( <Column> <LoadingIndicator /> </Column> ); } return ( <Column> <ColumnBackButton /> <StatusList prepend={<HeaderContainer accountId={this.props.params.accountId} />} scrollKey='account_timeline' statusIds={statusIds} isLoading={isLoading} hasMore={hasMore} me={me} onScrollToBottom={this.handleScrollToBottom} /> </Column> ); } }} AccountTimelineは以下のように表示されます。 &ref(account-timeline.png); 今回興味があるのは上部のユーザ情報部分にあるフォローボタンです。AccountTimelineのrenderメソッドを見る感じ、それはStatusListコンポーネントのprependで指定されているHeaderContainerに書かれていそうだということでそちらに視点を移動。 ***app/assets/javascripts/components/features/account_timeline/containers/header_container.jsx [#ubf0a4aa] イベントハンドラが書いてあってコンポーネント自体の定義はHeaderへ続く。このファイルはまた後出てくるので覚えておいてください。 ***app/assets/javascripts/components/features/account_timeline/components/header.jsx [#i0137036] Heeaderクラスのrenderメソッド。 #code{{ render () { const { account, me } = this.props; if (account === null) { return <MissingIndicator />; } return ( <div className='account-timeline__header'> <InnerHeader account={account} me={me} onFollow={this.handleFollow} /> <ActionBar account={account} me={me} onBlock={this.handleBlock} onMention={this.handleMention} onReport={this.handleReport} onMute={this.handleMute} /> </div> ); } }} ActionBarって下にあるハンバーガーメニューだろうので、InnerHeaderに進みます。少しややこしいですが、InnerHeaderとは、 #code{{ import InnerHeader from '../../account/components/header'; }} と、features/account/componentsにあるheader.jsxで定義されているクラスです。 ***app/assets/javascripts/components/features/account/components/header.jsx [#lc2d2813] renderメソッド全部載せると長いので要点だけ、 #code{{ if (me !== account.get('id')) { if (account.getIn(['relationship', 'requested'])) { 省略 } else if (!account.getIn(['relationship', 'blocking'])) { actionBtn = ( <div style={ { position: 'absolute', top: '10px', left: '20px' } }> <IconButton size={26} icon={account.getIn(['relationship', 'following']) ? 'user-times' : 'user-plus'} active={account.getIn(['relationship', 'following'])} title={intl.formatMessage(account.getIn(['relationship', 'following']) ? messages.unfollow : messages.follow)} onClick={this.props.onFollow} /> </div> ); } } }} ここがフォローボタンを出力しているコードです。で、クリックされるとプロパティ中のonFollowが呼ばれるようになっています。 ***イベント発生時のフロー [#b6612372] コンポーネントを構築する際は徐々に細かいパーツに描画処理が移っていきました。 クリックなどのイベントが発生すると、今度は逆にイベントがより大きなコンポーネントに伝搬していきます。もう一度確認してみると、 app/assets/javascripts/components/features/account/components/header.jsx(末端) #code{{ <div style={ { position: 'absolute', top: '10px', left: '20px' } }> <IconButton size={26} icon={account.getIn(['relationship', 'following']) ? 'user-times' : 'user-plus'} active={account.getIn(['relationship', 'following'])} title={intl.formatMessage(account.getIn(['relationship', 'following']) ? messages.unfollow : messages.follow)} onClick={this.props.onFollow} /> </div> }} app/assets/javascripts/components/features/account_timeline/components/header.jsx #code{{ handleFollow () { this.props.onFollow(this.props.account); } render () { 省略 return ( <div className='account-timeline__header'> <InnerHeader account={account} me={me} onFollow={this.handleFollow} /> 省略 </div> ); } }} app/assets/javascripts/components/features/account_timeline/containers/header_container.jsx #code{{ const mapDispatchToProps = (dispatch, { intl }) => ({ onFollow (account) { if (account.getIn(['relationship', 'following'])) { dispatch(unfollowAccount(account.get('id'))); } else { dispatch(followAccount(account.get('id'))); } }, 省略 }); export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Header)); }} と、ここまでさかのぼったところでdispatchが出てきました。とすると、followAccountはActionであると考えられます。 **app/assets/javascripts/components/actions/accoounts.jsx [#s8ebc160] followAccountが書いてありそうなもの、ということでactions以下を見ると、accounts.jsxがあるので見てみます。 #code{{ export function followAccount(id) { return (dispatch, getState) => { dispatch(followAccountRequest(id)); api(getState).post(`/api/v1/accounts/${id}/follow`).then(response => { dispatch(followAccountSuccess(response.data)); }).catch(error => { dispatch(followAccountFail(error)); }); }; }; }} ありました。というわけで、フォローボタンが押されるとサーバの「/api/v1/accounts/:id/follow」が呼び出されるようです。 けっこう長くなったので一旦ここで切ります。 *おわりに [#p910a8ab] 今回はユーザをフォローするにあたり、 -そもそもユーザ画面はどうやって表示されるのか --トゥート(Status)からたどる場合、アカウント名をクリックするとどういうことが行われるのか -フォローボタンは誰が表示しているのか、ボタンをクリックすると何が起こるのか を見てきました。だいぶ慣れてきましたが、コンポーネントのツリー、また、クリックしたときにどのようにイベント情報が流れていくかはよく確認しないと「これがやってそうだけど、こいつは誰から呼ばれるの?」という事態になりそうに感じました。