From 26845416932ecf0ce035240ce934d2afb77bf550 Mon Sep 17 00:00:00 2001
From: MeiMei <30769358+mei23@users.noreply.github.com>
Date: Mon, 18 Mar 2019 00:03:57 +0900
Subject: [PATCH] Custom reaction (#4517)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* Custom reaction

* increase limit of reactions/delete

* リアクションの場合は OS標準の絵文字を使用 を迂回する

* カスタムリアクションを無効にする設定

* fix

* disableCustomReaction --> enableEmojiReaction

* Avoid MFM rendering

* :art:

* :art:

* Auto accept

* custom emoji reaction

* Improve usability

* Extract emojiRegex

* Fix

* Clean up

* :art:

* :art:

* toDbReaction で reaction は必須に

あとフォールバックは like に

* Clean up

* Make required

* https://github.com/syuilo/misskey/pull/4517/commits/3eb08748feeaab9ee5c5b505c870f97d7edbeb0d#r266241728

* Refactor

* Allow null
---
 locales/ja-JP.yml                             |  1 +
 src/client/app/admin/views/instance.vue       |  4 +
 .../app/common/views/components/emoji.vue     |  8 +-
 .../common/views/components/reaction-icon.vue | 45 +++++----
 .../views/components/reaction-picker.vue      | 55 ++++++++++-
 .../components/reactions-viewer.reaction.vue  |  4 -
 src/mfm/language.ts                           |  3 +-
 src/misc/emoji-regex.ts                       |  1 +
 src/misc/fetch-meta.ts                        |  1 +
 src/misc/reaction-lib.ts                      | 59 ++++++++++++
 src/models/meta.ts                            |  1 +
 src/models/note.ts                            | 13 ++-
 src/remote/activitypub/kernel/like.ts         | 10 +-
 src/server/api/endpoints/admin/update-meta.ts | 11 +++
 src/server/api/endpoints/meta.ts              |  5 +
 .../api/endpoints/notes/reactions/create.ts   |  3 +-
 .../api/endpoints/notes/reactions/delete.ts   |  2 +-
 src/services/note/reaction/create.ts          |  5 +
 test/reaction-lib.ts                          | 91 +++++++++++++++++++
 19 files changed, 278 insertions(+), 44 deletions(-)
 create mode 100644 src/misc/emoji-regex.ts
 create mode 100644 src/misc/reaction-lib.ts
 create mode 100644 test/reaction-lib.ts

diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index 7f9651b69a..cec462955f 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -1238,6 +1238,7 @@ admin/views/instance.vue:
   disable-local-timeline: "ローカルタイムラインを無効にする"
   disable-global-timeline: "グローバルタイムラインを無効にする"
   disabling-timelines-info: "これらのタイムラインを無効にしても、管理者およびモデレーターは引き続き利用できます。"
+  enable-emoji-reaction: "リアクションに絵文字を使えるようにする"
   invite: "招待"
   save: "保存"
   saved: "保存しました"
diff --git a/src/client/app/admin/views/instance.vue b/src/client/app/admin/views/instance.vue
index 8b3ec0cb26..ab337f1871 100644
--- a/src/client/app/admin/views/instance.vue
+++ b/src/client/app/admin/views/instance.vue
@@ -25,6 +25,7 @@
 			<ui-switch v-model="disableLocalTimeline">{{ $t('disable-local-timeline') }}</ui-switch>
 			<ui-switch v-model="disableGlobalTimeline">{{ $t('disable-global-timeline') }}</ui-switch>
 			<ui-info>{{ $t('disabling-timelines-info') }}</ui-info>
+			<ui-switch v-model="enableEmojiReaction">{{ $t('enable-emoji-reaction') }}</ui-switch>
 		</section>
 		<section class="fit-bottom">
 			<header><fa icon="cloud"/> {{ $t('drive-config') }}</header>
@@ -155,6 +156,7 @@ export default Vue.extend({
 			disableRegistration: false,
 			disableLocalTimeline: false,
 			disableGlobalTimeline: false,
+			enableEmojiReaction: true,
 			mascotImageUrl: null,
 			bannerUrl: null,
 			errorImageUrl: null,
@@ -206,6 +208,7 @@ export default Vue.extend({
 			this.disableRegistration = meta.disableRegistration;
 			this.disableLocalTimeline = meta.disableLocalTimeline;
 			this.disableGlobalTimeline = meta.disableGlobalTimeline;
+			this.enableEmojiReaction = meta.enableEmojiReaction;
 			this.mascotImageUrl = meta.mascotImageUrl;
 			this.bannerUrl = meta.bannerUrl;
 			this.errorImageUrl = meta.errorImageUrl;
@@ -267,6 +270,7 @@ export default Vue.extend({
 				disableRegistration: this.disableRegistration,
 				disableLocalTimeline: this.disableLocalTimeline,
 				disableGlobalTimeline: this.disableGlobalTimeline,
+				enableEmojiReaction: this.enableEmojiReaction,
 				mascotImageUrl: this.mascotImageUrl,
 				bannerUrl: this.bannerUrl,
 				errorImageUrl: this.errorImageUrl,
diff --git a/src/client/app/common/views/components/emoji.vue b/src/client/app/common/views/components/emoji.vue
index b4618a8d85..65b5683c2f 100644
--- a/src/client/app/common/views/components/emoji.vue
+++ b/src/client/app/common/views/components/emoji.vue
@@ -29,7 +29,11 @@ export default Vue.extend({
 		customEmojis: {
 			required: false,
 			default: () => []
-		}
+		},
+		isReaction: {
+			type: Boolean,
+			default: false
+		},
 	},
 
 	data() {
@@ -46,7 +50,7 @@ export default Vue.extend({
 		},
 
 		useOsDefaultEmojis(): boolean {
-			return this.$store.state.device.useOsDefaultEmojis;
+			return this.$store.state.device.useOsDefaultEmojis && !this.isReaction;
 		}
 	},
 
diff --git a/src/client/app/common/views/components/reaction-icon.vue b/src/client/app/common/views/components/reaction-icon.vue
index d413bece6b..1991669507 100644
--- a/src/client/app/common/views/components/reaction-icon.vue
+++ b/src/client/app/common/views/components/reaction-icon.vue
@@ -1,19 +1,5 @@
 <template>
-<span class="mk-reaction-icon">
-	<img v-if="reaction == 'like'" src="https://twemoji.maxcdn.com/2/svg/1f44d.svg" :alt="$t('@.reactions.like')">
-	<img v-if="reaction == 'love'" src="https://twemoji.maxcdn.com/2/svg/2764.svg" :alt="$t('@.reactions.love')">
-	<img v-if="reaction == 'laugh'" src="https://twemoji.maxcdn.com/2/svg/1f606.svg" :alt="$t('@.reactions.laugh')">
-	<img v-if="reaction == 'hmm'" src="https://twemoji.maxcdn.com/2/svg/1f914.svg" :alt="$t('@.reactions.hmm')">
-	<img v-if="reaction == 'surprise'" src="https://twemoji.maxcdn.com/2/svg/1f62e.svg" :alt="$t('@.reactions.surprise')">
-	<img v-if="reaction == 'congrats'" src="https://twemoji.maxcdn.com/2/svg/1f389.svg" :alt="$t('@.reactions.congrats')">
-	<img v-if="reaction == 'angry'" src="https://twemoji.maxcdn.com/2/svg/1f4a2.svg" :alt="$t('@.reactions.angry')">
-	<img v-if="reaction == 'confused'" src="https://twemoji.maxcdn.com/2/svg/1f625.svg" :alt="$t('@.reactions.confused')">
-	<img v-if="reaction == 'rip'" src="https://twemoji.maxcdn.com/2/svg/1f607.svg" :alt="$t('@.reactions.rip')">
-	<template v-if="reaction == 'pudding'">
-		<img v-if="$store.getters.isSignedIn && $store.state.settings.iLikeSushi" src="https://twemoji.maxcdn.com/2/svg/1f363.svg" :alt="$t('@.reactions.pudding')">
-		<img v-else src="https://twemoji.maxcdn.com/2/svg/1f36e.svg" :alt="$t('@.reactions.pudding')">
-	</template>
-</span>
+<mk-emoji :emoji="str.startsWith(':') ? null : str" :name="str.startsWith(':') ? str.substr(1, str.length - 2) : null" :is-reaction="true" :custom-emojis="customEmojis" :normal="true"/>
 </template>
 
 <script lang="ts">
@@ -21,7 +7,34 @@ import Vue from 'vue';
 import i18n from '../../../i18n';
 export default Vue.extend({
 	i18n: i18n(),
-	props: ['reaction']
+	props: {
+		reaction: {
+			type: String,
+			required: true
+		},
+	},
+	data() {
+		return {
+			customEmojis: (this.$root.getMetaSync() || { emojis: [] }).emojis || []
+		};
+	},
+	computed: {
+		str(): any {
+			switch (this.reaction) {
+				case 'like': return '👍';
+				case 'love': return '❤';
+				case 'laugh': return '😆';
+				case 'hmm': return '🤔';
+				case 'surprise': return '😮';
+				case 'congrats': return '🎉';
+				case 'angry': return '💢';
+				case 'confused': return '😥';
+				case 'rip': return '😇';
+				case 'pudding': return (this.$store.getters.isSignedIn && this.$store.state.settings.iLikeSushi) ? '🍣' : '🍮';
+				default: return this.reaction;
+			}
+		},
+	},
 });
 </script>
 
diff --git a/src/client/app/common/views/components/reaction-picker.vue b/src/client/app/common/views/components/reaction-picker.vue
index 54c8e2a68f..af340dcf7a 100644
--- a/src/client/app/common/views/components/reaction-picker.vue
+++ b/src/client/app/common/views/components/reaction-picker.vue
@@ -3,7 +3,7 @@
 	<div class="backdrop" ref="backdrop" @click="close"></div>
 	<div class="popover" :class="{ isMobile: $root.isMobile }" ref="popover">
 		<p v-if="!$root.isMobile">{{ title }}</p>
-		<div ref="buttons" :class="{ showFocus }">
+		<div class="buttons" ref="buttons" :class="{ showFocus }">
 			<button @click="react('like')" @mouseover="onMouseover" @mouseout="onMouseout" tabindex="1" :title="$t('@.reactions.like')" v-particle><mk-reaction-icon reaction="like"/></button>
 			<button @click="react('love')" @mouseover="onMouseover" @mouseout="onMouseout" tabindex="2" :title="$t('@.reactions.love')" v-particle><mk-reaction-icon reaction="love"/></button>
 			<button @click="react('laugh')" @mouseover="onMouseover" @mouseout="onMouseout" tabindex="3" :title="$t('@.reactions.laugh')" v-particle><mk-reaction-icon reaction="laugh"/></button>
@@ -15,6 +15,9 @@
 			<button @click="react('rip')" @mouseover="onMouseover" @mouseout="onMouseout" tabindex="9" :title="$t('@.reactions.rip')" v-particle><mk-reaction-icon reaction="rip"/></button>
 			<button @click="react('pudding')" @mouseover="onMouseover" @mouseout="onMouseout" tabindex="10" :title="$t('@.reactions.pudding')" v-particle><mk-reaction-icon reaction="pudding"/></button>
 		</div>
+		<div v-if="enableEmojiReaction" class="text">
+			<input v-model="text" placeholder="または絵文字を入力" @keyup.enter="reactText" @input="tryReactText" v-autocomplete="{ model: 'text' }">
+		</div>
 	</div>
 </div>
 </template>
@@ -23,6 +26,7 @@
 import Vue from 'vue';
 import i18n from '../../../i18n';
 import anime from 'animejs';
+import { emojiRegex } from '../../../../../misc/emoji-regex';
 
 export default Vue.extend({
 	i18n: i18n('common/views/components/reaction-picker.vue'),
@@ -56,6 +60,8 @@ export default Vue.extend({
 	data() {
 		return {
 			title: this.$t('choose-reaction'),
+			text: null,
+			enableEmojiReaction: false,
 			focus: null
 		};
 	},
@@ -94,6 +100,10 @@ export default Vue.extend({
 	},
 
 	mounted() {
+		this.$root.getMeta().then(meta => {
+			this.enableEmojiReaction = meta.enableEmojiReaction;
+		});
+
 		this.$nextTick(() => {
 			this.focus = 0;
 
@@ -143,6 +153,17 @@ export default Vue.extend({
 			});
 		},
 
+		reactText() {
+			if (!this.text) return;
+			this.react(this.text);
+		},
+
+		tryReactText() {
+			if (!this.text) return;
+			if (!this.text.match(emojiRegex)) return;
+			this.reactText();
+		},
+
 		onMouseover(e) {
 			this.title = e.target.title;
 		},
@@ -256,9 +277,9 @@ export default Vue.extend({
 			color var(--popupFg)
 			border-bottom solid var(--lineWidth) var(--faceDivider)
 
-		> div
+		> .buttons
 			padding 4px
-			width 240px
+			width 216px
 			text-align center
 
 			&.showFocus
@@ -283,6 +304,9 @@ export default Vue.extend({
 				font-size 24px
 				border-radius 2px
 
+				> *
+					height 1em
+
 				&:hover
 					background var(--reactionPickerButtonHoverBg)
 
@@ -290,4 +314,29 @@ export default Vue.extend({
 					background var(--primary)
 					box-shadow inset 0 0.15em 0.3em rgba(27, 31, 35, 0.15)
 
+		> .text
+			width 216px
+			padding 4px 8px 8px 8px
+
+			> input
+				width 100%
+				padding 10px
+				margin 0
+				text-align center
+				font-size 16px
+				color var(--desktopPostFormTextareaFg)
+				background var(--desktopPostFormTextareaBg)
+				outline none
+				border solid 1px var(--primaryAlpha01)
+				border-radius 4px
+				transition border-color .2s ease
+
+				&:hover
+					border-color var(--primaryAlpha02)
+					transition border-color .1s ease
+
+				&:focus
+					border-color var(--primaryAlpha05)
+					transition border-color 0s ease
+
 </style>
diff --git a/src/client/app/common/views/components/reactions-viewer.reaction.vue b/src/client/app/common/views/components/reactions-viewer.reaction.vue
index b7c321fc9f..ecd22d3f57 100644
--- a/src/client/app/common/views/components/reactions-viewer.reaction.vue
+++ b/src/client/app/common/views/components/reactions-viewer.reaction.vue
@@ -136,12 +136,8 @@ export default Vue.extend({
 		&:hover
 			background var(--reactionViewerButtonHoverBg)
 
-	> .mk-reaction-icon
-		font-size 1.4em
-
 	> span
 		font-size 1.1em
 		line-height 32px
-		vertical-align middle
 		color var(--text)
 </style>
diff --git a/src/mfm/language.ts b/src/mfm/language.ts
index 7b083b99af..fc191d0423 100644
--- a/src/mfm/language.ts
+++ b/src/mfm/language.ts
@@ -3,8 +3,7 @@ import { createLeaf, createTree, urlRegex } from './prelude';
 import { takeWhile, cumulativeSum } from '../prelude/array';
 import parseAcct from '../misc/acct/parse';
 import { toUnicode } from 'punycode';
-
-const emojiRegex = /((?:\ud83d[\udc68\udc69])(?:\ud83c[\udffb-\udfff])?\u200d(?:\u2695\ufe0f|\u2696\ufe0f|\u2708\ufe0f|\ud83c[\udf3e\udf73\udf93\udfa4\udfa8\udfeb\udfed]|\ud83d[\udcbb\udcbc\udd27\udd2c\ude80\ude92]|\ud83e[\uddb0-\uddb3])|(?:\ud83c[\udfcb\udfcc]|\ud83d[\udd74\udd75]|\u26f9)((?:\ud83c[\udffb-\udfff]|\ufe0f)\u200d[\u2640\u2642]\ufe0f)|(?:\ud83c[\udfc3\udfc4\udfca]|\ud83d[\udc6e\udc71\udc73\udc77\udc81\udc82\udc86\udc87\ude45-\ude47\ude4b\ude4d\ude4e\udea3\udeb4-\udeb6]|\ud83e[\udd26\udd35\udd37-\udd39\udd3d\udd3e\uddb8\uddb9\uddd6-\udddd])(?:\ud83c[\udffb-\udfff])?\u200d[\u2640\u2642]\ufe0f|(?:\ud83d\udc68\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68|\ud83d\udc68\u200d\ud83d\udc68\u200d\ud83d\udc66\u200d\ud83d\udc66|\ud83d\udc68\u200d\ud83d\udc68\u200d\ud83d\udc67\u200d\ud83d[\udc66\udc67]|\ud83d\udc68\u200d\ud83d\udc69\u200d\ud83d\udc66\u200d\ud83d\udc66|\ud83d\udc68\u200d\ud83d\udc69\u200d\ud83d\udc67\u200d\ud83d[\udc66\udc67]|\ud83d\udc69\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d[\udc68\udc69]|\ud83d\udc69\u200d\ud83d\udc69\u200d\ud83d\udc66\u200d\ud83d\udc66|\ud83d\udc69\u200d\ud83d\udc69\u200d\ud83d\udc67\u200d\ud83d[\udc66\udc67]|\ud83d\udc68\u200d\u2764\ufe0f\u200d\ud83d\udc68|\ud83d\udc68\u200d\ud83d\udc66\u200d\ud83d\udc66|\ud83d\udc68\u200d\ud83d\udc67\u200d\ud83d[\udc66\udc67]|\ud83d\udc68\u200d\ud83d\udc68\u200d\ud83d[\udc66\udc67]|\ud83d\udc68\u200d\ud83d\udc69\u200d\ud83d[\udc66\udc67]|\ud83d\udc69\u200d\u2764\ufe0f\u200d\ud83d[\udc68\udc69]|\ud83d\udc69\u200d\ud83d\udc66\u200d\ud83d\udc66|\ud83d\udc69\u200d\ud83d\udc67\u200d\ud83d[\udc66\udc67]|\ud83d\udc69\u200d\ud83d\udc69\u200d\ud83d[\udc66\udc67]|\ud83c\udff3\ufe0f\u200d\ud83c\udf08|\ud83c\udff4\u200d\u2620\ufe0f|\ud83d\udc41\u200d\ud83d\udde8|\ud83d\udc68\u200d\ud83d[\udc66\udc67]|\ud83d\udc69\u200d\ud83d[\udc66\udc67]|\ud83d\udc6f\u200d\u2640\ufe0f|\ud83d\udc6f\u200d\u2642\ufe0f|\ud83e\udd3c\u200d\u2640\ufe0f|\ud83e\udd3c\u200d\u2642\ufe0f|\ud83e\uddde\u200d\u2640\ufe0f|\ud83e\uddde\u200d\u2642\ufe0f|\ud83e\udddf\u200d\u2640\ufe0f|\ud83e\udddf\u200d\u2642\ufe0f)|[\u0023\u002a\u0030-\u0039]\ufe0f?\u20e3|(?:[\u00a9\u00ae\u2122\u265f]\ufe0f)|(?:\ud83c[\udc04\udd70\udd71\udd7e\udd7f\ude02\ude1a\ude2f\ude37\udf21\udf24-\udf2c\udf36\udf7d\udf96\udf97\udf99-\udf9b\udf9e\udf9f\udfcd\udfce\udfd4-\udfdf\udff3\udff5\udff7]|\ud83d[\udc3f\udc41\udcfd\udd49\udd4a\udd6f\udd70\udd73\udd76-\udd79\udd87\udd8a-\udd8d\udda5\udda8\uddb1\uddb2\uddbc\uddc2-\uddc4\uddd1-\uddd3\udddc-\uddde\udde1\udde3\udde8\uddef\uddf3\uddfa\udecb\udecd-\udecf\udee0-\udee5\udee9\udef0\udef3]|[\u203c\u2049\u2139\u2194-\u2199\u21a9\u21aa\u231a\u231b\u2328\u23cf\u23ed-\u23ef\u23f1\u23f2\u23f8-\u23fa\u24c2\u25aa\u25ab\u25b6\u25c0\u25fb-\u25fe\u2600-\u2604\u260e\u2611\u2614\u2615\u2618\u2620\u2622\u2623\u2626\u262a\u262e\u262f\u2638-\u263a\u2640\u2642\u2648-\u2653\u2660\u2663\u2665\u2666\u2668\u267b\u267f\u2692-\u2697\u2699\u269b\u269c\u26a0\u26a1\u26aa\u26ab\u26b0\u26b1\u26bd\u26be\u26c4\u26c5\u26c8\u26cf\u26d1\u26d3\u26d4\u26e9\u26ea\u26f0-\u26f5\u26f8\u26fa\u26fd\u2702\u2708\u2709\u270f\u2712\u2714\u2716\u271d\u2721\u2733\u2734\u2744\u2747\u2757\u2763\u2764\u27a1\u2934\u2935\u2b05-\u2b07\u2b1b\u2b1c\u2b50\u2b55\u3030\u303d\u3297\u3299])(?:\ufe0f|(?!\ufe0e))|(?:(?:\ud83c[\udfcb\udfcc]|\ud83d[\udd74\udd75\udd90]|[\u261d\u26f7\u26f9\u270c\u270d])(?:\ufe0f|(?!\ufe0e))|(?:\ud83c[\udf85\udfc2-\udfc4\udfc7\udfca]|\ud83d[\udc42\udc43\udc46-\udc50\udc66-\udc69\udc6e\udc70-\udc78\udc7c\udc81-\udc83\udc85-\udc87\udcaa\udd7a\udd95\udd96\ude45-\ude47\ude4b-\ude4f\udea3\udeb4-\udeb6\udec0\udecc]|\ud83e[\udd18-\udd1c\udd1e\udd1f\udd26\udd30-\udd39\udd3d\udd3e\uddb5\uddb6\uddb8\uddb9\uddd1-\udddd]|[\u270a\u270b]))(?:\ud83c[\udffb-\udfff])?|(?:\ud83c\udff4\udb40\udc67\udb40\udc62\udb40\udc65\udb40\udc6e\udb40\udc67\udb40\udc7f|\ud83c\udff4\udb40\udc67\udb40\udc62\udb40\udc73\udb40\udc63\udb40\udc74\udb40\udc7f|\ud83c\udff4\udb40\udc67\udb40\udc62\udb40\udc77\udb40\udc6c\udb40\udc73\udb40\udc7f|\ud83c\udde6\ud83c[\udde8-\uddec\uddee\uddf1\uddf2\uddf4\uddf6-\uddfa\uddfc\uddfd\uddff]|\ud83c\udde7\ud83c[\udde6\udde7\udde9-\uddef\uddf1-\uddf4\uddf6-\uddf9\uddfb\uddfc\uddfe\uddff]|\ud83c\udde8\ud83c[\udde6\udde8\udde9\uddeb-\uddee\uddf0-\uddf5\uddf7\uddfa-\uddff]|\ud83c\udde9\ud83c[\uddea\uddec\uddef\uddf0\uddf2\uddf4\uddff]|\ud83c\uddea\ud83c[\udde6\udde8\uddea\uddec\udded\uddf7-\uddfa]|\ud83c\uddeb\ud83c[\uddee-\uddf0\uddf2\uddf4\uddf7]|\ud83c\uddec\ud83c[\udde6\udde7\udde9-\uddee\uddf1-\uddf3\uddf5-\uddfa\uddfc\uddfe]|\ud83c\udded\ud83c[\uddf0\uddf2\uddf3\uddf7\uddf9\uddfa]|\ud83c\uddee\ud83c[\udde8-\uddea\uddf1-\uddf4\uddf6-\uddf9]|\ud83c\uddef\ud83c[\uddea\uddf2\uddf4\uddf5]|\ud83c\uddf0\ud83c[\uddea\uddec-\uddee\uddf2\uddf3\uddf5\uddf7\uddfc\uddfe\uddff]|\ud83c\uddf1\ud83c[\udde6-\udde8\uddee\uddf0\uddf7-\uddfb\uddfe]|\ud83c\uddf2\ud83c[\udde6\udde8-\udded\uddf0-\uddff]|\ud83c\uddf3\ud83c[\udde6\udde8\uddea-\uddec\uddee\uddf1\uddf4\uddf5\uddf7\uddfa\uddff]|\ud83c\uddf4\ud83c\uddf2|\ud83c\uddf5\ud83c[\udde6\uddea-\udded\uddf0-\uddf3\uddf7-\uddf9\uddfc\uddfe]|\ud83c\uddf6\ud83c\udde6|\ud83c\uddf7\ud83c[\uddea\uddf4\uddf8\uddfa\uddfc]|\ud83c\uddf8\ud83c[\udde6-\uddea\uddec-\uddf4\uddf7-\uddf9\uddfb\uddfd-\uddff]|\ud83c\uddf9\ud83c[\udde6\udde8\udde9\uddeb-\udded\uddef-\uddf4\uddf7\uddf9\uddfb\uddfc\uddff]|\ud83c\uddfa\ud83c[\udde6\uddec\uddf2\uddf3\uddf8\uddfe\uddff]|\ud83c\uddfb\ud83c[\udde6\udde8\uddea\uddec\uddee\uddf3\uddfa]|\ud83c\uddfc\ud83c[\uddeb\uddf8]|\ud83c\uddfd\ud83c\uddf0|\ud83c\uddfe\ud83c[\uddea\uddf9]|\ud83c\uddff\ud83c[\udde6\uddf2\uddfc]|\ud83c[\udccf\udd8e\udd91-\udd9a\udde6-\uddff\ude01\ude32-\ude36\ude38-\ude3a\ude50\ude51\udf00-\udf20\udf2d-\udf35\udf37-\udf7c\udf7e-\udf84\udf86-\udf93\udfa0-\udfc1\udfc5\udfc6\udfc8\udfc9\udfcf-\udfd3\udfe0-\udff0\udff4\udff8-\udfff]|\ud83d[\udc00-\udc3e\udc40\udc44\udc45\udc51-\udc65\udc6a-\udc6d\udc6f\udc79-\udc7b\udc7d-\udc80\udc84\udc88-\udca9\udcab-\udcfc\udcff-\udd3d\udd4b-\udd4e\udd50-\udd67\udda4\uddfb-\ude44\ude48-\ude4a\ude80-\udea2\udea4-\udeb3\udeb7-\udebf\udec1-\udec5\uded0-\uded2\udeeb\udeec\udef4-\udef9]|\ud83e[\udd10-\udd17\udd1d\udd20-\udd25\udd27-\udd2f\udd3a\udd3c\udd40-\udd45\udd47-\udd70\udd73-\udd76\udd7a\udd7c-\udda2\uddb4\uddb7\uddc0-\uddc2\uddd0\uddde-\uddff]|[\u23e9-\u23ec\u23f0\u23f3\u267e\u26ce\u2705\u2728\u274c\u274e\u2753-\u2755\u2795-\u2797\u27b0\u27bf\ue50a])|\ufe0f)/;
+import { emojiRegex } from '../misc/emoji-regex';
 
 export function removeOrphanedBrackets(s: string): string {
 	const openBrackets = ['(', '「'];
diff --git a/src/misc/emoji-regex.ts b/src/misc/emoji-regex.ts
new file mode 100644
index 0000000000..3c8c02f48c
--- /dev/null
+++ b/src/misc/emoji-regex.ts
@@ -0,0 +1 @@
+export const emojiRegex = /((?:\ud83d[\udc68\udc69])(?:\ud83c[\udffb-\udfff])?\u200d(?:\u2695\ufe0f|\u2696\ufe0f|\u2708\ufe0f|\ud83c[\udf3e\udf73\udf93\udfa4\udfa8\udfeb\udfed]|\ud83d[\udcbb\udcbc\udd27\udd2c\ude80\ude92]|\ud83e[\uddb0-\uddb3])|(?:\ud83c[\udfcb\udfcc]|\ud83d[\udd74\udd75]|\u26f9)((?:\ud83c[\udffb-\udfff]|\ufe0f)\u200d[\u2640\u2642]\ufe0f)|(?:\ud83c[\udfc3\udfc4\udfca]|\ud83d[\udc6e\udc71\udc73\udc77\udc81\udc82\udc86\udc87\ude45-\ude47\ude4b\ude4d\ude4e\udea3\udeb4-\udeb6]|\ud83e[\udd26\udd35\udd37-\udd39\udd3d\udd3e\uddb8\uddb9\uddd6-\udddd])(?:\ud83c[\udffb-\udfff])?\u200d[\u2640\u2642]\ufe0f|(?:\ud83d\udc68\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68|\ud83d\udc68\u200d\ud83d\udc68\u200d\ud83d\udc66\u200d\ud83d\udc66|\ud83d\udc68\u200d\ud83d\udc68\u200d\ud83d\udc67\u200d\ud83d[\udc66\udc67]|\ud83d\udc68\u200d\ud83d\udc69\u200d\ud83d\udc66\u200d\ud83d\udc66|\ud83d\udc68\u200d\ud83d\udc69\u200d\ud83d\udc67\u200d\ud83d[\udc66\udc67]|\ud83d\udc69\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d[\udc68\udc69]|\ud83d\udc69\u200d\ud83d\udc69\u200d\ud83d\udc66\u200d\ud83d\udc66|\ud83d\udc69\u200d\ud83d\udc69\u200d\ud83d\udc67\u200d\ud83d[\udc66\udc67]|\ud83d\udc68\u200d\u2764\ufe0f\u200d\ud83d\udc68|\ud83d\udc68\u200d\ud83d\udc66\u200d\ud83d\udc66|\ud83d\udc68\u200d\ud83d\udc67\u200d\ud83d[\udc66\udc67]|\ud83d\udc68\u200d\ud83d\udc68\u200d\ud83d[\udc66\udc67]|\ud83d\udc68\u200d\ud83d\udc69\u200d\ud83d[\udc66\udc67]|\ud83d\udc69\u200d\u2764\ufe0f\u200d\ud83d[\udc68\udc69]|\ud83d\udc69\u200d\ud83d\udc66\u200d\ud83d\udc66|\ud83d\udc69\u200d\ud83d\udc67\u200d\ud83d[\udc66\udc67]|\ud83d\udc69\u200d\ud83d\udc69\u200d\ud83d[\udc66\udc67]|\ud83c\udff3\ufe0f\u200d\ud83c\udf08|\ud83c\udff4\u200d\u2620\ufe0f|\ud83d\udc41\u200d\ud83d\udde8|\ud83d\udc68\u200d\ud83d[\udc66\udc67]|\ud83d\udc69\u200d\ud83d[\udc66\udc67]|\ud83d\udc6f\u200d\u2640\ufe0f|\ud83d\udc6f\u200d\u2642\ufe0f|\ud83e\udd3c\u200d\u2640\ufe0f|\ud83e\udd3c\u200d\u2642\ufe0f|\ud83e\uddde\u200d\u2640\ufe0f|\ud83e\uddde\u200d\u2642\ufe0f|\ud83e\udddf\u200d\u2640\ufe0f|\ud83e\udddf\u200d\u2642\ufe0f)|[\u0023\u002a\u0030-\u0039]\ufe0f?\u20e3|(?:[\u00a9\u00ae\u2122\u265f]\ufe0f)|(?:\ud83c[\udc04\udd70\udd71\udd7e\udd7f\ude02\ude1a\ude2f\ude37\udf21\udf24-\udf2c\udf36\udf7d\udf96\udf97\udf99-\udf9b\udf9e\udf9f\udfcd\udfce\udfd4-\udfdf\udff3\udff5\udff7]|\ud83d[\udc3f\udc41\udcfd\udd49\udd4a\udd6f\udd70\udd73\udd76-\udd79\udd87\udd8a-\udd8d\udda5\udda8\uddb1\uddb2\uddbc\uddc2-\uddc4\uddd1-\uddd3\udddc-\uddde\udde1\udde3\udde8\uddef\uddf3\uddfa\udecb\udecd-\udecf\udee0-\udee5\udee9\udef0\udef3]|[\u203c\u2049\u2139\u2194-\u2199\u21a9\u21aa\u231a\u231b\u2328\u23cf\u23ed-\u23ef\u23f1\u23f2\u23f8-\u23fa\u24c2\u25aa\u25ab\u25b6\u25c0\u25fb-\u25fe\u2600-\u2604\u260e\u2611\u2614\u2615\u2618\u2620\u2622\u2623\u2626\u262a\u262e\u262f\u2638-\u263a\u2640\u2642\u2648-\u2653\u2660\u2663\u2665\u2666\u2668\u267b\u267f\u2692-\u2697\u2699\u269b\u269c\u26a0\u26a1\u26aa\u26ab\u26b0\u26b1\u26bd\u26be\u26c4\u26c5\u26c8\u26cf\u26d1\u26d3\u26d4\u26e9\u26ea\u26f0-\u26f5\u26f8\u26fa\u26fd\u2702\u2708\u2709\u270f\u2712\u2714\u2716\u271d\u2721\u2733\u2734\u2744\u2747\u2757\u2763\u2764\u27a1\u2934\u2935\u2b05-\u2b07\u2b1b\u2b1c\u2b50\u2b55\u3030\u303d\u3297\u3299])(?:\ufe0f|(?!\ufe0e))|(?:(?:\ud83c[\udfcb\udfcc]|\ud83d[\udd74\udd75\udd90]|[\u261d\u26f7\u26f9\u270c\u270d])(?:\ufe0f|(?!\ufe0e))|(?:\ud83c[\udf85\udfc2-\udfc4\udfc7\udfca]|\ud83d[\udc42\udc43\udc46-\udc50\udc66-\udc69\udc6e\udc70-\udc78\udc7c\udc81-\udc83\udc85-\udc87\udcaa\udd7a\udd95\udd96\ude45-\ude47\ude4b-\ude4f\udea3\udeb4-\udeb6\udec0\udecc]|\ud83e[\udd18-\udd1c\udd1e\udd1f\udd26\udd30-\udd39\udd3d\udd3e\uddb5\uddb6\uddb8\uddb9\uddd1-\udddd]|[\u270a\u270b]))(?:\ud83c[\udffb-\udfff])?|(?:\ud83c\udff4\udb40\udc67\udb40\udc62\udb40\udc65\udb40\udc6e\udb40\udc67\udb40\udc7f|\ud83c\udff4\udb40\udc67\udb40\udc62\udb40\udc73\udb40\udc63\udb40\udc74\udb40\udc7f|\ud83c\udff4\udb40\udc67\udb40\udc62\udb40\udc77\udb40\udc6c\udb40\udc73\udb40\udc7f|\ud83c\udde6\ud83c[\udde8-\uddec\uddee\uddf1\uddf2\uddf4\uddf6-\uddfa\uddfc\uddfd\uddff]|\ud83c\udde7\ud83c[\udde6\udde7\udde9-\uddef\uddf1-\uddf4\uddf6-\uddf9\uddfb\uddfc\uddfe\uddff]|\ud83c\udde8\ud83c[\udde6\udde8\udde9\uddeb-\uddee\uddf0-\uddf5\uddf7\uddfa-\uddff]|\ud83c\udde9\ud83c[\uddea\uddec\uddef\uddf0\uddf2\uddf4\uddff]|\ud83c\uddea\ud83c[\udde6\udde8\uddea\uddec\udded\uddf7-\uddfa]|\ud83c\uddeb\ud83c[\uddee-\uddf0\uddf2\uddf4\uddf7]|\ud83c\uddec\ud83c[\udde6\udde7\udde9-\uddee\uddf1-\uddf3\uddf5-\uddfa\uddfc\uddfe]|\ud83c\udded\ud83c[\uddf0\uddf2\uddf3\uddf7\uddf9\uddfa]|\ud83c\uddee\ud83c[\udde8-\uddea\uddf1-\uddf4\uddf6-\uddf9]|\ud83c\uddef\ud83c[\uddea\uddf2\uddf4\uddf5]|\ud83c\uddf0\ud83c[\uddea\uddec-\uddee\uddf2\uddf3\uddf5\uddf7\uddfc\uddfe\uddff]|\ud83c\uddf1\ud83c[\udde6-\udde8\uddee\uddf0\uddf7-\uddfb\uddfe]|\ud83c\uddf2\ud83c[\udde6\udde8-\udded\uddf0-\uddff]|\ud83c\uddf3\ud83c[\udde6\udde8\uddea-\uddec\uddee\uddf1\uddf4\uddf5\uddf7\uddfa\uddff]|\ud83c\uddf4\ud83c\uddf2|\ud83c\uddf5\ud83c[\udde6\uddea-\udded\uddf0-\uddf3\uddf7-\uddf9\uddfc\uddfe]|\ud83c\uddf6\ud83c\udde6|\ud83c\uddf7\ud83c[\uddea\uddf4\uddf8\uddfa\uddfc]|\ud83c\uddf8\ud83c[\udde6-\uddea\uddec-\uddf4\uddf7-\uddf9\uddfb\uddfd-\uddff]|\ud83c\uddf9\ud83c[\udde6\udde8\udde9\uddeb-\udded\uddef-\uddf4\uddf7\uddf9\uddfb\uddfc\uddff]|\ud83c\uddfa\ud83c[\udde6\uddec\uddf2\uddf3\uddf8\uddfe\uddff]|\ud83c\uddfb\ud83c[\udde6\udde8\uddea\uddec\uddee\uddf3\uddfa]|\ud83c\uddfc\ud83c[\uddeb\uddf8]|\ud83c\uddfd\ud83c\uddf0|\ud83c\uddfe\ud83c[\uddea\uddf9]|\ud83c\uddff\ud83c[\udde6\uddf2\uddfc]|\ud83c[\udccf\udd8e\udd91-\udd9a\udde6-\uddff\ude01\ude32-\ude36\ude38-\ude3a\ude50\ude51\udf00-\udf20\udf2d-\udf35\udf37-\udf7c\udf7e-\udf84\udf86-\udf93\udfa0-\udfc1\udfc5\udfc6\udfc8\udfc9\udfcf-\udfd3\udfe0-\udff0\udff4\udff8-\udfff]|\ud83d[\udc00-\udc3e\udc40\udc44\udc45\udc51-\udc65\udc6a-\udc6d\udc6f\udc79-\udc7b\udc7d-\udc80\udc84\udc88-\udca9\udcab-\udcfc\udcff-\udd3d\udd4b-\udd4e\udd50-\udd67\udda4\uddfb-\ude44\ude48-\ude4a\ude80-\udea2\udea4-\udeb3\udeb7-\udebf\udec1-\udec5\uded0-\uded2\udeeb\udeec\udef4-\udef9]|\ud83e[\udd10-\udd17\udd1d\udd20-\udd25\udd27-\udd2f\udd3a\udd3c\udd40-\udd45\udd47-\udd70\udd73-\udd76\udd7a\udd7c-\udda2\uddb4\uddb7\uddc0-\uddc2\uddd0\uddde-\uddff]|[\u23e9-\u23ec\u23f0\u23f3\u267e\u26ce\u2705\u2728\u274c\u274e\u2753-\u2755\u2795-\u2797\u27b0\u27bf\ue50a])|\ufe0f)/;
diff --git a/src/misc/fetch-meta.ts b/src/misc/fetch-meta.ts
index e6488da395..3584a819bf 100644
--- a/src/misc/fetch-meta.ts
+++ b/src/misc/fetch-meta.ts
@@ -13,6 +13,7 @@ const defaultMeta: any = {
 		originalUsersCount: 0
 	},
 	maxNoteTextLength: 1000,
+	enableEmojiReaction: true,
 	enableTwitterIntegration: false,
 	enableGithubIntegration: false,
 	enableDiscordIntegration: false,
diff --git a/src/misc/reaction-lib.ts b/src/misc/reaction-lib.ts
new file mode 100644
index 0000000000..c81e35b371
--- /dev/null
+++ b/src/misc/reaction-lib.ts
@@ -0,0 +1,59 @@
+import Emoji from '../models/emoji';
+import { emojiRegex } from './emoji-regex';
+
+const basic10: Record<string, string> = {
+	'👍': 'like',
+	'❤': 'love',	// ここに記述する場合は異体字セレクタを入れない
+	'😆': 'laugh',
+	'🤔': 'hmm',
+	'😮': 'surprise',
+	'🎉': 'congrats',
+	'💢': 'angry',
+	'😥': 'confused',
+	'😇': 'rip',
+	'🍮': 'pudding',
+};
+
+export async function getFallbackReaction(): Promise<string> {
+	return 'like';
+}
+
+export async function toDbReaction(reaction: string, enableEmoji = true): Promise<string> {
+	if (reaction == null) return await getFallbackReaction();
+
+	// 既存の文字列リアクションはそのまま
+	if (Object.values(basic10).includes(reaction)) return reaction;
+
+	if (!enableEmoji) return await getFallbackReaction();
+
+	// Unicode絵文字
+	const match = emojiRegex.exec(reaction);
+	if (match) {
+		// 合字を含む1つの絵文字
+		const unicode = match[0];
+
+		// 異体字セレクタ除去後の絵文字
+		const normalized = unicode.match('\u200d') ? unicode : unicode.replace(/\ufe0f/g, '');
+
+		// Unicodeプリンは寿司化不能とするため文字列化しない
+		if (normalized === '🍮') return normalized;
+
+		// プリン以外の既存のリアクションは文字列化する
+		if (basic10[normalized]) return basic10[normalized];
+
+		// それ以外はUnicodeのまま
+		return normalized;
+	}
+
+	const custom = reaction.match(/:([\w+-]+):/);
+	if (custom) {
+		const emoji = await Emoji.findOne({
+			host: null,
+			name: custom[1],
+		});
+
+		if (emoji) return reaction;
+	}
+
+	return await getFallbackReaction();
+}
diff --git a/src/models/meta.ts b/src/models/meta.ts
index 5351c17c52..bea4714bf7 100644
--- a/src/models/meta.ts
+++ b/src/models/meta.ts
@@ -194,6 +194,7 @@ export type IMeta = {
 	disableRegistration?: boolean;
 	disableLocalTimeline?: boolean;
 	disableGlobalTimeline?: boolean;
+	enableEmojiReaction?: boolean;
 	hidedTags?: string[];
 	mascotImageUrl?: string;
 	bannerUrl?: string;
diff --git a/src/models/note.ts b/src/models/note.ts
index 369a009162..af45ff966d 100644
--- a/src/models/note.ts
+++ b/src/models/note.ts
@@ -12,6 +12,7 @@ import { packMany as packFileMany, IDriveFile } from './drive-file';
 import Following from './following';
 import Emoji from './emoji';
 import { dbLogger } from '../db/logger';
+import { unique, concat } from '../prelude/array';
 
 const Note = db.get<INote>('notes');
 Note.createIndex('uri', { sparse: true, unique: true });
@@ -242,6 +243,11 @@ export const pack = async (
 
 	const id = _note._id;
 
+	// Some counts
+	_note.renoteCount = _note.renoteCount || 0;
+	_note.repliesCount = _note.repliesCount || 0;
+	_note.reactionCounts = _note.reactionCounts || {};
+
 	// _note._userを消す前か、_note.userを解決した後でないとホストがわからない
 	if (_note._user) {
 		const host = _note._user.host;
@@ -253,6 +259,8 @@ export const pack = async (
 				fields: { _id: false }
 			});
 		} else {
+			_note.emojis = unique(concat([_note.emojis, Object.keys(_note.reactionCounts)]));
+
 			_note.emojis = Emoji.find({
 				name: { $in: _note.emojis },
 				host: host
@@ -290,11 +298,6 @@ export const pack = async (
 	// Populate files
 	_note.files = packFileMany(_note.fileIds || []);
 
-	// Some counts
-	_note.renoteCount = _note.renoteCount || 0;
-	_note.repliesCount = _note.repliesCount || 0;
-	_note.reactionCounts = _note.reactionCounts || {};
-
 	// 後方互換性のため
 	_note.mediaIds = _note.fileIds;
 	_note.media = _note.files;
diff --git a/src/remote/activitypub/kernel/like.ts b/src/remote/activitypub/kernel/like.ts
index d36f63c79e..ed35da8133 100644
--- a/src/remote/activitypub/kernel/like.ts
+++ b/src/remote/activitypub/kernel/like.ts
@@ -3,7 +3,6 @@ import Note from '../../../models/note';
 import { IRemoteUser } from '../../../models/user';
 import { ILike } from '../type';
 import create from '../../../services/note/reaction/create';
-import { validateReaction } from '../../../models/note-reaction';
 
 export default async (actor: IRemoteUser, activity: ILike) => {
 	const id = typeof activity.object == 'string' ? activity.object : activity.object.id;
@@ -18,12 +17,5 @@ export default async (actor: IRemoteUser, activity: ILike) => {
 		throw new Error();
 	}
 
-	let reaction = 'like';
-
-	// 他のMisskeyインスタンスからのリアクション
-	if (activity._misskey_reaction && validateReaction.ok(activity._misskey_reaction)) {
-		reaction = activity._misskey_reaction;
-	}
-
-	await create(actor, note, reaction);
+	await create(actor, note, activity._misskey_reaction);
 };
diff --git a/src/server/api/endpoints/admin/update-meta.ts b/src/server/api/endpoints/admin/update-meta.ts
index df7520917c..9afe90295e 100644
--- a/src/server/api/endpoints/admin/update-meta.ts
+++ b/src/server/api/endpoints/admin/update-meta.ts
@@ -41,6 +41,13 @@ export const meta = {
 			}
 		},
 
+		enableEmojiReaction: {
+			validator: $.optional.nullable.bool,
+			desc: {
+				'ja-JP': '絵文字リアクションを有効にするか否か'
+			}
+		},
+
 		hidedTags: {
 			validator: $.optional.nullable.arr($.str),
 			desc: {
@@ -351,6 +358,10 @@ export default define(meta, async (ps) => {
 		set.disableGlobalTimeline = ps.disableGlobalTimeline;
 	}
 
+	if (typeof ps.enableEmojiReaction === 'boolean') {
+		set.enableEmojiReaction = ps.enableEmojiReaction;
+	}
+
 	if (Array.isArray(ps.hidedTags)) {
 		set.hidedTags = ps.hidedTags;
 	}
diff --git a/src/server/api/endpoints/meta.ts b/src/server/api/endpoints/meta.ts
index e3e9badff5..1759a3c2f0 100644
--- a/src/server/api/endpoints/meta.ts
+++ b/src/server/api/endpoints/meta.ts
@@ -70,6 +70,10 @@ export const meta = {
 				type: 'boolean',
 				description: 'Whether disabled GTL.',
 			},
+			enableEmojiReaction: {
+				type: 'boolean',
+				description: 'Whether enabled emoji reaction.',
+			},
 		}
 	}
 };
@@ -107,6 +111,7 @@ export default define(meta, async (ps, me) => {
 		disableRegistration: instance.disableRegistration,
 		disableLocalTimeline: instance.disableLocalTimeline,
 		disableGlobalTimeline: instance.disableGlobalTimeline,
+		enableEmojiReaction: instance.enableEmojiReaction,
 		driveCapacityPerLocalUserMb: instance.localDriveCapacityMb,
 		driveCapacityPerRemoteUserMb: instance.remoteDriveCapacityMb,
 		cacheRemoteFiles: instance.cacheRemoteFiles,
diff --git a/src/server/api/endpoints/notes/reactions/create.ts b/src/server/api/endpoints/notes/reactions/create.ts
index 291e10bbd0..299ed30278 100644
--- a/src/server/api/endpoints/notes/reactions/create.ts
+++ b/src/server/api/endpoints/notes/reactions/create.ts
@@ -1,7 +1,6 @@
 import $ from 'cafy';
 import ID, { transform } from '../../../../../misc/cafy-id';
 import createReaction from '../../../../../services/note/reaction/create';
-import { validateReaction } from '../../../../../models/note-reaction';
 import define from '../../../define';
 import { getNote } from '../../../common/getters';
 import { ApiError } from '../../../error';
@@ -30,7 +29,7 @@ export const meta = {
 		},
 
 		reaction: {
-			validator: $.str.pipe(validateReaction.ok),
+			validator: $.str,
 			desc: {
 				'ja-JP': 'リアクションの種類'
 			}
diff --git a/src/server/api/endpoints/notes/reactions/delete.ts b/src/server/api/endpoints/notes/reactions/delete.ts
index 2ccfb93293..08442226c5 100644
--- a/src/server/api/endpoints/notes/reactions/delete.ts
+++ b/src/server/api/endpoints/notes/reactions/delete.ts
@@ -20,7 +20,7 @@ export const meta = {
 
 	limit: {
 		duration: ms('1hour'),
-		max: 5,
+		max: 60,
 		minInterval: ms('3sec')
 	},
 
diff --git a/src/services/note/reaction/create.ts b/src/services/note/reaction/create.ts
index 5897df2c97..4fdaf92ac6 100644
--- a/src/services/note/reaction/create.ts
+++ b/src/services/note/reaction/create.ts
@@ -10,6 +10,8 @@ import { deliver } from '../../../queue';
 import { renderActivity } from '../../../remote/activitypub/renderer';
 import perUserReactionsChart from '../../../services/chart/per-user-reactions';
 import { IdentifiableError } from '../../../misc/identifiable-error';
+import { toDbReaction } from '../../../misc/reaction-lib';
+import fetchMeta from '../../../misc/fetch-meta';
 
 export default async (user: IUser, note: INote, reaction: string) => {
 	// Myself
@@ -17,6 +19,9 @@ export default async (user: IUser, note: INote, reaction: string) => {
 		throw new IdentifiableError('2d8e7297-1873-4c00-8404-792c68d7bef0', 'cannot react to my note');
 	}
 
+	const meta = await fetchMeta();
+	reaction = await toDbReaction(reaction, meta.enableEmojiReaction);
+
 	// Create reaction
 	await NoteReaction.insert({
 		createdAt: new Date(),
diff --git a/test/reaction-lib.ts b/test/reaction-lib.ts
new file mode 100644
index 0000000000..5a128ad7cc
--- /dev/null
+++ b/test/reaction-lib.ts
@@ -0,0 +1,91 @@
+/*
+ * Tests of MFM
+ *
+ * How to run the tests:
+ * > mocha test/reaction-lib.ts --require ts-node/register
+ *
+ * To specify test:
+ * > mocha test/reaction-lib.ts --require ts-node/register -g 'test name'
+ */
+
+import * as assert from 'assert';
+
+import { toDbReaction } from '../src/misc/reaction-lib';
+
+describe('toDbReaction', async () => {
+	it('既存の文字列リアクションはそのまま', async () => {
+		assert.strictEqual(await toDbReaction('like'), 'like');
+	});
+
+	it('Unicodeプリンは寿司化不能とするため文字列化しない', async () => {
+		assert.strictEqual(await toDbReaction('🍮'), '🍮');
+	});
+
+	it('プリン以外の既存のリアクションは文字列化する like', async () => {
+		assert.strictEqual(await toDbReaction('👍'), 'like');
+	});
+
+	it('プリン以外の既存のリアクションは文字列化する love', async () => {
+		assert.strictEqual(await toDbReaction('❤️'), 'love');
+	});
+
+	it('プリン以外の既存のリアクションは文字列化する love 異体字セレクタなし', async () => {
+		assert.strictEqual(await toDbReaction('❤'), 'love');
+	});
+
+	it('プリン以外の既存のリアクションは文字列化する laugh', async () => {
+		assert.strictEqual(await toDbReaction('😆'), 'laugh');
+	});
+
+	it('プリン以外の既存のリアクションは文字列化する hmm', async () => {
+		assert.strictEqual(await toDbReaction('🤔'), 'hmm');
+	});
+
+	it('プリン以外の既存のリアクションは文字列化する surprise', async () => {
+		assert.strictEqual(await toDbReaction('😮'), 'surprise');
+	});
+
+	it('プリン以外の既存のリアクションは文字列化する congrats', async () => {
+		assert.strictEqual(await toDbReaction('🎉'), 'congrats');
+	});
+
+	it('プリン以外の既存のリアクションは文字列化する angry', async () => {
+		assert.strictEqual(await toDbReaction('💢'), 'angry');
+	});
+
+	it('プリン以外の既存のリアクションは文字列化する confused', async () => {
+		assert.strictEqual(await toDbReaction('😥'), 'confused');
+	});
+
+	it('プリン以外の既存のリアクションは文字列化する rip', async () => {
+		assert.strictEqual(await toDbReaction('😇'), 'rip');
+	});
+
+	it('それ以外はUnicodeのまま', async () => {
+		assert.strictEqual(await toDbReaction('🍅'), '🍅');
+	});
+
+	it('異体字セレクタ除去', async () => {
+		assert.strictEqual(await toDbReaction('㊗️'), '㊗');
+	});
+
+	it('異体字セレクタ除去 必要なし', async () => {
+		assert.strictEqual(await toDbReaction('㊗'), '㊗');
+	});
+
+	it('fallback - undefined', async () => {
+		assert.strictEqual(await toDbReaction(undefined), 'like');
+	});
+
+	it('fallback - null', async () => {
+		assert.strictEqual(await toDbReaction(null), 'like');
+	});
+
+	it('fallback - empty', async () => {
+		assert.strictEqual(await toDbReaction(''), 'like');
+	});
+
+	it('fallback - unknown', async () => {
+		assert.strictEqual(await toDbReaction('unknown'), 'like');
+	});
+});