Improve emoji-picker (#5515)

* Improve emoji-picker

* remove unimplanted translation

* カテゴリのサジェスト

* use unique
This commit is contained in:
MeiMei 2019-10-21 00:43:39 +09:00 committed by syuilo
parent 97b6af62fe
commit 4c6c06c80a
11 changed files with 169 additions and 32 deletions

View File

@ -673,7 +673,9 @@ common/views/components/reaction-picker.vue:
input-reaction-placeholder: "または絵文字を入力" input-reaction-placeholder: "または絵文字を入力"
common/views/components/emoji-picker.vue: common/views/components/emoji-picker.vue:
recent-emoji: "最近使った絵文字"
custom-emoji: "カスタム絵文字" custom-emoji: "カスタム絵文字"
no-category: "カテゴリなし"
people: "人" people: "人"
animals-and-nature: "動物&自然" animals-and-nature: "動物&自然"
food-and-drink: "食べ物&飲み物" food-and-drink: "食べ物&飲み物"
@ -1591,6 +1593,7 @@ admin/views/emoji.vue:
title: "絵文字の登録" title: "絵文字の登録"
name: "絵文字名" name: "絵文字名"
name-desc: "a~z 0~9 _ の文字が使えます。" name-desc: "a~z 0~9 _ の文字が使えます。"
category: "カテゴリ"
aliases: "エイリアス" aliases: "エイリアス"
aliases-desc: "スペースで区切って複数設定できます。" aliases-desc: "スペースで区切って複数設定できます。"
url: "絵文字画像URL" url: "絵文字画像URL"

View File

@ -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);
}
}

View File

