diff options
| author | syuilo <Syuilotan@yahoo.co.jp> | 2023-01-26 15:48:12 +0900 |
|---|---|---|
| committer | syuilo <Syuilotan@yahoo.co.jp> | 2023-01-26 15:48:12 +0900 |
| commit | 452bd6db2501a1f862b167f56230acd40e62731b (patch) | |
| tree | e0640cb62d8fef0112c7f239e21e5129c1540e1b | |
| parent | chore: check emoji host (diff) | |
| download | misskey-452bd6db2501a1f862b167f56230acd40e62731b.tar.gz misskey-452bd6db2501a1f862b167f56230acd40e62731b.tar.bz2 misskey-452bd6db2501a1f862b167f56230acd40e62731b.zip | |
tweak custom emoji handling
Close #9721
19 files changed, 261 insertions, 78 deletions
diff --git a/packages/backend/src/core/CustomEmojiService.ts b/packages/backend/src/core/CustomEmojiService.ts index 169b7892e2..2610ad5692 100644 --- a/packages/backend/src/core/CustomEmojiService.ts +++ b/packages/backend/src/core/CustomEmojiService.ts @@ -6,22 +6,35 @@ import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import type { DriveFile } from '@/models/entities/DriveFile.js'; import type { Emoji } from '@/models/entities/Emoji.js'; -import type { EmojisRepository } from '@/models/index.js'; +import type { EmojisRepository, Note } from '@/models/index.js'; import { bindThis } from '@/decorators.js'; +import { Cache } from '@/misc/cache.js'; +import { UtilityService } from '@/core/UtilityService.js'; +import type { Config } from '@/config.js'; +import { ReactionService } from '@/core/ReactionService.js'; +import { query } from '@/misc/prelude/url.js'; @Injectable() export class CustomEmojiService { + private cache: Cache<Emoji | null>; + constructor( + @Inject(DI.config) + private config: Config, + @Inject(DI.db) private db: DataSource, @Inject(DI.emojisRepository) private emojisRepository: EmojisRepository, + private utilityService: UtilityService, private idService: IdService, private emojiEntityService: EmojiEntityService, private globalEventService: GlobalEventService, + private reactionService: ReactionService, ) { + this.cache = new Cache<Emoji | null>(1000 * 60 * 60 * 12); } @bindThis @@ -54,4 +67,121 @@ export class CustomEmojiService { return emoji; } + + @bindThis + private normalizeHost(src: string | undefined, noteUserHost: string | null): string | null { + // クエリに使うホスト + let host = src === '.' ? null // .はローカルホスト (ここがマッチするのはリアクションのみ) + : src === undefined ? noteUserHost // ノートなどでホスト省略表記の場合はローカルホスト (ここがリアクションにマッチすることはない) + : this.utilityService.isSelfHost(src) ? null // 自ホスト指定 + : (src || noteUserHost); // 指定されたホスト || ノートなどの所有者のホスト (こっちがリアクションにマッチすることはない) + + host = this.utilityService.toPunyNullable(host); + + return host; + } + + @bindThis + private parseEmojiStr(emojiName: string, noteUserHost: string | null) { + const match = emojiName.match(/^(\w+)(?:@([\w.-]+))?$/); + if (!match) return { name: null, host: null }; + + const name = match[1]; + + // ホスト正規化 + const host = this.utilityService.toPunyNullable(this.normalizeHost(match[2], noteUserHost)); + + return { name, host }; + } + + /** + * 添付用(リモート)カスタム絵文字URLを解決する + * @param emojiName ノートやユーザープロフィールに添付された、またはリアクションのカスタム絵文字名 (:は含めない, リアクションでローカルホストの場合は@.を付ける (これはdecodeReactionで可能)) + * @param noteUserHost ノートやユーザープロフィールの所有者のホスト + * @returns URL, nullは未マッチを意味する + */ + @bindThis + public async populateEmoji(emojiName: string, noteUserHost: string | null): Promise<string | null> { + const { name, host } = this.parseEmojiStr(emojiName, noteUserHost); + if (name == null) return null; + if (host == null) return null; + + const queryOrNull = async () => (await this.emojisRepository.findOneBy({ + name, + host: host ?? IsNull(), + })) ?? null; + + const emoji = await this.cache.fetch(`${name} ${host}`, queryOrNull); + + if (emoji == null) return null; + + const isLocal = emoji.host == null; + const emojiUrl = emoji.publicUrl || emoji.originalUrl; // || emoji.originalUrl してるのは後方互換性のため(publicUrlはstringなので??はだめ) + const url = isLocal ? emojiUrl : `${this.config.url}/proxy/${encodeURIComponent((new URL(emojiUrl)).pathname)}?${query({ url: emojiUrl })}`; + + return url; + } + + /** + * 複数の添付用(リモート)カスタム絵文字URLを解決する (キャシュ付き, 存在しないものは結果から除外される) + */ + @bindThis + public async populateEmojis(emojiNames: string[], noteUserHost: string | null): Promise<Record<string, string>> { + const emojis = await Promise.all(emojiNames.map(x => this.populateEmoji(x, noteUserHost))); + const res = {} as any; + for (let i = 0; i < emojiNames.length; i++) { + if (emojis[i] != null) { + res[emojiNames[i]] = emojis[i]; + } + } + return res; + } + + @bindThis + public aggregateNoteEmojis(notes: Note[]) { + let emojis: { name: string | null; host: string | null; }[] = []; + for (const note of notes) { + emojis = emojis.concat(note.emojis + .map(e => this.parseEmojiStr(e, note.userHost))); + if (note.renote) { + emojis = emojis.concat(note.renote.emojis + .map(e => this.parseEmojiStr(e, note.renote!.userHost))); + if (note.renote.user) { + emojis = emojis.concat(note.renote.user.emojis + .map(e => this.parseEmojiStr(e, note.renote!.userHost))); + } + } + const customReactions = Object.keys(note.reactions).map(x => this.reactionService.decodeReaction(x)).filter(x => x.name != null) as typeof emojis; + emojis = emojis.concat(customReactions); + if (note.user) { + emojis = emojis.concat(note.user.emojis + .map(e => this.parseEmojiStr(e, note.userHost))); + } + } + return emojis.filter(x => x.name != null && x.host != null) as { name: string; host: string; }[]; + } + + /** + * 与えられた絵文字のリストをデータベースから取得し、キャッシュに追加します + */ + @bindThis + public async prefetchEmojis(emojis: { name: string; host: string | null; }[]): Promise<void> { + const notCachedEmojis = emojis.filter(emoji => this.cache.get(`${emoji.name} ${emoji.host}`) == null); + const emojisQuery: any[] = []; + const hosts = new Set(notCachedEmojis.map(e => e.host)); + for (const host of hosts) { + if (host == null) continue; + emojisQuery.push({ + name: In(notCachedEmojis.filter(e => e.host === host).map(e => e.name)), + host: host, + }); + } + const _emojis = emojisQuery.length > 0 ? await this.emojisRepository.find({ + where: emojisQuery, + select: ['name', 'host', 'originalUrl', 'publicUrl'], + }) : []; + for (const emoji of _emojis) { + this.cache.set(`${emoji.name} ${emoji.host}`, emoji); + } + } } diff --git a/packages/backend/src/core/entities/NoteEntityService.ts b/packages/backend/src/core/entities/NoteEntityService.ts index 2b179643f3..bd6971adb3 100644 --- a/packages/backend/src/core/entities/NoteEntityService.ts +++ b/packages/backend/src/core/entities/NoteEntityService.ts @@ -282,7 +282,9 @@ export class NoteEntityService implements OnModuleInit { : await this.channelsRepository.findOneBy({ id: note.channelId }) : null; - const reactionEmojiNames = Object.keys(note.reactions).filter(x => x.startsWith(':')).map(x => this.reactionService.decodeReaction(x).reaction).map(x => x.replace(/:/g, '')); + const reactionEmojiNames = Object.keys(note.reactions) + .filter(x => x.startsWith(':') && x.includes('@') && !x.includes('@.')) // リモートカスタム絵文字のみ + .map(x => this.reactionService.decodeReaction(x).reaction.replaceAll(':', '')); const packed: Packed<'Note'> = await awaitAll({ id: note.id, @@ -299,6 +301,8 @@ export class NoteEntityService implements OnModuleInit { renoteCount: note.renoteCount, repliesCount: note.repliesCount, reactions: this.reactionService.convertLegacyReactions(note.reactions), + reactionEmojis: this.customEmojiService.populateEmojis(reactionEmojiNames, host), + emojis: host != null ? this.customEmojiService.populateEmojis(note.emojis, host) : undefined, tags: note.tags.length > 0 ? note.tags : undefined, fileIds: note.fileIds, files: this.driveFileEntityService.packMany(note.fileIds), @@ -384,6 +388,8 @@ export class NoteEntityService implements OnModuleInit { } } + await this.customEmojiService.prefetchEmojis(this.customEmojiService.aggregateNoteEmojis(notes)); + return await Promise.all(notes.map(n => this.pack(n, me, { ...options, _hint_: { diff --git a/packages/backend/src/core/entities/NotificationEntityService.ts b/packages/backend/src/core/entities/NotificationEntityService.ts index a8210eea02..ded1b512a1 100644 --- a/packages/backend/src/core/entities/NotificationEntityService.ts +++ b/packages/backend/src/core/entities/NotificationEntityService.ts @@ -146,6 +146,8 @@ export class NotificationEntityService implements OnModuleInit { myReactionsMap.set(target, myReactions.find(reaction => reaction.noteId === target) ?? null); } + await this.customEmojiService.prefetchEmojis(this.customEmojiService.aggregateNoteEmojis(notes)); + return await Promise.all(notifications.map(x => this.pack(x, { _hintForEachNotes_: { myReactions: myReactionsMap, diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index f532b5bf6e..546e61a26e 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -413,6 +413,7 @@ export class UserEntityService implements OnModuleInit { faviconUrl: instance.faviconUrl, themeColor: instance.themeColor, } : undefined) : undefined, + emojis: this.customEmojiService.populateEmojis(user.emojis, user.host), onlineStatus: this.getOnlineStatus(user), ...(opts.detail ? { diff --git a/packages/frontend/src/components/MkAutocomplete.vue b/packages/frontend/src/components/MkAutocomplete.vue index 2cb3aeb3d8..27997eb330 100644 --- a/packages/frontend/src/components/MkAutocomplete.vue +++ b/packages/frontend/src/components/MkAutocomplete.vue @@ -17,7 +17,7 @@ </ol> <ol v-else-if="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"> - <MkEmoji :emoji="emoji.emoji" :class="$style.emoji"/> + <MkCustomEmoji :name="emoji.emoji" :class="$style.emoji"/> <!-- eslint-disable-next-line vue/no-v-html --> <span v-if="q" :class="$style.emojiName" v-html="sanitizeHtml(emoji.name.replace(q, `<b>${q}</b>`))"></span> <span v-else v-text="emoji.name"></span> @@ -112,7 +112,7 @@ const emojiDb = computed(() => { customEmojiDB.sort((a, b) => a.name.length - b.name.length); //#endregion - return markRaw([ ...customEmojiDB, ...unicodeEmojiDB ]); + return markRaw([...customEmojiDB, ...unicodeEmojiDB]); }); export default { diff --git a/packages/frontend/src/components/MkEmojiPicker.vue b/packages/frontend/src/components/MkEmojiPicker.vue index f64cc6e9aa..39e274ba11 100644 --- a/packages/frontend/src/components/MkEmojiPicker.vue +++ b/packages/frontend/src/components/MkEmojiPicker.vue @@ -12,7 +12,7 @@ tabindex="0" @click="chosen(emoji, $event)" > - <MkEmoji class="emoji" :emoji="`:${emoji.name}:`"/> + <MkCustomEmoji class="emoji" :name="emoji.name"/> </button> </div> <div v-if="searchResultUnicode.length > 0" class="body"> @@ -39,7 +39,8 @@ tabindex="0" @click="chosen(emoji, $event)" > - <MkEmoji class="emoji" :emoji="emoji" :normal="true"/> + <MkCustomEmoji v-if="emoji[0] === ':'" class="emoji" :name="emoji" :normal="true"/> + <MkEmoji v-else class="emoji" :emoji="emoji" :normal="true"/> </button> </div> </section> @@ -53,7 +54,8 @@ class="_button item" @click="chosen(emoji, $event)" > - <MkEmoji class="emoji" :emoji="emoji" :normal="true"/> + <MkCustomEmoji v-if="emoji[0] === ':'" class="emoji" :name="emoji" :normal="true"/> + <MkEmoji v-else class="emoji" :emoji="emoji" :normal="true"/> </button> </div> </section> diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue index 1f6a2883d7..351861ac17 100644 --- a/packages/frontend/src/components/MkNote.vue +++ b/packages/frontend/src/components/MkNote.vue @@ -48,12 +48,12 @@ <div :class="$style.text"> <span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span> <MkA v-if="appearNote.replyId" :class="$style.replyIcon" :to="`/notes/${appearNote.replyId}`"><i class="ti ti-arrow-back-up"></i></MkA> - <Mfm v-if="appearNote.text" v-once :text="appearNote.text" :author="appearNote.user" :i="$i"/> + <Mfm v-if="appearNote.text" v-once :text="appearNote.text" :author="appearNote.user" :i="$i" :emoji-urls="appearNote.emojis"/> <div v-if="translating || translation" :class="$style.translation"> <MkLoading v-if="translating" mini/> <div v-else :class="$style.translated"> <b>{{ $t('translatedFrom', { x: translation.sourceLang }) }}: </b> - <Mfm :text="translation.text" :author="appearNote.user" :i="$i"/> + <Mfm :text="translation.text" :author="appearNote.user" :i="$i" :emoji-urls="appearNote.emojis"/> </div> </div> </div> diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue index 48ace56d9c..0da06c4f14 100644 --- a/packages/frontend/src/components/MkNoteDetailed.vue +++ b/packages/frontend/src/components/MkNoteDetailed.vue @@ -65,13 +65,13 @@ <div class="text"> <span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span> <MkA v-if="appearNote.replyId" class="reply" :to="`/notes/${appearNote.replyId}`"><i class="ti ti-arrow-back-up"></i></MkA> - <Mfm v-if="appearNote.text" :text="appearNote.text" :author="appearNote.user" :i="$i"/> + <Mfm v-if="appearNote.text" :text="appearNote.text" :author="appearNote.user" :i="$i" :emoji-urls="appearNote.emojis"/> <a v-if="appearNote.renote != null" class="rp">RN:</a> <div v-if="translating || translation" class="translation"> <MkLoading v-if="translating" mini/> <div v-else class="translated"> <b>{{ $t('translatedFrom', { x: translation.sourceLang }) }}: </b> - <Mfm :text="translation.text" :author="appearNote.user" :i="$i"/> + <Mfm :text="translation.text" :author="appearNote.user" :i="$i" :emoji-urls="appearNote.emojis"/> </div> </div> </div> diff --git a/packages/frontend/src/components/MkNoteSimple.vue b/packages/frontend/src/components/MkNoteSimple.vue index 828bbee5af..757b325a06 100644 --- a/packages/frontend/src/components/MkNoteSimple.vue +++ b/packages/frontend/src/components/MkNoteSimple.vue @@ -5,7 +5,7 @@ <MkNoteHeader :class="$style.header" :note="note" :mini="true"/> <div> <p v-if="note.cw != null" :class="$style.cw"> - <Mfm v-if="note.cw != ''" style="margin-right: 8px;" :text="note.cw" :author="note.user" :i="$i"/> + <Mfm v-if="note.cw != ''" style="margin-right: 8px;" :text="note.cw" :author="note.user" :i="$i" :emoji-urls="note.emojis"/> <MkCwButton v-model="showContent" :note="note"/> </p> <div v-show="note.cw == null || showContent"> diff --git a/packages/frontend/src/components/MkReactionIcon.vue b/packages/frontend/src/components/MkReactionIcon.vue index 6e9d2b1a6c..29b3f9b85b 100644 --- a/packages/frontend/src/components/MkReactionIcon.vue +++ b/packages/frontend/src/components/MkReactionIcon.vue @@ -1,5 +1,6 @@ <template> -<MkEmoji :emoji="reaction" :is-reaction="true" :normal="true" :no-style="noStyle"/> +<MkCustomEmoji v-if="reaction[0] === ':'" :name="reaction" :normal="true" :no-style="noStyle" :url="emojiUrl"/> +<MkEmoji v-else :emoji="reaction" :normal="true" :no-style="noStyle"/> </template> <script lang="ts" setup> @@ -8,5 +9,6 @@ import { } from 'vue'; const props = defineProps<{ reaction: string; noStyle?: boolean; + emojiUrl?: string; }>(); </script> diff --git a/packages/frontend/src/components/MkReactionsViewer.reaction.vue b/packages/frontend/src/components/MkReactionsViewer.reaction.vue index eed6b46594..83fdf0f988 100644 --- a/packages/frontend/src/components/MkReactionsViewer.reaction.vue +++ b/packages/frontend/src/components/MkReactionsViewer.reaction.vue @@ -6,7 +6,7 @@ :class="[$style.root, { [$style.reacted]: note.myReaction == reaction, [$style.canToggle]: canToggle }]" @click="toggleReaction()" > - <MkReactionIcon :class="$style.icon" :reaction="reaction"/> + <MkReactionIcon :class="$style.icon" :reaction="reaction" :emoji-url="note.reactionEmojis[reaction.substr(1, reaction.length - 2)]"/> <span :class="$style.count">{{ count }}</span> </button> </template> diff --git a/packages/frontend/src/components/MkSubNoteContent.vue b/packages/frontend/src/components/MkSubNoteContent.vue index 482a43f474..7e6833dae9 100644 --- a/packages/frontend/src/components/MkSubNoteContent.vue +++ b/packages/frontend/src/components/MkSubNoteContent.vue @@ -4,7 +4,7 @@ <span v-if="note.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span> <span v-if="note.deletedAt" style="opacity: 0.5">({{ i18n.ts.deleted }})</span> <MkA v-if="note.replyId" :class="$style.reply" :to="`/notes/${note.replyId}`"><i class="ti ti-arrow-back-up"></i></MkA> - <Mfm v-if="note.text" v-once :text="note.text" :author="note.user" :i="$i"/> + <Mfm v-if="note.text" v-once :text="note.text" :author="note.user" :i="$i" :emoji-urls="note.emojis"/> <MkA v-if="note.renoteId" :class="$style.rp" :to="`/notes/${note.renoteId}`">RN: ...</MkA> </div> <details v-if="note.files.length > 0"> diff --git a/packages/frontend/src/components/global/MkCustomEmoji.vue b/packages/frontend/src/components/global/MkCustomEmoji.vue new file mode 100644 index 0000000000..93c47f0c27 --- /dev/null +++ b/packages/frontend/src/components/global/MkCustomEmoji.vue @@ -0,0 +1,61 @@ +<template> +<span v-if="errored">:{{ customEmojiName }}:</span> +<img v-else :class="[$style.root, { [$style.normal]: normal, [$style.noStyle]: noStyle }]" :src="url" :alt="alt" :title="alt" decoding="async" @error="errored = true" @load="errored = false"/> +</template> + +<script lang="ts" setup> +import { computed } from 'vue'; +import { getStaticImageUrl } from '@/scripts/media-proxy'; +import { defaultStore } from '@/store'; +import { customEmojis } from '@/custom-emojis'; + +const props = defineProps<{ + name: string; + normal?: boolean; + noStyle?: boolean; + host?: string | null; + url?: string; +}>(); + +const customEmojiName = computed(() => (props.name[0] === ':' ? props.name.substr(1, props.name.length - 2) : props.name).replace('@.', '')); +const url = computed(() => { + if (props.url) { + return props.url; + } else if (props.host == null && !customEmojiName.value.includes('@')) { + const found = customEmojis.value.find(x => x.name === customEmojiName.value); + return found ? defaultStore.state.disableShowingAnimatedImages ? getStaticImageUrl(found.url) : found.url : null; + } else { + const rawUrl = props.host ? `/emoji/${customEmojiName.value}@${props.host}.webp` : `/emoji/${customEmojiName.value}.webp`; + return defaultStore.state.disableShowingAnimatedImages + ? getStaticImageUrl(rawUrl) + : rawUrl; + } +}); +const alt = computed(() => `:${customEmojiName.value}:`); +let errored = $ref(url.value == null); +</script> + +<style lang="scss" module> +.root { + height: 2.5em; + vertical-align: middle; + transition: transform 0.2s ease; + + &:hover { + transform: scale(1.2); + } +} + +.normal { + height: 1.25em; + vertical-align: -0.25em; + + &:hover { + transform: none; + } +} + +.noStyle { + height: auto !important; +} +</style> diff --git a/packages/frontend/src/components/global/MkEmoji.vue b/packages/frontend/src/components/global/MkEmoji.vue index b554d5e47c..1b0d34a445 100644 --- a/packages/frontend/src/components/global/MkEmoji.vue +++ b/packages/frontend/src/components/global/MkEmoji.vue @@ -1,54 +1,29 @@ <template> -<span v-if="isCustom && errored">:{{ customEmojiName }}:</span> -<img v-else-if="isCustom" :class="[$style.root, $style.custom, { [$style.normal]: normal, [$style.noStyle]: noStyle }]" :src="url" :alt="alt" :title="alt" decoding="async" @error="errored = true" @load="errored = false"/> -<img v-else-if="char && !useOsNativeEmojis" :class="$style.root" :src="url" :alt="alt" decoding="async" @pointerenter="computeTitle"/> -<span v-else-if="char && useOsNativeEmojis" :alt="alt" @pointerenter="computeTitle">{{ char }}</span> +<img v-if="!useOsNativeEmojis" :class="$style.root" :src="url" :alt="props.emoji" decoding="async" @pointerenter="computeTitle"/> +<span v-else-if="useOsNativeEmojis" :alt="props.emoji" @pointerenter="computeTitle">{{ props.emoji }}</span> <span v-else>{{ emoji }}</span> </template> <script lang="ts" setup> import { computed } from 'vue'; -import { getStaticImageUrl } from '@/scripts/media-proxy'; import { char2twemojiFilePath, char2fluentEmojiFilePath } from '@/scripts/emoji-base'; import { defaultStore } from '@/store'; import { getEmojiName } from '@/scripts/emojilist'; -import { customEmojis } from '@/custom-emojis'; const props = defineProps<{ emoji: string; - normal?: boolean; - noStyle?: boolean; - isReaction?: boolean; - host?: string | null; }>(); const char2path = defaultStore.state.emojiStyle === 'twemoji' ? char2twemojiFilePath : char2fluentEmojiFilePath; -const isCustom = computed(() => props.emoji.startsWith(':')); -const customEmojiName = computed(() => props.emoji.substr(1, props.emoji.length - 2).replace('@.', '')); -const char = computed(() => isCustom.value ? undefined : props.emoji); -const useOsNativeEmojis = computed(() => defaultStore.state.emojiStyle === 'native' && !props.isReaction); +const useOsNativeEmojis = computed(() => defaultStore.state.emojiStyle === 'native'); const url = computed(() => { - if (char.value) { - return char2path(char.value); - } else if (props.host == null && !customEmojiName.value.includes('@')) { - const found = customEmojis.value.find(x => x.name === customEmojiName.value); - return found ? defaultStore.state.disableShowingAnimatedImages ? getStaticImageUrl(found.url) : found.url : null; - } else { - const rawUrl = props.host ? `/emoji/${customEmojiName.value}@${props.host}.webp` : `/emoji/${customEmojiName.value}.webp`; - return defaultStore.state.disableShowingAnimatedImages - ? getStaticImageUrl(rawUrl) - : rawUrl; - } + return char2path(props.emoji); }); -const alt = computed(() => isCustom.value ? `:${customEmojiName.value}:` : char.value); -let errored = $ref(isCustom.value && url.value == null); // Searching from an array with 2000 items for every emoji felt like too energy-consuming, so I decided to do it lazily on pointerenter function computeTitle(event: PointerEvent): void { - const title = isCustom.value - ? `:${customEmojiName.value}:` - : (getEmojiName(char.value as string) ?? char.value as string); + const title = getEmojiName(props.emoji as string) ?? props.emoji as string; (event.target as HTMLElement).title = title; } </script> @@ -58,27 +33,4 @@ function computeTitle(event: PointerEvent): void { height: 1.25em; vertical-align: -0.25em; } - -.custom { - height: 2.5em; - vertical-align: middle; - transition: transform 0.2s ease; - - &:hover { - transform: scale(1.2); - } -} - -.normal { - height: 1.25em; - vertical-align: -0.25em; - - &:hover { - transform: none; - } -} - -.noStyle { - height: auto !important; -} </style> diff --git a/packages/frontend/src/components/global/MkUserName.vue b/packages/frontend/src/components/global/MkUserName.vue index fc08310acc..4186a4a4fb 100644 --- a/packages/frontend/src/components/global/MkUserName.vue +++ b/packages/frontend/src/components/global/MkUserName.vue @@ -1,5 +1,5 @@ <template> -<Mfm :text="user.name ?? user.username" :author="user" :plain="true" :nowrap="nowrap"/> +<Mfm :text="user.name ?? user.username" :author="user" :plain="true" :nowrap="nowrap" :emoji-urls="user.emojis"/> </template> <script lang="ts" setup> diff --git a/packages/frontend/src/components/index.ts b/packages/frontend/src/components/index.ts index 8639257003..560870f84c 100644 --- a/packages/frontend/src/components/index.ts +++ b/packages/frontend/src/components/index.ts @@ -5,6 +5,7 @@ import MkA from './global/MkA.vue'; import MkAcct from './global/MkAcct.vue'; import MkAvatar from './global/MkAvatar.vue'; import MkEmoji from './global/MkEmoji.vue'; +import MkCustomEmoji from './global/MkCustomEmoji.vue'; import MkUserName from './global/MkUserName.vue'; import MkEllipsis from './global/MkEllipsis.vue'; import MkTime from './global/MkTime.vue'; @@ -26,6 +27,7 @@ export default function(app: App) { app.component('MkAcct', MkAcct); app.component('MkAvatar', MkAvatar); app.component('MkEmoji', MkEmoji); + app.component('MkCustomEmoji', MkCustomEmoji); app.component('MkUserName', MkUserName); app.component('MkEllipsis', MkEllipsis); app.component('MkTime', MkTime); @@ -47,6 +49,7 @@ declare module '@vue/runtime-core' { MkAcct: typeof MkAcct; MkAvatar: typeof MkAvatar; MkEmoji: typeof MkEmoji; + MkCustomEmoji: typeof MkCustomEmoji; MkUserName: typeof MkUserName; MkEllipsis: typeof MkEllipsis; MkTime: typeof MkTime; diff --git a/packages/frontend/src/components/mfm.ts b/packages/frontend/src/components/mfm.ts index b9ed9002ac..7cfb19aeb6 100644 --- a/packages/frontend/src/components/mfm.ts +++ b/packages/frontend/src/components/mfm.ts @@ -4,6 +4,7 @@ import MkUrl from '@/components/global/MkUrl.vue'; import MkLink from '@/components/MkLink.vue'; import MkMention from '@/components/MkMention.vue'; import MkEmoji from '@/components/global/MkEmoji.vue'; +import MkCustomEmoji from '@/components/global/MkCustomEmoji.vue'; import { concat } from '@/scripts/array'; import MkCode from '@/components/MkCode.vue'; import MkGoogle from '@/components/MkGoogle.vue'; @@ -47,6 +48,10 @@ export default defineComponent({ type: Boolean, default: true, }, + emojiUrls: { + type: Object, + default: null, + }, }, render() { @@ -301,20 +306,35 @@ export default defineComponent({ } case 'emojiCode': { - return [h(MkEmoji, { - key: Math.random(), - emoji: `:${token.props.name}:`, - normal: this.plain, + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (this.author?.host == null) { + return [h(MkCustomEmoji, { + key: Math.random(), + name: token.props.name, + normal: this.plain, + host: null, + })]; + } else { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - host: this.author?.host, - })]; + if (this.emojiUrls && (this.emojiUrls[token.props.name] == null)) { + return [h('span', `:${token.props.name}:`)]; + } else { + return [h(MkCustomEmoji, { + key: Math.random(), + name: token.props.name, + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + url: this.emojiUrls ? this.emojiUrls[token.props.name] : null, + normal: this.plain, + host: this.author.host, + })]; + } + } } case 'unicodeEmoji': { return [h(MkEmoji, { key: Math.random(), emoji: token.props.emoji, - normal: this.plain, })]; } diff --git a/packages/frontend/src/pages/about-misskey.vue b/packages/frontend/src/pages/about-misskey.vue index bc63d7159a..6d88feceaf 100644 --- a/packages/frontend/src/pages/about-misskey.vue +++ b/packages/frontend/src/pages/about-misskey.vue @@ -9,7 +9,10 @@ <img src="/client-assets/about-icon.png" alt="" class="icon" draggable="false" @load="iconLoaded" @click="gravity"/> <div class="misskey">Misskey</div> <div class="version">v{{ version }}</div> - <span v-for="emoji in easterEggEmojis" :key="emoji.id" class="emoji" :data-physics-x="emoji.left" :data-physics-y="emoji.top" :class="{ _physics_circle_: !emoji.emoji.startsWith(':') }"><MkEmoji class="emoji" :emoji="emoji.emoji" :is-reaction="false" :normal="true" :no-style="true"/></span> + <span v-for="emoji in easterEggEmojis" :key="emoji.id" class="emoji" :data-physics-x="emoji.left" :data-physics-y="emoji.top" :class="{ _physics_circle_: !emoji.emoji.startsWith(':') }"> + <MkCustomEmoji v-if="emoji.emoji[0] === ':'" class="emoji" :name="emoji.emoji" :normal="true" :no-style="true"/> + <MkEmoji v-else class="emoji" :emoji="emoji.emoji" :normal="true" :no-style="true"/> + </span> </div> <button v-if="thereIsTreasure" class="_button treasure" @click="getTreasure"><img src="/fluent-emoji/1f3c6.png" class="treasureImg"></button> </div> diff --git a/packages/frontend/src/pages/settings/reaction.vue b/packages/frontend/src/pages/settings/reaction.vue index e307abe2d4..c8b47b8299 100644 --- a/packages/frontend/src/pages/settings/reaction.vue +++ b/packages/frontend/src/pages/settings/reaction.vue @@ -6,7 +6,8 @@ <Sortable v-model="reactions" class="zoaiodol" :item-key="item => item" :animation="150" :delay="100" :delay-on-touch-only="true"> <template #item="{element}"> <button class="_button item" @click="remove(element, $event)"> - <MkEmoji :emoji="element" :normal="true"/> + <MkCustomEmoji v-if="element[0] === ':'" :name="element" :normal="true"/> + <MkEmoji v-else :emoji="element" :normal="true"/> </button> </template> <template #footer> |