[Misskey]ノートに複数リアクションをつけられるようにする

この記事は大学実験の成果物として作成しました。一部誤りがある可能性があるのでご了承ください。

# 目次
- [はじめに](#はじめに)
- [ビルド方法](#ビルド方法)
- [現状の問題点](#現状の問題点)
- [解決法](#解決法)
- [実験結果](#実験結果)
- [今後の課題とまとめ](#今後の課題とまとめ)
- [おまけ](#おまけ)

## はじめに
misskeyはX（旧Twitter)ライクなオープンソースソフトウェアです。Xとの違いの一つに、ノート（いわゆるツイート）に対して「いいね」以外の様々な絵文字リアクションをつけられる機能があり、見栄えがとても良いです。
しかし、一つのノートに対して一人のユーザーは特定のリアクションを一つしか行うことができないため、せっかく様々なリアクションをつけられるのに、この機能を活用しきれていない状況です。もったいない。
そのため、この仕様を改善し一人のユーザーでも一つのノートに「♡」や「👍️」を複数つけられるようにしました。
![スクリーンショット 2024-10-24 15.05.40.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2709993/62cb4cb6-12ca-a869-d903-a5aa2c5a6033.png)

↑絵文字リアクションの例。様々なリアクションを行うことができる。

## ビルド方法
devcontainerを用いるため、VS CodeとDockerを使用します。
VS Codeの拡張機能で”dev container”を入れておきましょう。
より詳しい情報は
https://github.com/misskey-dev/misskey/blob/develop/CONTRIBUTING.md
で確認してください。



### 1. Misskeyリポジトリをクローン
まず、MisskeyのGitHubリポジトリからコードをクローンします。

```bash
git clone https://github.com/misskey-dev/misskey.git
```
### 2.ディレクトリに移動してコンテナを開く
クローンしたディレクトリに移動します。

```bash
cd misskey
```
その後、VS Codeなどの環境で **「コンテナで再度開く」** という通知が表示されるので、それをクリックします。

### 3.Misskeyをローカルで起動
コンテナ上のターミナルで以下のコマンドを実行します。

```bash
pnpm dev
```
これでローカル環境でMisskeyを使用できるようになります。

ブラウザで以下のURLを入力して動作を確認してください。

```arduino
http://localhost:5173
```
Misskeyの画面が表示されれば成功です！

## 現状の問題点
機能を追加するにあたって、フロントエンド、バックエンド、データベースそれぞれに問題が見つかりました。まずはそれぞれについて詳しく説明していきます。

### 1.データベース
ユーザーがあるノートにどのようなリアクションをしたのかを保存するテーブルがあります。なにかリアクションを行った場合、そのテーブルに挿入される仕組みです。そのテーブル構造は以下のようになっています。

![スクリーンショット 2024-10-28 14.34.25.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2709993/b00c16f1-4f7f-56c5-c83b-c26dd1cfb643.png)

* ここの **UNIQUE, btree(“userId” , “noteId”)** という制約により、同じユーザーが同じノートに複数リアクションがつけられない構造になっていました。

### 2.バックエンド
リアクションを行うと、”create.ts”の中のエラー処理が行われます。その中で”すでにリアクションをしている”ということを示すエラーを発見しました。
```
alreadyReacted: { message: 'You are already reacting to that note.',
                     code: 'ALREADY_REACTED', 			
                       id: '71efcf98-86d6-4e2b-b2ad-9d032369366b',},
```
更に、このファイルの下にあるコードを確認するとこのようにreactionServiceの中のcreate関数によって生じていることが確認できました。
```
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
	constructor(
		private getterService: GetterService,
		private reactionService: ReactionService,
	) {
		super(meta, paramDef, async (ps, me) => {
			const note = await this.getterService.getNote(ps.noteId).catch(err => {
				if (err.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote);
				throw err;
			});
			await this.reactionService.create(me, note, ps.reaction).catch(err => {
				if (err.id === '51c42bb4-931a-456b-bff7-e5a8a70dd298') throw new ApiError(meta.errors.alreadyReacted);
				if (err.id === 'e70412a4-7197-4726-8e74-f3e0deb92aa7') throw new ApiError(meta.errors.youHaveBeenBlocked);
				if (err.id === '12c35529-3c79-4327-b1cc-e2cf63a71925') throw new ApiError(meta.errors.cannotReactToRenote);
				throw err;
			});
			return;
		});
	}
}
```
つまり、　流れとしては以下のようになります。
* 関数**reactionService.create(me, note, ps.reaction)** でノートにリアクションをつける仕組みになっています。（meが自分、noteがリアクション先のノート、ps.reactionがつけたリアクションと理解すればわかりやすいです。）
* 関数**reactionService.create(me, note, ps.reaction)** は処理が失敗した際に、エラーを吐きます。（例えばブロックされています等😭）その中で、もうリアクションをしたノートにリアクションをしていますよ、と示すエラーだった場合、
**if (err.id === '51c42bb4-931a-456b-bff7-e5a8a70dd298') throw new ApiError(meta.errors.alreadyReacted);** 
このコードから、alreadyReactedというエラーをthrow(投げる）ことがわかります。
* そして、フロントエンドに **message: 'You are already reacting to that note.'** というエラーを返し、処理が失敗したことが伝わります。

以上より、問題は関数**reactionService.create(me, note, ps.reaction)** にあることがわかります。ここのエラーを消すことができれば良さそうです。

では、最後に関数**reactionService.create(me, note, ps.reaction)** の中身を見てみましょう！
```
		try {
			await this.noteReactionsRepository.insert(record);
		} catch (e) {
			if (isDuplicateKeyValueError(e)) {
			  const exists = await this.noteReactionsRepository.findOneByOrFail({
				noteId: note.id,
				userId: user.id,
			});

			if (exists.reaction !== reaction) {
			// 別のリアクションがすでにされていたら置き換える
				await this.delete(user, note);
				await this.noteReactionsRepository.insert(record);
			} else {
				// 同じリアクションがすでにされていたらエラー
				throw new IdentifiableError('51c42bb4-931a-456b-bff7-e5a8a70dd298');
			}
			} else {
				throw e;
			}
		}
```
このコードを解説していきます。
* **await this.noteReactionsRepository.insert(record);** により、DBにアクセスし、データを挿入しようと試みます。
* ここで、エラーが返った場合、**isDuplicateKeyValueError(e)** でそのエラーが値が被っていることに対するエラーかどうか識別しています。そして、それが正だった場合、existsにもとのデータを代入し、元データのリアクション（以前行ったリアクション）と今行ったリアクションが違うなら置き換え、同じなら先程のエラーを返す仕組みになっています。（エラーidが'51c42bb4-931a-456b-bff7-e5a8a70dd298'で一致していますね。）

長くなりましたが、以上より別のリアクションが来た場合、**置き換えずにそのままDBに挿入する** ように変更すれば良さそうです！

### 3.フロントエンド
リアクションが複数個くる想定はないため、リアクションの状態を一つの変数で保持していました。様々なフロントエンドの関数がその変数をもとに処理を行っているため、すべての関数を変える必要があります。一例として、下記にリアクションボタンを押すと発火する関数　**toggleReaction()** を記載します。
```
async function toggleReaction() {
	if (!canToggle.value) return;

	const oldReaction = props.note.myReaction;
	if (oldReaction && oldReaction == props.reaction) {

		sound.playMisskeySfx('reaction');

		if (mock) {
			emit('reactionToggled', props.reaction, (props.count - 1));
			return;
		}

		misskeyApi('notes/reactions/delete', {
			noteId: props.note.id,
		}).then(() => {
			if (oldReaction !== props.reaction) {
				misskeyApi('notes/reactions/create', {
					noteId: props.note.id,
					reaction: props.reaction,
				});
			}
		});
	} else {
		sound.playMisskeySfx('reaction');

		if (mock) {
			emit('reactionToggled', props.reaction, (props.count + 1));
			return;
		}

		misskeyApi('notes/reactions/create', {
			noteId: props.note.id,
			reaction: props.reaction,
		});
		if (props.note.text && props.note.text.length > 100 && (Date.now() - new Date(props.note.createdAt).getTime() < 1000 * 3)) {
			claimAchievement('reactWithoutRead');
		}
	}
}
```
もとのリアクションの状態を変数 **oldReaction**として保持しています。
* **if (oldReaction && oldReaction == props.reaction)** が正、つまりoldReactionが存在し、入力されたリアクションと等しい場合、"/delete"が呼び出されていることがわかります。（これはいいねボタンを想像するとわかりやすいです。もしいいねしていた場合、もう一度いいねボタンを押すと消えるという処理を行っています）
* misskeyではいいね以外のリアクションもたくさんあります。そのため、**if (oldReaction !== props.reaction)** で入力されたリアクションがoldReactionと違う場合、deleteしたあとに入力されたリアクションを引数として"/create"を呼び出し、**置き換え** をしていることがわかります。

他にもフロントエンドはoldReaction変数のもと、自分が行ったリアクションを緑色にしたりするなど、複数リアクションとはそぐわない実装をしていました。


## 解決法
上記の問題を解決するにあたり、方針を下記のように定めました。
### 「トグル式」から「配列による保持」へ変更

#### 1.データベース
+  **同じノート ∧ 同じユーザー** から**同じノート ∧ 同じユーザー ∧ 同じ種類** へと制約を緩める

#### 2.バックエンド
+ 複数のリアクションをDBから取得するAPIエンドポイント (getMyReactions) を追加
+ 既存のAPIエンドポイント（create / delete）の修正

#### 3.フロントエンド
+ oldReactionを廃止し、以前のリアクションを配列として取得
+ リアクションを押す「+」ボタンの仕様を変更

それぞれを詳しく解説していきます！

### 1.データベース
データベースにアクセスし、ゴリ押しですが制約を削除してしまいます。まずはデータベースにアクセスしましょう。
Docker Desktopを開き、データベースを開いているコンテナに移動します。(postgres:15-alpineと書いてあるコンテナに入り、Execから、コマンドを入力できます。）

```
psql -U postgres -d misskey
```
上記を入力してデータベースにアクセスしましょう。そしてpostgresQLのコマンドを入力し、必要な操作を行います。

```
DROP INDEX "IDX_ad0c221b25672daf2df320a817";
```
こちらを入力することで、
![スクリーンショット 2024-10-28 14.34.25.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2709993/b00c16f1-4f7f-56c5-c83b-c26dd1cfb643.png)
先程の**UNIQUE, btree(“userId” , “noteId”)** 制約を消すことができました！
しかし、今現在の状況では、同じユーザーが同じノートに**同じリアクション**を複数行うことができてしまいます。
```
CREATE UNIQUE INDEX "IDX_ad0c221b25672daf2df320a817" ON public.note_reaction USING btree ("userId", "noteId", "reaction")
```
このコマンドを入力し、**同じノート ∧ 同じユーザー ∧ 同じ種類**の制約を追加しましょう！この際、INDEXを同じにすることが必要ですので注意してください。
これでデータベースの変更は終わりです。

### 2.バックエンド
#### はじめにAPIエンドポイントgetMyReactionsの追加について説明します。
```
export class ReactionService {
	... (中略) ...
	@bindThis
	public async getReactions(user: { id: MiUser['id']; host: MiUser['host']; isBot: MiUser['isBot'] }, noteId: MiNote['id']): Promise<MiNoteReaction[]> {
		return this.noteReactionsRepository.find({
			where: {
				noteId: noteId,
				userId: user.id
			}
		});
	}
 {{{
 ```
 まずはこのようなgetReactios関数を追加しました。これはデータベースにアクセスし、特定のnoteIdとuserIdに一致するデータをすべて返すという関数です。そして、この関数をAPIで呼び出します。

 ```
 @Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> {
	constructor(
		private getterService: GetterService,
		private reactionService: ReactionService,
	) {
		super(meta, paramDef, async (ps, me) => {
			// ツイート（ノート）を取得
			const note = await this.getterService.getNote(ps.noteId).catch(err => {
				if (err.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote);
				throw err;
			});

			// 特定のユーザーによるリアクションを取得
			const reactions = await this.reactionService.getReactions(me, note.id); // 第1引数にmeを指定しよう！

			return reactions; // 取得したリアクションを返す
		});
	}
}
```
このように定義することで、先程の関数をエンドポイントとして定義できます！（他にも定義や呼び出しを他の関数で行う必要があるのですが、重要ではないので省略します）
"return reactions"とあるように、このAPI関数(getMyReactions)はgetReactionsを呼び出し、返ってきたリアクションを配列にして、フロントエンドに送る役割をしています。
これでフロントエンド側でも今まで行ったリアクションを取得することができるようになりました！

#### 続いて既存の関数の修正も見ていきましょう。

・create関数の修正
リアクションをデータベースに追加する関数です。
```
@bindThis
	public async create(user: { id: MiUser['id']; host: MiUser['host']; isBot: MiUser['isBot'] }, note: MiNote, _reaction?: string | null) {
	... (中略) ...
					try {
					const exists = await this.noteReactionsRepository.findOneByOrFail({
						noteId: note.id,
						userId: user.id,
						reaction: reaction, // 既存リアクションの検索条件にリアクションの中身も加える
					});
					throw new IdentifiableError('51c42bb4-931a-456b-bff7-e5a8a70dd298', 'Duplicate reaction');
				} catch (findError) {
					if (findError instanceof EntityNotFoundError) {
						// 同じユーザーの異なるリアクションの場合、新しいリアクションを挿入
						await this.noteReactionsRepository.insert(record);
					} else {
						throw findError;
					}
				}
			} else {
				throw e;
			}
		}
  ```
  変更箇所は上コードの 
  **reaction: reaction, // 既存リアクションの検索条件にリアクションの中身も加える**
  という部分です。これがないと、同じノートに同じユーザーが複数リアクションをしているというエラーが出てしまいます。この一文を追加することで、同じノートに同じユーザーが同じリアクションをしていた場合のみ、エラーが出るように修正しました。

・delete関数の修正
```
public async delete(user: { id: MiUser['id']; host: MiUser['host']; isBot: MiUser['isBot']; }, note: MiNote, myreaction: string) {
		// if already unreacted
		const exist = await this.noteReactionsRepository.findOneBy({
			noteId: note.id,
			userId: user.id,
			reaction: myreaction　// 既存リアクションの検索条件にリアクションの中身も加える
		});
  ```
  こちらも同様に、既存リアクションの検索条件にリアクションの中身も加えるという操作を加えました。今まではユーザーが特定のノートに行うリアクションは一意に定まっていたので、noteIdとuserIdをもとに検索すれば良かったのですが、複数つけられるとなるとリアクションも引数に取る必要があります。この３つの検索条件であれば一意に定まるので、しっかりと消したいリアクションのみを削除することができるようになりました！

### 3.フロントエンド
#### getMyReactionsからリアクションを取得する
oldReactionを廃止し、APIを叩いてリアクションを取得します。バックエンドで新しく作ったgetMyReactions関数から配列を取得し、変数に格納しましょう。
```
async function getMyReactions(targetNote: Misskey.entities.Note): Promise<string[]> {
	try {
		const response: string[] = await misskeyApi('notes/reactions/getmyreactions', {
			noteId: targetNote.id
		});
		const reactions: string[] = []; // 配列の初期化
		response.forEach(element => {
			reactions.push(element.reaction);
		});
		return reactions;
	} catch (error) {
		// エラーが発生した場合の処理
		console.error("API call failed:", error);
		return [];
	}
}
```
misskeyApi(...)という部分がAPIを叩くコードになります。ここでノートに対するリアクションを取得し、reactions配列として返すことを行っています。

この配列を用いて、フロントエンドの処理を行うことができるようになりました！

#### -ボタンの削除
フロントエンドは基本的に今まで使用していたoldReactionsをreactions配列に置き換えることによって、処理が完結します。しかし、ボタン操作に関しては別の修正が必要となったので、そちらを紹介します。
今までの仕様では、下の画像のようにリアクションをすると、+ボタンが-ボタンとなってしまいリアクションを追加で増やすことができません。

![スクリーンショット 2024-11-21 21.14.22.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2709993/975a55c3-786a-7822-f48f-017dcb25dcf9.png)
リアクションをすると+ボタンが消えてしまう↓
![スクリーンショット 2024-11-21 21.14.24.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2709993/804a6849-55fc-4f5c-bfc1-395f48d356c3.png)

これは、リアクションをしているかどうかでボタンの表示を切り替える仕様となっているためです。
```
<i v-else-if="appearNote.myReaction != null" class="ti ti-minus" style="color: var(--accent);"></i>
```
具体的には、このコードのappearNote.myReactionが"null"でない、つまりリアクションをしている場合にclass="ti ti-minus"（-ボタンのクラス）に切り替えるという仕組みになっています。

よって、これを削除してしまえば+ボタンのまま、複数リアクションをつけられるようになります！


## 実験結果
![スクリーンショット 2024-11-21 21.32.53.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2709993/8463f5ae-a07f-c96f-afd0-11f3571fdae5.png)
このように無事、複数リアクションをつけられる様になりました！


## 今後の課題とまとめ
* 今現在、ノートを読み込むたびにAPIを叩いてリアクションを取得しています。このままではネットワークに負荷がかかってしまうため、一度呼び出したあとはフロントエンド側で保持できるようにしたいと考えています。
* ユニットテスト等を何も通していません。見過ごしているバグがもしかしたらたくさんあるかもしれないので、テストをセットしたいと思っています。

はじめは何もわからない状態でしたが、意外とコードがわからなくても理解できるんだなぁという所存です。素人が軽く見ても理解できるように整理されたディレクトリ構成、コード記述になっているのだと思います。真似したいです。

## おまけ
Misskeyには実績機能があります。
![スクリーンショット 2024-11-21 21.42.36.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2709993/f97fdd17-f9ba-a98a-17b2-ad31e21b6417.png)
例えばMisskeyを60分開きっぱなしにすると、上のような実績を獲得できます。

これをもとに、α課題としてリアクションを20種類行うと、「オーバーリアクション」という実績を取得できるようにしました。

#### 実装方法

実績がまとまっているachivementコードに下記の一文を追加しました。
```
_achievements:
  earnedAt: "獲得日時"
  _types:
	  ... (中略) ...
		_overReaction:
		  title: "オーバーリアクション"
		  description: "1つのノートにリアクションを20種類以上つけた"
		  flavor: "そんなにリアクションばっかりして、疲れないの？"
```
リアクション配列の長さが20以上になると、この実績が出るようになっています。
各自でいろんな実績を作れるのは面白いですね。