@ -8,6 +8,9 @@
<span>{{ $t('add-emoji.name') }}</span> <span>{{ $t('add-emoji.name') }}</span>
<template #desc>{{ $t('add-emoji.name-desc') }}</template> <template #desc>{{ $t('add-emoji.name-desc') }}</template>
</ui-input> </ui-input>
<ui-input v-model="category" :datalist="categoryList">
<span>{{ $t('add-emoji.category') }}</span>
</ui-input>
<ui-input v-model="aliases"> <ui-input v-model="aliases">
<span>{{ $t('add-emoji.aliases') }}</span> <span>{{ $t('add-emoji.aliases') }}</span>
<template #desc>{{ $t('add-emoji.aliases-desc') }}</template> <template #desc>{{ $t('add-emoji.aliases-desc') }}</template>
@ -24,7 +27,7 @@
<ui-card> <ui-card>
<template #title><fa :icon="faGrin"/> {{ $t('emojis.title') }}</template> <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> <div>
<img :src="emoji.url" :alt="emoji.name" style="width: 64px;"/> <img :src="emoji.url" :alt="emoji.name" style="width: 64px;"/>
</div> </div>
@ -33,6 +36,9 @@
<ui-input v-model="emoji.name"> <ui-input v-model="emoji.name">
<span>{{ $t('add-emoji.name') }}</span> <span>{{ $t('add-emoji.name') }}</span>
</ui-input> </ui-input>
<ui-input v-model="emoji.category" :datalist="categoryList">
<span>{{ $t('add-emoji.category') }}</span>
</ui-input>
<ui-input v-model="emoji.aliases"> <ui-input v-model="emoji.aliases">
<span>{{ $t('add-emoji.aliases') }}</span> <span>{{ $t('add-emoji.aliases') }}</span>
</ui-input> </ui-input>
@ -55,12 +61,14 @@
import Vue from 'vue'; import Vue from 'vue';
import i18n from '../../i18n'; import i18n from '../../i18n';
import { faGrin } from '@fortawesome/free-regular-svg-icons'; import { faGrin } from '@fortawesome/free-regular-svg-icons';
import { unique } from '../../../../prelude/array';
export default Vue.extend({ export default Vue.extend({
i18n: i18n('admin/views/emoji.vue'), i18n: i18n('admin/views/emoji.vue'),
data() { data() {
return { return {
name: '', name: '',
category: '',
url: '', url: '',
aliases: '', aliases: '',
emojis: [], emojis: [],
@ -72,10 +80,17 @@ export default Vue.extend({
this.fetchEmojis(); this.fetchEmojis();
}, },
computed: {
categoryList() {
return unique(this.emojis.map((x: any) => x.category || '').filter((x: string) => x !== ''));
}
},
methods: { methods: {
add() { add() {
this.$root.api('admin/emoji/add', { this.$root.api('admin/emoji/add', {
name: this.name, name: this.name,
category: this.category,
url: this.url, url: this.url,
aliases: this.aliases.split(' ').filter(x => x.length > 0) aliases: this.aliases.split(' ').filter(x => x.length > 0)
}).then(() => { }).then(() => {
@ -94,7 +109,6 @@ export default Vue.extend({
fetchEmojis() { fetchEmojis() {
this.$root.api('admin/emoji/list').then(emojis => { this.$root.api('admin/emoji/list').then(emojis => {
emojis.reverse();
for (const e of emojis) { for (const e of emojis) {
e.aliases = (e.aliases || []).join(' '); e.aliases = (e.aliases || []).join(' ');
} }
@ -106,6 +120,7 @@ export default Vue.extend({
this.$root.api('admin/emoji/update', { this.$root.api('admin/emoji/update', {
id: emoji.id, id: emoji.id,
name: emoji.name, name: emoji.name,
category: emoji.category,
url: emoji.url, url: emoji.url,
aliases: emoji.aliases.split(' ').filter(x => x.length > 0) aliases: emoji.aliases.split(' ').filter(x => x.length > 0)
}).then(() => { }).then(() => {

View File

@ -11,25 +11,46 @@
</button> </button>
</header> </header>
<div class="emojis"> <div class="emojis">
<header><fa :icon="categories.find(x => x.isActive).icon" fixed-width/> {{ categories.find(x => x.isActive).text }}</header> <template v-if="categories[0].isActive">
<div v-if="categories.find(x => x.isActive).name"> <header class="category"><fa :icon="faHistory" fixed-width/> {{ $t('recent-emoji') }}</header>
<button v-for="emoji in emojilist.filter(e => e.category === categories.find(x => x.isActive).name)" <div class="list">
:title="emoji.name" <button v-for="(emoji, i) in ($store.state.device.recentEmojis || [])"
@click="chosen(emoji.char)" :title="emoji.name"
:key="emoji.name" @click="chosen(emoji)"
> :key="i"
<mk-emoji :emoji="emoji.char"/> >
</button> <mk-emoji v-if="emoji.char != null" :emoji="emoji.char"/>
</div> <img v-else :src="$store.state.device.disableShowingAnimatedImages ? getStaticImageUrl(emoji.url) : emoji.url"/>
<div v-else> </button>
<button v-for="emoji in customEmojis" </div>
:title="emoji.name" </template>
@click="chosen(`:${emoji.name}:`)"
:key="emoji.name" <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">
<img :src="emoji.url" :alt="emoji.name"/> <div class="list">
</button> <button v-for="emoji in emojilist.filter(e => e.category === categories.find(x => x.isActive).name)"
</div> :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>
</div> </div>
</template> </template>
@ -38,8 +59,10 @@
import Vue from 'vue'; import Vue from 'vue';
import i18n from '../../../i18n'; import i18n from '../../../i18n';
import { emojilist } from '../../../../../misc/emojilist'; 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 { faHeart, faFlag } from '@fortawesome/free-regular-svg-icons';
import { groupByX } from '../../../../../prelude/array';
export default Vue.extend({ export default Vue.extend({
i18n: i18n('common/views/components/emoji-picker.vue'), i18n: i18n('common/views/components/emoji-picker.vue'),
@ -47,7 +70,9 @@ export default Vue.extend({
data() { data() {
return { return {
emojilist, emojilist,
customEmojis: [], getStaticImageUrl,
customEmojis: {},
faGlobe, faHistory,
categories: [{ categories: [{
text: this.$t('custom-emoji'), text: this.$t('custom-emoji'),
icon: faAsterisk, icon: faAsterisk,
@ -97,18 +122,43 @@ export default Vue.extend({
}, },
created() { 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: { methods: {
go(category) { go(category: any) {
this.goCategory(category.name);
},
goCategory(name: string) {
let matched = false;
for (const c of this.categories) { 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) { chosen(emoji: any) {
this.$emit('chosen', emoji); 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-y auto
overflow-x hidden overflow-x hidden
> header > header.category
position sticky position sticky
top 0 top 0
left 0 left 0
@ -152,7 +202,12 @@ export default Vue.extend({
color var(--text) color var(--text)
font-size 12px font-size 12px
> div >>> header.sub
padding 4px 8px
color var(--text)
font-size 12px
>>> div.list
display grid display grid
grid-template-columns 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr grid-template-columns 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr
gap 4px gap 4px
@ -180,6 +235,7 @@ export default Vue.extend({
left 0 left 0
width 100% width 100%
height 100% height 100%
object-fit contain
font-size 28px font-size 28px
transition transform 0.2s ease transition transform 0.2s ease
pointer-events none pointer-events none

View File

@ -79,6 +79,8 @@ const defaultDeviceSettings = {
enableMobileQuickNotificationView: false, enableMobileQuickNotificationView: false,
roomGraphicsQuality: 'medium', roomGraphicsQuality: 'medium',
roomUseOrthographicCamera: true, roomUseOrthographicCamera: true,
activeEmojiCategoryName: undefined,
recentEmojis: [],
}; };
export default (os: MiOS) => new Vuex.Store({ export default (os: MiOS) => new Vuex.Store({

View File

@ -24,6 +24,11 @@ export class Emoji {
}) })
public host: string | null; public host: string | null;
@Column('varchar', {
length: 128, nullable: true
})
public category: string | null;
@Column('varchar', { @Column('varchar', {
length: 512, length: 512,
}) })

View File

@ -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); 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 * Compare two arrays by lexicographical order
*/ */

View File

@ -26,6 +26,10 @@ export const meta = {
validator: $.str.min(1) validator: $.str.min(1)
}, },
category: {
validator: $.optional.str
},
aliases: { aliases: {
validator: $.optional.arr($.str.min(1)), validator: $.optional.arr($.str.min(1)),
default: [] as string[] default: [] as string[]
@ -52,6 +56,7 @@ export default define(meta, async (ps, me) => {
id: genId(), id: genId(),
updatedAt: new Date(), updatedAt: new Date(),
name: ps.name, name: ps.name,
category: ps.category,
host: null, host: null,
aliases: ps.aliases, aliases: ps.aliases,
url: ps.url, url: ps.url,

View File

@ -23,12 +23,19 @@ export const meta = {
export default define(meta, async (ps) => { export default define(meta, async (ps) => {
const emojis = await Emojis.find({ const emojis = await Emojis.find({
host: toPunyNullable(ps.host) where: {
host: toPunyNullable(ps.host)
},
order: {
category: 'ASC',
name: 'ASC'
}
}); });
return emojis.map(e => ({ return emojis.map(e => ({
id: e.id, id: e.id,
name: e.name, name: e.name,
category: e.category,
aliases: e.aliases, aliases: e.aliases,
host: e.host, host: e.host,
url: e.url url: e.url

View File

@ -25,6 +25,10 @@ export const meta = {
validator: $.str validator: $.str
}, },
category: {
validator: $.optional.str
},
url: { url: {
validator: $.str validator: $.str
}, },
@ -53,6 +57,7 @@ export default define(meta, async (ps) => {
await Emojis.update(emoji.id, { await Emojis.update(emoji.id, {
updatedAt: new Date(), updatedAt: new Date(),
name: ps.name, name: ps.name,
category: ps.category,
aliases: ps.aliases, aliases: ps.aliases,
url: ps.url, url: ps.url,
type, type,

View File

@ -96,7 +96,19 @@ export const meta = {
export default define(meta, async (ps, me) => { export default define(meta, async (ps, me) => {
const instance = await fetchMeta(true); 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 = { const response: any = {
maintainerName: instance.maintainerName, maintainerName: instance.maintainerName,
@ -144,6 +156,7 @@ export default define(meta, async (ps, me) => {
id: e.id, id: e.id,
aliases: e.aliases, aliases: e.aliases,
name: e.name, name: e.name,
category: e.category,
url: e.url, url: e.url,
})), })),
enableEmail: instance.enableEmail, enableEmail: instance.enableEmail,