From bef2534fa86cc58654c23bbc8d59f9f9e756f762 Mon Sep 17 00:00:00 2001
From: syuilo <syuilotan@yahoo.co.jp>
Date: Sat, 7 Nov 2020 10:43:27 +0900
Subject: [PATCH] =?UTF-8?q?=E7=B5=B5=E6=96=87=E5=AD=97=E3=83=94=E3=83=83?=
 =?UTF-8?q?=E3=82=AB=E3=83=BC=E3=82=92=E5=BC=B7=E5=8C=96=20+=20=E7=B5=B5?=
 =?UTF-8?q?=E6=96=87=E5=AD=97=E3=83=94=E3=83=83=E3=82=AB=E3=83=BC=E3=82=92?=
 =?UTF-8?q?=E3=83=AA=E3=82=A2=E3=82=AF=E3=82=B7=E3=83=A7=E3=83=B3=E3=83=94?=
 =?UTF-8?q?=E3=83=83=E3=82=AB=E3=83=BC=E3=81=A8=E3=81=97=E3=81=A6=E4=BD=BF?=
 =?UTF-8?q?=E3=81=88=E3=82=8B=E3=82=88=E3=81=86=E3=81=AB?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Resolve #5079
Resolve #3219
---
 locales/ja-JP.yml                      |   1 +
 src/client/components/emoji-picker.vue | 442 +++++++++++++++++--------
 src/client/components/note.vue         |  44 ++-
 src/client/pages/settings/reaction.vue |  25 +-
 src/client/store.ts                    |  11 +
 5 files changed, 369 insertions(+), 154 deletions(-)

diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index a88ac74417..4091e7c3f1 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -542,6 +542,7 @@ pluginInstallWarn: "信頼できないプラグインはインストールしな
 deck: "デッキ"
 undeck: "デッキ解除"
 useBlurEffectForModal: "モーダルにぼかし効果を使用"
+useFullReactionPicker: "フル機能リアクションピッカーを使用"
 generateAccessToken: "アクセストークンの発行"
 permission: "権限"
 enableAll: "全て有効にする"
diff --git a/src/client/components/emoji-picker.vue b/src/client/components/emoji-picker.vue
index a9d7c71141..12f770205d 100644
--- a/src/client/components/emoji-picker.vue
+++ b/src/client/components/emoji-picker.vue
@@ -1,62 +1,94 @@
 <template>
 <MkModal ref="modal" :src="src" @click="$refs.modal.close()" @closed="$emit('closed')">
 	<div class="omfetrab _popup">
-		<header>
-			<button v-for="(category, i) in categories"
-				class="_button"
-				@click="go(category)"
-				:class="{ active: category.isActive }"
-				:key="i"
-			>
-				<Fa :icon="category.icon" fixed-width/>
-			</button>
-		</header>
-
+		<input ref="search" class="search" v-model.trim="q" :placeholder="$t('search')" @paste.stop="paste" @keyup.enter="done()" autofocus>
 		<div class="emojis">
-			<template v-if="categories[0].isActive">
-				<header class="category"><Fa :icon="faHistory" fixed-width/> {{ $t('recentUsed') }}</header>
-				<div class="list">
-					<button v-for="emoji in ($store.state.device.recentEmojis || [])"
+			<section class="result">
+				<div v-if="searchResultCustom.length > 0">
+					<button v-for="emoji in searchResultCustom"
 						class="_button"
 						:title="emoji.name"
-						@click="chosen(emoji)"
+						@click="chosen(emoji, $event)"
 						:key="emoji"
+						tabindex="0"
 					>
 						<MkEmoji v-if="emoji.char != null" :emoji="emoji.char"/>
 						<img v-else :src="$store.state.device.disableShowingAnimatedImages ? getStaticImageUrl(emoji.url) : emoji.url"/>
 					</button>
 				</div>
