From 08144dfce4eac076c9f9d9204bafb881f9d57da4 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 7f9651b69..cec462955 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 8b3ec0cb2..ab337f187 100644 --- a/src/client/app/admin/views/instance.vue +++ b/src/client/app/admin/views/instance.vue @@ -25,6 +25,7 @@ {{ $t('disable-local-timeline') }} {{ $t('disable-global-timeline') }} {{ $t('disabling-timelines-info') }} + {{ $t('enable-emoji-reaction') }}
{{ $t('drive-config') }}
@@ -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 b4618a8d8..65b5683c2 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 d413bece6..199166950 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 @@ diff --git a/src/client/app/common/views/components/reaction-picker.vue b/src/client/app/common/views/components/reaction-picker.vue index 54c8e2a68..af340dcf7 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 @@

{{ title }}

-
+
@@ -15,6 +15,9 @@
+
+ +
@@ -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 + 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 b7c321fc9..ecd22d3f5 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) diff --git a/src/mfm/language.ts b/src/mfm/language.ts index 7b083b99a..fc191d042 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 000000000..3c8c02f48 --- /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 e6488da39..3584a819b 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 000000000..c81e35b37 --- /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 = { + '👍': 'like', + '❤': 'love', // ここに記述する場合は異体字セレクタを入れない + '😆': 'laugh', + '🤔': 'hmm', + '😮': 'surprise', + '🎉': 'congrats', + '💢': 'angry', + '😥': 'confused', + '😇': 'rip', + '🍮': 'pudding', +}; + +export async function getFallbackReaction(): Promise { + return 'like'; +} + +export async function toDbReaction(reaction: string, enableEmoji = true): Promise { + 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 5351c17c5..bea4714bf 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 369a00916..af45ff966 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('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 d36f63c79..ed35da813 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 df7520917..9afe90295 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 e3e9badff..1759a3c2f 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 291e10bbd..299ed3027 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 2ccfb9329..08442226c 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 5897df2c9..4fdaf92ac 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 000000000..5a128ad7c --- /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'); + }); +});