From 5a9f54a07aeb5c0adbafd39089c893a24b366452 Mon Sep 17 00:00:00 2001
From: anatawa12 <anatawa12@icloud.com>
Date: Wed, 16 Apr 2025 11:25:21 +0900
Subject: Complete unicode emoji after last colon (#13384)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* chore: add types on MkAutocomplete

* feat: complete `:emoji:` to unicode emoji

* chore: do not use fuzzy match for emojiComplete

* docs(changelog): Unicode絵文字をslugから入力する際に`:ok:`のように最後の`:`を入力したあとにUnicode絵文字に変換できるように

* docs(changelog): update changelog insertion position

* chore: improve indent

---------

Co-authored-by: Sayamame-beans <61457993+Sayamame-beans@users.noreply.github.com>
Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>
---
 .../frontend/src/components/MkAutocomplete.vue     | 77 +++++++++++++++++-----
 1 file changed, 61 insertions(+), 16 deletions(-)

(limited to 'packages/frontend/src/components/MkAutocomplete.vue')

diff --git a/packages/frontend/src/components/MkAutocomplete.vue b/packages/frontend/src/components/MkAutocomplete.vue
index e51a56fa7b..e5b9533cd7 100644
--- a/packages/frontend/src/components/MkAutocomplete.vue
+++ b/packages/frontend/src/components/MkAutocomplete.vue
@@ -15,12 +15,12 @@ SPDX-License-Identifier: AGPL-3.0-only
 		</li>
 		<li tabindex="-1" :class="$style.item" @click="chooseUser()" @keydown="onKeydown">{{ i18n.ts.selectUser }}</li>
 	</ol>
-	<ol v-else-if="hashtags.length > 0" ref="suggests" :class="$style.list">
+	<ol v-else-if="type === 'hashtag' && hashtags.length > 0" ref="suggests" :class="$style.list">
 		<li v-for="hashtag in hashtags" tabindex="-1" :class="$style.item" @click="complete(type, hashtag)" @keydown="onKeydown">
 			<span class="name">{{ hashtag }}</span>
 		</li>
 	</ol>
-	<ol v-else-if="emojis.length > 0" ref="suggests" :class="$style.list">
+	<ol v-else-if="type === 'emoji' || type === 'emojiComplete' && emojis.length > 0" ref="suggests" :class="$style.list">
 		<li v-for="emoji in emojis" :key="emoji.emoji" :class="$style.item" tabindex="-1" @click="complete(type, emoji.emoji)" @keydown="onKeydown">
 			<MkCustomEmoji v-if="'isCustomEmoji' in emoji && emoji.isCustomEmoji" :name="emoji.emoji" :class="$style.emoji" :fallbackToImage="true"/>
 			<MkEmoji v-else :emoji="emoji.emoji" :class="$style.emoji"/>
@@ -30,12 +30,12 @@ SPDX-License-Identifier: AGPL-3.0-only
 			<span v-if="emoji.aliasOf" :class="$style.emojiAlias">({{ emoji.aliasOf }})</span>
 		</li>
 	</ol>
-	<ol v-else-if="mfmTags.length > 0" ref="suggests" :class="$style.list">
+	<ol v-else-if="type === 'mfmTag' && mfmTags.length > 0" ref="suggests" :class="$style.list">
 		<li v-for="tag in mfmTags" tabindex="-1" :class="$style.item" @click="complete(type, tag)" @keydown="onKeydown">
 			<span>{{ tag }}</span>
 		</li>
 	</ol>
-	<ol v-else-if="mfmParams.length > 0" ref="suggests" :class="$style.list">
+	<ol v-else-if="type === 'mfmParam' && mfmParams.length > 0" ref="suggests" :class="$style.list">
 		<li v-for="param in mfmParams" tabindex="-1" :class="$style.item" @click="complete(type, q.params.toSpliced(-1, 1, param).join(','))" @keydown="onKeydown">
 			<span>{{ param }}</span>
 		</li>
@@ -58,12 +58,44 @@ import { store } from '@/store.js';
 import { i18n } from '@/i18n.js';
 import { miLocalStorage } from '@/local-storage.js';
 import { customEmojis } from '@/custom-emojis.js';
-import { searchEmoji } from '@/utility/search-emoji.js';
+import { searchEmoji, searchEmojiExact } from '@/utility/search-emoji.js';
 import { prefer } from '@/preferences.js';
 
+export type CompleteInfo = {
+	user: {
+		payload: any;
+		query: string | null;
+	},
+	hashtag: {
+		payload: string;
+		query: string;
+	},
+	// `:emo` -> `:emoji:` or some unicode emoji
+	emoji: {
+		payload: string;
+		query: string;
+	},
+	// like emoji but for `:emoji:` -> unicode emoji
+	emojiComplete: {
+		payload: string;
+		query: string;
+	},
+	mfmTag: {
+		payload: string;
+		query: string;
+	},
+	mfmParam: {
+		payload: string;
+		query: {
+			tag: string;
+			params: string[];
+		};
+	},
+};
+
 const lib = emojilist.filter(x => x.category !== 'flags');
 
-const emojiDb = computed(() => {
+const unicodeEmojiDB = computed(() => {
 	//#region Unicode Emoji
 	const char2path = prefer.r.emojiStyle.value === 'twemoji' ? char2twemojiFilePath : char2fluentEmojiFilePath;
 
@@ -87,6 +119,12 @@ const emojiDb = computed(() => {
 	}
 
 	unicodeEmojiDB.sort((a, b) => a.name.length - b.name.length);
+
+	return unicodeEmojiDB;
+});
+
+const emojiDb = computed(() => {
+	//#region Unicode Emoji
 	//#endregion
 
 	//#region Custom Emoji
@@ -114,7 +152,7 @@ const emojiDb = computed(() => {
 	customEmojiDB.sort((a, b) => a.name.length - b.name.length);
 	//#endregion
 
-	return markRaw([...customEmojiDB, ...unicodeEmojiDB]);
+	return markRaw([...customEmojiDB, ...unicodeEmojiDB.value]);
 });
 
 export default {
@@ -123,18 +161,23 @@ export default {
 };
 </script>
 
-<script lang="ts" setup>
-const props = defineProps<{
-	type: string;
-	q: any;
-	textarea: HTMLTextAreaElement;
+<script lang="ts" setup generic="T extends keyof CompleteInfo">
+type PropsType<T extends keyof CompleteInfo> = {
+	type: T;
+	q: CompleteInfo[T]['query'];
+	// なぜかわからないけど HTMLTextAreaElement | HTMLInputElement だと addEventListener/removeEventListenerがエラー
+	textarea: (HTMLTextAreaElement | HTMLInputElement) & HTMLElement;
 	close: () => void;
 	x: number;
 	y: number;
-}>();
+};
+//const props = defineProps<PropsType<keyof CompleteInfo>>();
+// ↑と同じだけど↓にしないとdiscriminated unionにならない。
+// https://www.typescriptlang.org/docs/handbook/typescript-in-5-minutes-func.html#discriminated-unions
+const props = defineProps<PropsType<'user'> | PropsType<'hashtag'> | PropsType<'emoji'> | PropsType<'emojiComplete'> | PropsType<'mfmTag'> | PropsType<'mfmParam'>>();
 
 const emit = defineEmits<{
-	(event: 'done', value: { type: string; value: any }): void;
+	<T extends keyof CompleteInfo>(event: 'done', value: { type: T; value: CompleteInfo[T]['payload'] }): void;
 	(event: 'closed'): void;
 }>();
 
@@ -151,10 +194,10 @@ const mfmParams = ref<string[]>([]);
 const select = ref(-1);
 const zIndex = os.claimZIndex('high');
 
-function complete(type: string, value: any) {
+function complete<T extends keyof CompleteInfo>(type: T, value: CompleteInfo[T]['payload']) {
 	emit('done', { type, value });
 	emit('closed');
-	if (type === 'emoji') {
+	if (type === 'emoji' || type === 'emojiComplete') {
 		let recents = store.s.recentlyUsedEmojis;
 		recents = recents.filter((emoji: any) => emoji !== value);
 		recents.unshift(value);
@@ -243,6 +286,8 @@ function exec() {
 		}
 
 		emojis.value = searchEmoji(props.q, emojiDb.value);
+	} else if (props.type === 'emojiComplete') {
+		emojis.value = searchEmojiExact(props.q, unicodeEmojiDB.value);
 	} else if (props.type === 'mfmTag') {
 		if (!props.q || props.q === '') {
 			mfmTags.value = MFM_TAGS;
-- 
cgit v1.2.3-freya