-
-				<header class="category"><Fa :icon="faAsterisk" fixed-width/> {{ $t('customEmojis') }}</header>
-			</template>
-
-			<template v-if="categories.find(x => x.isActive).name">
-				<div class="list">
-					<button v-for="emoji in emojilist.filter(e => e.category === categories.find(x => x.isActive).name)"
+				<div v-if="searchResultUnicode.length > 0">
+					<button v-for="emoji in searchResultUnicode"
 						class="_button"
 						:title="emoji.name"
-						@click="chosen(emoji)"
+						@click="chosen(emoji, $event)"
+						:key="emoji.name"
+						tabindex="0"
+					>
+						<MkEmoji :emoji="emoji.char"/>
+					</button>
+				</div>
+			</section>
+
+			<div class="index">
+				<section>
+					<div>
+						<button v-for="emoji in reactions || $store.state.settings.reactions"
+							class="_button"
+							:title="emoji.name"
+							@click="chosen(emoji, $event)"
+							:key="emoji"
+							tabindex="0"
+						>
+							<MkEmoji :emoji="emoji.startsWith(':') ? null : emoji" :name="emoji.startsWith(':') ? emoji.substr(1, emoji.length - 2) : null" :normal="true"/>
+						</button>
+					</div>
+				</section>
+
+				<section>
+					<header class="_acrylic"><Fa :icon="faHistory" fixed-width/> {{ $t('recentUsed') }}</header>
+					<div>
+						<button v-for="emoji in ($store.state.device.recentEmojis || [])"
+							class="_button"
+							:title="emoji.name"
+							@click="chosen(emoji, $event)"
+							:key="emoji"
+						>
+							<MkEmoji v-if="emoji.char != null" :emoji="emoji.char"/>
+							<img v-else :src="$store.state.device.disableShowingAnimatedImages ? getStaticImageUrl(emoji.url) : emoji.url"/>
+						</button>
+					</div>
+				</section>
+
+				<div class="arrow"><Fa :icon="faChevronDown"/></div>
+			</div>
+
+			<section v-for="category in customEmojiCategories" :key="'custom:' + category" class="custom">
+				<header class="_acrylic" v-appear="() => visibleCategories[category] = true">{{ category || $t('other') }}</header>
+				<div v-if="visibleCategories[category]">
+					<button v-for="emoji in customEmojis.filter(e => e.category === category)"
+						class="_button"
+						:title="emoji.name"
+						@click="chosen(emoji, $event)"
+						:key="emoji.name"
+					>
+						<img :src="$store.state.device.disableShowingAnimatedImages ? getStaticImageUrl(emoji.url) : emoji.url"/>
+					</button>
+				</div>
+			</section>
+
+			<section v-for="category in categories" :key="category.name" class="unicode">
+				<header class="_acrylic" v-appear="() => category.isActive = true"><Fa :icon="category.icon" fixed-width/> {{ category.name }}</header>
+				<div v-if="category.isActive">
+					<button v-for="emoji in emojilist.filter(e => e.category === category.name)"
+						class="_button"
+						:title="emoji.name"
+						@click="chosen(emoji, $event)"
 						:key="emoji.name"
 					>
 						<MkEmoji :emoji="emoji.char"/>
 					</button>
 				</div>
-			</template>
-			<template v-else>
-				<div v-for="(key, i) in Object.keys(customEmojis)" :key="i">
-					<header class="sub" v-if="key">{{ key }}</header>
-					<div class="list">
-						<button v-for="emoji in customEmojis[key]"
-							class="_button"
-							:title="emoji.name"
-							@click="chosen(emoji)"
-							:key="emoji.name"
-						>
-							<img :src="$store.state.device.disableShowingAnimatedImages ? getStaticImageUrl(emoji.url) : emoji.url"/>
-						</button>
-					</div>
-				</div>
-			</template>
+			</section>
 		</div>
 	</div>
 </MkModal>
@@ -66,10 +98,11 @@
 import { defineComponent, markRaw } from 'vue';
 import { emojilist } from '../../misc/emojilist';
 import { getStaticImageUrl } from '@/scripts/get-static-image-url';
