From 22417c94be9d8fae905cd13759632f1195017a78 Mon Sep 17 00:00:00 2001
From: MeiMei <30769358+mei23@users.noreply.github.com>
Date: Mon, 21 Oct 2019 00:43:39 +0900
Subject: [PATCH] Improve emoji-picker (#5515)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* Improve emoji-picker

* remove unimplanted translation

* カテゴリのサジェスト

* use unique
---
 locales/ja-JP.yml                             |   3 +
 .../1571220798684-CustomEmojiCategory.ts      |  13 ++
 src/client/app/admin/views/emoji.vue          |  19 ++-
 .../common/views/components/emoji-picker.vue  | 112 +++++++++++++-----
 src/client/app/store.ts                       |   2 +
 src/models/entities/emoji.ts                  |   5 +
 src/prelude/array.ts                          |  13 ++
 src/server/api/endpoints/admin/emoji/add.ts   |   5 +
 src/server/api/endpoints/admin/emoji/list.ts  |   9 +-
 .../api/endpoints/admin/emoji/update.ts       |   5 +
 src/server/api/endpoints/meta.ts              |  15 ++-
 11 files changed, 169 insertions(+), 32 deletions(-)
 create mode 100644 migration/1571220798684-CustomEmojiCategory.ts

diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index 672c9710b..1ef397a2e 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -673,7 +673,9 @@ common/views/components/reaction-picker.vue:
   input-reaction-placeholder: "または絵文字を入力"
 
 common/views/components/emoji-picker.vue:
+  recent-emoji: "最近使った絵文字"
   custom-emoji: "カスタム絵文字"
+  no-category: "カテゴリなし"
   people: "人"
   animals-and-nature: "動物&自然"
   food-and-drink: "食べ物&飲み物"
@@ -1591,6 +1593,7 @@ admin/views/emoji.vue:
     title: "絵文字の登録"
     name: "絵文字名"
     name-desc: "a~z 0~9 _ の文字が使えます。"
+    category: "カテゴリ"
     aliases: "エイリアス"
     aliases-desc: "スペースで区切って複数設定できます。"
     url: "絵文字画像URL"
diff --git a/migration/1571220798684-CustomEmojiCategory.ts b/migration/1571220798684-CustomEmojiCategory.ts
new file mode 100644
index 000000000..37f63fa3d
--- /dev/null
+++ b/migration/1571220798684-CustomEmojiCategory.ts
@@ -0,0 +1,13 @@
+import {MigrationInterface, QueryRunner} from "typeorm";
+
+export class CustomEmojiCategory1571220798684 implements MigrationInterface {
+
+    public async up(queryRunner: QueryRunner): Promise<any> {
+        await queryRunner.query(`ALTER TABLE "emoji" ADD "category" character varying(128)`, undefined);
+    }
+
+    public async down(queryRunner: QueryRunner): Promise<any> {
+        await queryRunner.query(`ALTER TABLE "emoji" DROP COLUMN "category"`, undefined);
+    }
+
+}
diff --git a/src/client/app/admin/views/emoji.vue b/src/client/app/admin/views/emoji.vue
index 9f2f3a0c8..2925fcab5 100644
--- a/src/client/app/admin/views/emoji.vue
+++ b/src/client/app/admin/views/emoji.vue
@@ -8,6 +8,9 @@
 					<span>{{ $t('add-emoji.name') }}</span>
 					<template #desc>{{ $t('add-emoji.name-desc') }}</template>
 				</ui-input>
+				<ui-input v-model="category" :datalist="categoryList">
+					<span>{{ $t('add-emoji.category') }}</span>
+				</ui-input>
 				<ui-input v-model="aliases">
 					<span>{{ $t('add-emoji.aliases') }}</span>
 					<template #desc>{{ $t('add-emoji.aliases-desc') }}</template>
@@ -24,7 +27,7 @@
 
 	<ui-card>
 		<template #title><fa :icon="faGrin"/> {{ $t('emojis.title') }}</template>
-		<section v-for="emoji in emojis" class="oryfrbft">
+		<section v-for="emoji in emojis" :key="emoji.name" class="oryfrbft">
 			<div>
 				<img :src="emoji.url" :alt="emoji.name" style="width: 64px;"/>
 			</div>
@@ -33,6 +36,9 @@
 					<ui-input v-model="emoji.name">
 						<span>{{ $t('add-emoji.name') }}</span>
 					</ui-input>
+					<ui-input v-model="emoji.category" :datalist="categoryList">
+						<span>{{ $t('add-emoji.category') }}</span>
+					</ui-input>
 					<ui-input v-model="emoji.aliases">
 						<span>{{ $t('add-emoji.aliases') }}</span>
 					</ui-input>
@@ -55,12 +61,14 @@
 import Vue from 'vue';
 import i18n from '../../i18n';
 import { faGrin } from '@fortawesome/free-regular-svg-icons';
+import { unique } from '../../../../prelude/array';
 
 export default Vue.extend({
 	i18n: i18n('admin/views/emoji.vue'),
 	data() {
 		return {
 			name: '',
+			category: '',
 			url: '',
 			aliases: '',
 			emojis: [],
@@ -72,10 +80,17 @@ export default Vue.extend({
 		this.fetchEmojis();
 	},
 
+	computed: {
+		categoryList() {
+			return unique(this.emojis.map((x: any) => x.category || '').filter((x: string) => x !== ''));
+		}
+	},
+
 	methods: {
 		add() {
 			this.$root.api('admin/emoji/add', {
 				name: this.name,
+				category: this.category,
 				url: this.url,
 				aliases: this.aliases.split(' ').filter(x => x.length > 0)
 			}).then(() => {
@@ -94,7 +109,6 @@ export default Vue.extend({
 
 		fetchEmojis() {
 			this.$root.api('admin/emoji/list').then(emojis => {
-				emojis.reverse();
 				for (const e of emojis) {
 					e.aliases = (e.aliases || []).join(' ');
 				}
@@ -106,6 +120,7 @@ export default Vue.extend({
 			this.$root.api('admin/emoji/update', {
 				id: emoji.id,
 				name: emoji.name,
+				category: emoji.category,
 				url: emoji.url,
 				aliases: emoji.aliases.split(' ').filter(x => x.length > 0)
 			}).then(() => {
diff --git a/src/client/app/common/views/components/emoji-picker.vue b/src/client/app/common/views/components/emoji-picker.vue
index 88761ae93..abae69e28 100644
--- a/src/client/app/common/views/components/emoji-picker.vue
+++ b/src/client/app/common/views/components/emoji-picker.vue
@@ -11,25 +11,46 @@
 		</button>
 	</header>
 	<div class="emojis">
-		<header><fa :icon="categories.find(x => x.isActive).icon" fixed-width/> {{ categories.find(x => x.isActive).text }}</header>
-		<div v-if="categories.find(x => x.isActive).name">
-			<button v-for="emoji in emojilist.filter(e => e.category === categories.find(x => x.isActive).name)"
-				:title="emoji.name"
-				@click="chosen(emoji.char)"
-				:key="emoji.name"
-			>
-				<mk-emoji :emoji="emoji.char"/>
-			</button>
-		</div>
-		<div v-else>
-			<button v-for="emoji in customEmojis"
-				:title="emoji.name"
-				@click="chosen(`:${emoji.name}:`)"
-				:key="emoji.name"
-			>
-				<img :src="emoji.url" :alt="emoji.name"/>
-			</button>
-		</div>
+		<template v-if="categories[0].isActive">
+			<header class="category"><fa :icon="faHistory" fixed-width/> {{ $t('recent-emoji') }}</header>
+			<div class="list">
+				<button v-for="(emoji, i) in ($store.state.device.recentEmojis || [])"
+					:title="emoji.name"
+					@click="chosen(emoji)"
+					:key="i"
+				>
+					<mk-emoji v-if="emoji.char != null" :emoji="emoji.char"/>
+					<img v-else :src="$store.state.device.disableShowingAnimatedImages ? getStaticImageUrl(emoji.url) : emoji.url"/>
+				</button>
+			</div>
+		</template>
+
+		<header class="category"><fa :icon="categories.find(x => x.isActive).icon" fixed-width/> {{ categories.find(x => x.isActive).text }}</header>
+		<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)"
+					:title="emoji.name"
+					@click="chosen(emoji)"
+					:key="emoji.name"
+				>
+					<mk-emoji :emoji="emoji.char"/>
+				</button>
+			</div>
+		</template>
+		<template v-else>
+			<div v-for="(key, i) in Object.keys(customEmojis)" :key="i">
+				<header class="sub">{{ key || $t('no-category') }}</header>
+				<div class="list">
+					<button v-for="emoji in customEmojis[key]"
+						:title="emoji.name"
+						@click="chosen(emoji)"
+						:key="emoji.name"
+					>
+						<img :src="$store.state.device.disableShowingAnimatedImages ? getStaticImageUrl(emoji.url) : emoji.url"/>
+					</button>
+				</div>
+			</div>
+		</template>
 	</div>
 </div>
 </template>
@@ -38,8 +59,10 @@
 import Vue from 'vue';
 import i18n from '../../../i18n';
 import { emojilist } from '../../../../../misc/emojilist';
-import { faAsterisk, faLeaf, faUtensils, faFutbol, faCity, faDice } from '@fortawesome/free-solid-svg-icons';
+import { getStaticImageUrl } from '../../../common/scripts/get-static-image-url';
+import { faAsterisk, faLeaf, faUtensils, faFutbol, faCity, faDice, faGlobe, faHistory } from '@fortawesome/free-solid-svg-icons';
 import { faHeart, faFlag } from '@fortawesome/free-regular-svg-icons';
+import { groupByX } from '../../../../../prelude/array';
 
 export default Vue.extend({
 	i18n: i18n('common/views/components/emoji-picker.vue'),
@@ -47,7 +70,9 @@ export default Vue.extend({
 	data() {
 		return {
 			emojilist,
-			customEmojis: [],
+			getStaticImageUrl,
+			customEmojis: {},
+			faGlobe, faHistory,
 			categories: [{
 				text: this.$t('custom-emoji'),
 				icon: faAsterisk,
@@ -97,18 +122,43 @@ export default Vue.extend({
 	},
 
 	created() {
-		this.customEmojis = (this.$root.getMetaSync() || { emojis: [] }).emojis || [];
+		let local = (this.$root.getMetaSync() || { emojis: [] }).emojis || [];
+		local = groupByX(local, (x: any) => x.category || '');
+		this.customEmojis = local;
+
+		if (this.$store.state.device.activeEmojiCategoryName) {
+			this.goCategory(this.$store.state.device.activeEmojiCategoryName);
+		}
 	},
 
 	methods: {
-		go(category) {
+		go(category: any) {
+			this.goCategory(category.name);
+		},
+
+		goCategory(name: string) {
+			let matched = false;
 			for (const c of this.categories) {
-				c.isActive = c.name === category.name;
+				c.isActive = c.name === name;
+				if (c.isActive) {
+					matched = true;
+					this.$store.commit('device/set', { key: 'activeEmojiCategoryName', value: c.name });
+				}
+			}
+			if (!matched) {
+				this.categories[0].isActive = true;
 			}
 		},
 
-		chosen(emoji) {
-			this.$emit('chosen', emoji);
+		chosen(emoji: any) {
+			const getKey = (emoji: any) => emoji.char || `:${emoji.name}:`;
+
+			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('chosen', getKey(emoji));
 		}
 	}
 });
@@ -142,7 +192,7 @@ export default Vue.extend({
 		overflow-y auto
 		overflow-x hidden
 
-		> header
+		> header.category
 			position sticky
 			top 0
 			left 0
@@ -152,7 +202,12 @@ export default Vue.extend({
 			color var(--text)
 			font-size 12px
 
-		> div
+		>>> header.sub
+			padding 4px 8px
+			color var(--text)
+			font-size 12px
+
+		>>> div.list
 			display grid
 			grid-template-columns 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr
 			gap 4px
@@ -180,6 +235,7 @@ export default Vue.extend({
 					left 0
 					width 100%
 					height 100%
+					object-fit contain
 					font-size 28px
 					transition transform 0.2s ease
 					pointer-events none
diff --git a/src/client/app/store.ts b/src/client/app/store.ts
index f4ec9e501..fd3aceb72 100644
--- a/src/client/app/store.ts
+++ b/src/client/app/store.ts
@@ -79,6 +79,8 @@ const defaultDeviceSettings = {
 	enableMobileQuickNotificationView: false,
 	roomGraphicsQuality: 'medium',
 	roomUseOrthographicCamera: true,
+	activeEmojiCategoryName: undefined,
+	recentEmojis: [],
 };
 
 export default (os: MiOS) => new Vuex.Store({
diff --git a/src/models/entities/emoji.ts b/src/models/entities/emoji.ts
index 020636a7f..d6080ae09 100644
--- a/src/models/entities/emoji.ts
+++ b/src/models/entities/emoji.ts
@@ -24,6 +24,11 @@ export class Emoji {
 	})
 	public host: string | null;
 
+	@Column('varchar', {
+		length: 128, nullable: true
+	})
+	public category: string | null;
+
 	@Column('varchar', {
 		length: 512,
 	})
diff --git a/src/prelude/array.ts b/src/prelude/array.ts
index 839bbc920..f4d684d57 100644
--- a/src/prelude/array.ts
+++ b/src/prelude/array.ts
@@ -84,6 +84,19 @@ export function groupOn<T, S>(f: (x: T) => S, xs: T[]): T[][] {
 	return groupBy((a, b) => f(a) === f(b), xs);
 }
 
+export function groupByX<T>(collections: T[], keySelector: (x: T) => string) {
+	return collections.reduce((obj: Record<string, T[]>, item: T) => {
+		const key = keySelector(item);
+		if (!obj.hasOwnProperty(key)) {
+			obj[key] = [];
+		}
+
+		obj[key].push(item);
+
+		return obj;
+	}, {});
+}
+
 /**
  * Compare two arrays by lexicographical order
  */
diff --git a/src/server/api/endpoints/admin/emoji/add.ts b/src/server/api/endpoints/admin/emoji/add.ts
index 6a91c31a9..73339cdc0 100644
--- a/src/server/api/endpoints/admin/emoji/add.ts
+++ b/src/server/api/endpoints/admin/emoji/add.ts
@@ -26,6 +26,10 @@ export const meta = {
 			validator: $.str.min(1)
 		},
 
+		category: {
+			validator: $.optional.str
+		},
+
 		aliases: {
 			validator: $.optional.arr($.str.min(1)),
 			default: [] as string[]
@@ -52,6 +56,7 @@ export default define(meta, async (ps, me) => {
 		id: genId(),
 		updatedAt: new Date(),
 		name: ps.name,
+		category: ps.category,
 		host: null,
 		aliases: ps.aliases,
 		url: ps.url,
diff --git a/src/server/api/endpoints/admin/emoji/list.ts b/src/server/api/endpoints/admin/emoji/list.ts
index 54686a5c5..d2a5e7df0 100644
--- a/src/server/api/endpoints/admin/emoji/list.ts
+++ b/src/server/api/endpoints/admin/emoji/list.ts
@@ -23,12 +23,19 @@ export const meta = {
 
 export default define(meta, async (ps) => {
 	const emojis = await Emojis.find({
-		host: toPunyNullable(ps.host)
+		where: {
+			host: toPunyNullable(ps.host)
+		},
+		order: {
+			category: 'ASC',
+			name: 'ASC'
+		}
 	});
 
 	return emojis.map(e => ({
 		id: e.id,
 		name: e.name,
+		category: e.category,
 		aliases: e.aliases,
 		host: e.host,
 		url: e.url
diff --git a/src/server/api/endpoints/admin/emoji/update.ts b/src/server/api/endpoints/admin/emoji/update.ts
index 062a8d0fb..f4a01a397 100644
--- a/src/server/api/endpoints/admin/emoji/update.ts
+++ b/src/server/api/endpoints/admin/emoji/update.ts
@@ -25,6 +25,10 @@ export const meta = {
 			validator: $.str
 		},
 
+		category: {
+			validator: $.optional.str
+		},
+
 		url: {
 			validator: $.str
 		},
@@ -53,6 +57,7 @@ export default define(meta, async (ps) => {
 	await Emojis.update(emoji.id, {
 		updatedAt: new Date(),
 		name: ps.name,
+		category: ps.category,
 		aliases: ps.aliases,
 		url: ps.url,
 		type,
diff --git a/src/server/api/endpoints/meta.ts b/src/server/api/endpoints/meta.ts
index 0b56a9d4e..153780e3f 100644
--- a/src/server/api/endpoints/meta.ts
+++ b/src/server/api/endpoints/meta.ts
@@ -96,7 +96,19 @@ export const meta = {
 export default define(meta, async (ps, me) => {
 	const instance = await fetchMeta(true);
 
-	const emojis = await Emojis.find({ where: { host: null }, cache: { id: 'meta_emojis', milliseconds: 3600000 } }); // 1 hour
+	const emojis = await Emojis.find({
+		where: {
+			host: null
+		},
+		order: {
+			category: 'ASC',
+			name: 'ASC'
+		},
+		cache: {
+			id: 'meta_emojis',
+			milliseconds: 3600000	// 1 hour
+		}
+	});
 
 	const response: any = {
 		maintainerName: instance.maintainerName,
@@ -144,6 +156,7 @@ export default define(meta, async (ps, me) => {
 			id: e.id,
 			aliases: e.aliases,
 			name: e.name,
+			category: e.category,
 			url: e.url,
 		})),
 		enableEmail: instance.enableEmail,