-import { faAsterisk, faLeaf, faUtensils, faFutbol, faCity, faDice, faGlobe, faHistory, faUser } from '@fortawesome/free-solid-svg-icons';
+import { faAsterisk, faLeaf, faUtensils, faFutbol, faCity, faDice, faGlobe, faHistory, faUser, faChevronDown } from '@fortawesome/free-solid-svg-icons';
 import { faHeart, faFlag, faLaugh } from '@fortawesome/free-regular-svg-icons';
-import { groupByX } from '../../prelude/array';
 import MkModal from '@/components/ui/modal.vue';
+import Particle from '@/components/particle.vue';
+import * as os from '@/os';
 
 export default defineComponent({
 	components: {
@@ -80,6 +113,9 @@ export default defineComponent({
 		src: {
 			required: false
 		},
+		reactions: {
+			required: false
+		},
 	},
 
 	emits: ['done', 'closed'],
@@ -88,12 +124,14 @@ export default defineComponent({
 		return {
 			emojilist: markRaw(emojilist),
 			getStaticImageUrl,
-			customEmojis: {},
-			faGlobe, faHistory,
+			customEmojiCategories: this.$store.getters['instance/emojiCategories'],
+			customEmojis: this.$store.state.instance.meta.emojis,
+			visibleCategories: {},
+			q: null,
+			searchResultCustom: [],
+			searchResultUnicode: [],
+			faGlobe, faHistory, faChevronDown,
 			categories: [{
-				icon: faAsterisk,
-				isActive: true
-			}, {
 				name: 'face',
 				icon: faLaugh,
 				isActive: false
@@ -134,38 +172,149 @@ export default defineComponent({
 		};
 	},
 
-	created() {
-		let local = this.$store.state.instance.meta.emojis;
-		local = groupByX(local, (x: any) => x.category || '');
-		this.customEmojis = markRaw(local);
+	watch: {
+		q() {
+			if (this.q == null || this.q === '') {
+				this.searchResultCustom = [];
+				this.searchResultUnicode = [];
+				return;
+			}
+
+			const q = this.q.replace(/:/g, '');
+
+			const searchCustom = () => {
+				const max = 8;
+				const emojis = this.customEmojis;
+				const matches = new Set();
+
+				const exactMatch = emojis.find(e => e.name === q);
+				if (exactMatch) matches.add(exactMatch);
+
+				for (const emoji of emojis) {
+					if (emoji.name.startsWith(q)) {
+						matches.add(emoji);
+						if (matches.size >= max) break;
+					}
+				}
+				if (matches.size >= max) return matches;
+
+				for (const emoji of emojis) {
+					if (emoji.aliases.some(alias => alias.startsWith(q))) {
+						matches.add(emoji);
+						if (matches.size >= max) break;
+					}
+				}
+				if (matches.size >= max) return matches;
+
+				for (const emoji of emojis) {
+					if (emoji.name.includes(q)) {
+						matches.add(emoji);
+						if (matches.size >= max) break;
+					}
+				}
+				if (matches.size >= max) return matches;
+
+				for (const emoji of emojis) {
+					if (emoji.aliases.some(alias => alias.includes(q))) {
+						matches.add(emoji);
+						if (matches.size >= max) break;
+					}
+				}
+				return matches;
+			};
+
+			const searchUnicode = () => {
+				const max = 8;
+				const emojis = this.emojilist;
+				const matches = new Set();
+
+				const exactMatch = emojis.find(e => e.name === q);
+				if (exactMatch) matches.add(exactMatch);
+
+				for (const emoji of emojis) {
+					if (emoji.name.startsWith(q)) {
+						matches.add(emoji);
+						if (matches.size >= max) break;
+					}
+				}
+				if (matches.size >= max) return matches;
+
+				for (const emoji of emojis) {
+					if (emoji.keywords.some(keyword => keyword.startsWith(q))) {
+						matches.add(emoji);
+						if (matches.size >= max) break;
+					}
+				}
+				if (matches.size >= max) return matches;
+
+				for (const emoji of emojis) {
+					if (emoji.name.includes(q)) {
+						matches.add(emoji);
+						if (matches.size >= max) break;
+					}
+				}
+				if (matches.size >= max) return matches;
+
+				for (const emoji of emojis) {
+					if (emoji.keywords.some(keyword => keyword.includes(q))) {
+						matches.add(emoji);
+						if (matches.size >= max) break;
+					}
+				}
+				return matches;
+			};
+
+			this.searchResultCustom = Array.from(searchCustom());
+			this.searchResultUnicode = Array.from(searchUnicode());
+		}
+	},
+
+	mounted() {
+		this.$refs.search.focus();
 	},
 
 	methods: {
-		go(category: any) {
-			this.goCategory(category.name);
-		},
-
-		goCategory(name: string) {
-			let matched = false;
-			for (const c of this.categories) {
-				c.isActive = c.name === name;
-				if (c.isActive) {
-					matched = true;
-				}
+		chosen(emoji: any, ev) {
+			if (ev) {
+				const el = ev.currentTarget || ev.target;
+				const rect = el.getBoundingClientRect();
+				const x = rect.left + (el.clientWidth / 2);
+				const y = rect.top + (el.clientHeight / 2);
+				os.popup(Particle, { x, y }, {}, 'end');
 			}
-			if (!matched) {
-				this.categories[0].isActive = true;
-			}
-		},
 
-		chosen(emoji: any) {
-			const getKey = (emoji: any) => emoji.char || `:${emoji.name}:`;
+			const getKey = (emoji: any) => typeof emoji === 'string' ? emoji : emoji.char || `:${emoji.name}:`;
+			this.$emit('done', getKey(emoji));
+			this.$refs.modal.close();
+
+			// 最近使った絵文字更新
 			let recents = this.$store.state.device.recentEmojis || [];
 			recents = recents.filter((e: any) => getKey(e) !== getKey(emoji));
 			recents.unshift(emoji)
 			this.$store.commit('device/set', { key: 'recentEmojis', value: recents.splice(0, 16) });
-			this.$emit('done', getKey(emoji));
-			this.$refs.modal.close();
+		},
+
+		paste(event) {
+			const paste = (event.clipboardData || window.clipboardData).getData('text');
+			if (this.done(paste)) {
+				event.preventDefault();
+			}
+		},
+
+		done(query) {
+			if (query == null) query = this.q;
+			if (query == null) return;
+			const q = query.replace(/:/g, '');
+			const exactMatchCustom = this.customEmojis.find(e => e.name === q);
+			if (exactMatchCustom) {
+				this.chosen(exactMatchCustom);
+				return true;
+			}
+			const exactMatchUnicode = this.emojilist.find(e => e.name === q);
+			if (exactMatchUnicode) {
+				this.chosen(exactMatchUnicode);
+				return true;
+			}
 		},
 	}
 });
@@ -174,85 +323,108 @@ export default defineComponent({
 <style lang="scss" scoped>
 .omfetrab {
 	width: 350px;
+	contain: content;
 
-	> header {
-		display: flex;
-
-		> button {
-			flex: 1;
-			padding: 10px 0;
-			font-size: 16px;
-			transition: color 0.2s ease;
-
-			&:hover {
-				color: var(--fgHighlighted);
-				transition: color 0s;
-			}
-
-			&.active {
-				color: var(--accent);
-				transition: color 0s;
-			}
-		}
+	> .search {
+		width: 100%;
+		padding: 12px;
+		box-sizing: border-box;
+		font-size: 1em;
+		outline: none;
+		border: none;
+		background: transparent;
+		color: var(--fg);
 	}
 
 	> .emojis {
-		height: 300px;
+		$height: 300px;
+
+		height: $height;
 		overflow-y: auto;
 		overflow-x: hidden;
 
-		> header.category {
-			position: sticky;
-			top: 0;
-			left: 0;
-			z-index: 1;
-			padding: 8px;
-			background: var(--panel);
-			font-size: 12px;
-		}
-
-		header.sub {
-			padding: 4px 8px;
-			font-size: 12px;
-		}
-
-		div.list {
-			display: grid;
-			grid-template-columns: 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr;
-			gap: 4px;
-			padding: 8px;
-
-			> button {
-				position: relative;
-				padding: 0;
+		> .index {
+			min-height: $height;
+			position: relative;
+			border-bottom: solid 1px var(--divider);
+				
+			> .arrow {
+				position: absolute;
+				bottom: 0;
+				left: 0;
 				width: 100%;
+				padding: 16px 0;
+				text-align: center;
+				opacity: 0.5;
+				pointer-events: none;
+			}
+		}
 
-				&:before {
-					content: '';
-					display: block;
-					width: 1px;
-					height: 0;
-					padding-bottom: 100%;
-				}
+		section {
+			> header {
+				position: sticky;
+				top: 0;
+				left: 0;
+				z-index: 1;
+				padding: 8px;
+				font-size: 12px;
+			}
+
+			> div {
+				display: grid;
+				grid-template-columns: 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr;
+				gap: 4px;
+				padding: 8px;
+
+				> button {
+					position: relative;
+					padding: 0;
+					width: 100%;
+
+					&:focus {
+						outline: solid 2px var(--focus);
+						z-index: 1;
+					}
+
+					&:before {
+						content: '';
+						display: block;
+						width: 1px;
+						height: 0;
+						padding-bottom: 100%;
+					}
+
+					&:hover {
+						> * {
+							transform: scale(1.2);
+							transition: transform 0s;
+						}
+					}
 
-				&:hover {
 					> * {
-						transform: scale(1.2);
-						transition: transform 0s;
+						position: absolute;
+						top: 0;
+						left: 0;
+						width: 100%;
+						height: 100%;
+						object-fit: contain;
+						font-size: 28px;
+						transition: transform 0.2s ease;
+						pointer-events: none;
 					}
 				}
+			}
 
-				> * {
-					position: absolute;
-					top: 0;
-					left: 0;
-					width: 100%;
-					height: 100%;
-					object-fit: contain;
-					font-size: 28px;
-					transition: transform 0.2s ease;
-					pointer-events: none;
-				}
+			&.result {
+				border-bottom: solid 1px var(--divider);
+			}
+
+			&.unicode {
+				min-height: 384px;
+			}
+
+			&.custom {
+				min-height: 64px;
 			}
 		}
 	}
diff --git a/src/client/components/note.vue b/src/client/components/note.vue
index 377496b402..53972d9f6f 100644
--- a/src/client/components/note.vue
+++ b/src/client/components/note.vue
@@ -498,20 +498,36 @@ export default defineComponent({
 		react(viaKeyboard = false) {
 			pleaseLogin();
 			this.blur();
-			os.popup(import('@/components/reaction-picker.vue'), {
-				showFocus: viaKeyboard,
-				src: this.$refs.reactButton,
-			}, {
-				done: reaction => {
-					if (reaction) {
-						os.api('notes/reactions/create', {
-							noteId: this.appearNote.id,
-							reaction: reaction
-						});
-					}
-					this.focus();
-				},
-			}, 'closed');
+			if (this.$store.state.device.useFullReactionPicker) {
+				os.popup(import('@/components/emoji-picker.vue'), {
+					src: this.$refs.reactButton,
+				}, {
+					done: reaction => {
+						if (reaction) {
+							os.api('notes/reactions/create', {
+								noteId: this.appearNote.id,
+								reaction: reaction
+							});
+						}
+						this.focus();
+					},
+				}, 'closed');
+			} else {
+				os.popup(import('@/components/reaction-picker.vue'), {
+					showFocus: viaKeyboard,
+					src: this.$refs.reactButton,
+				}, {
+					done: reaction => {
+						if (reaction) {
+							os.api('notes/reactions/create', {
+								noteId: this.appearNote.id,
+								reaction: reaction
+							});
+						}
+						this.focus();
+					},
+				}, 'closed');
+			}
 		},
 
 		reactDirectly(reaction) {
diff --git a/src/client/pages/settings/reaction.vue b/src/client/pages/settings/reaction.vue
index 7de2f72f61..81bb024995 100644
--- a/src/client/pages/settings/reaction.vue
+++ b/src/client/pages/settings/reaction.vue
@@ -7,6 +7,7 @@
 				{{ $t('reaction') }}<template #desc>{{ $t('reactionSettingDescription') }} <button class="_textButton" @click="chooseEmoji">{{ $t('chooseEmoji') }}</button></template>
 			</MkInput>
 			<MkButton inline @click="setDefault"><Fa :icon="faUndo"/> {{ $t('default') }}</MkButton>
+			<MkSwitch v-model:value="useFullReactionPicker">{{ $t('useFullReactionPicker') }}</MkSwitch>
 		</div>
 		<div class="_footer">
 			<MkButton @click="save()" primary inline :disabled="!changed"><Fa :icon="faSave"/> {{ $t('save') }}</MkButton>
@@ -22,6 +23,7 @@ import { faLaugh, faSave, faEye } from '@fortawesome/free-regular-svg-icons';
 import { faUndo } from '@fortawesome/free-solid-svg-icons';
 import MkInput from '@/components/ui/input.vue';
 import MkButton from '@/components/ui/button.vue';
+import MkSwitch from '@/components/ui/switch.vue';
 import { emojiRegexWithCustom } from '../../../misc/emoji-regex';
 import { defaultSettings } from '@/store';
 import * as os from '@/os';
@@ -30,6 +32,7 @@ export default defineComponent({
 	components: {
 		MkInput,
 		MkButton,
+		MkSwitch,
 	},
 
 	emits: ['info'],
@@ -50,6 +53,11 @@ export default defineComponent({
 		splited(): any {
 			return this.reactions.match(emojiRegexWithCustom);
 		},
+
+		useFullReactionPicker: {
+			get() { return this.$store.state.device.useFullReactionPicker; },
+			set(value) { this.$store.commit('device/set', { key: 'useFullReactionPicker', value: value }); }
+		},
 	},
 
 	watch: {
@@ -72,11 +80,18 @@ export default defineComponent({
 		},
 
 		preview(ev) {
-			os.popup(import('@/components/reaction-picker.vue'), {
-				reactions: this.splited,
-				showFocus: false,
-				src: ev.currentTarget || ev.target,
-			}, {}, 'closed');
+			if (this.$store.state.device.useFullReactionPicker) {
+				os.popup(import('@/components/emoji-picker.vue'), {
+					reactions: this.splited,
+					src: ev.currentTarget || ev.target,
+				}, {}, 'closed');
+			} else {
+				os.popup(import('@/components/reaction-picker.vue'), {
+					reactions: this.splited,
+					showFocus: false,
+					src: ev.currentTarget || ev.target,
+				}, {}, 'closed');
+			}
 		},
 
 		setDefault() {
diff --git a/src/client/store.ts b/src/client/store.ts
index 8b78824f7f..507c7563a1 100644
--- a/src/client/store.ts
+++ b/src/client/store.ts
@@ -76,6 +76,7 @@ export const defaultDeviceSettings = {
 	disablePagesScript: false,
 	enableInfiniteScroll: true,
 	useBlurEffectForModal: true,
+	useFullReactionPicker: false,
 	sidebarDisplay: 'full', // full, icon, hide
 	instanceTicker: 'remote', // none, remote, always
 	roomGraphicsQuality: 'medium',
@@ -182,6 +183,16 @@ export const store = createStore({
 				meta: null
 			},
 
+			getters: {
+				emojiCategories: state => {
+					const categories = new Set();
+					for (const emoji of state.meta.emojis) {
+						categories.add(emoji.category);
+					}
+					return Array.from(categories);
+				},
+			},
+
 			mutations: {
 				set(state, meta) {
 					state.meta = meta;