diff --git a/packages/frontend/src/components/MkReactionsViewer.details.vue b/packages/frontend/src/components/MkReactionsViewer.details.vue index d24e0b15bf..f41a8a40f3 100644 --- a/packages/frontend/src/components/MkReactionsViewer.details.vue +++ b/packages/frontend/src/components/MkReactionsViewer.details.vue @@ -100,4 +100,4 @@ function getReactionName(reaction: string): string { .more { padding-top: 4px; } -</style> +</style> \ No newline at end of file diff --git a/packages/frontend/src/components/MkReactionsViewer.reaction.vue b/packages/frontend/src/components/MkReactionsViewer.reaction.vue index 56bbf1b0b5..f35c938d02 100644 --- a/packages/frontend/src/components/MkReactionsViewer.reaction.vue +++ b/packages/frontend/src/components/MkReactionsViewer.reaction.vue @@ -18,7 +18,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { computed, inject, onBeforeMount, shallowRef, watch } from 'vue'; +import { computed, inject, onMounted, shallowRef, watch } from 'vue'; import * as Misskey from 'misskey-js'; import { getUnicodeEmoji } from '@@/js/emojilist.js'; import MkCustomEmojiDetailedDialog from './MkCustomEmojiDetailedDialog.vue'; @@ -32,37 +32,10 @@ import MkReactionEffect from '@/components/MkReactionEffect.vue'; import { claimAchievement } from '@/utility/achievements.js'; import { i18n } from '@/i18n.js'; import * as sound from '@/utility/sound.js'; +import { checkReactionPermissions } from '@/utility/check-reaction-permissions.js'; import { customEmojisMap } from '@/custom-emojis.js'; import { prefer } from '@/preferences.js'; -const localEmojiSet = new Set(Array.from(customEmojisMap.keys())); -const reactionCache = new Map<string, { hasNative: boolean; base: string }>(); - -function getReactionInfo(reaction: string) { - if (reactionCache.has(reaction)) { - const cachedReaction = reactionCache.get(reaction); - if (cachedReaction) { - return cachedReaction; - } - } - - let hasNative: boolean; - let base: string; - - if (!reaction.includes(':')) { - hasNative = true; - base = reaction; - } else { - const baseName = reaction.split('@')[0].split(':')[1]; - hasNative = localEmojiSet.has(baseName); - base = hasNative ? `:${baseName}:` : reaction; - } - - const info = { hasNative, base }; - reactionCache.set(reaction, info); - return info; -} - const props = defineProps<{ reaction: string; count: number; @@ -81,39 +54,40 @@ const buttonEl = shallowRef<HTMLElement>(); const emojiName = computed(() => props.reaction.replace(/:/g, '').replace(/@\./, '')); const emoji = computed(() => customEmojisMap.get(emojiName.value) ?? getUnicodeEmoji(props.reaction)); -const reactionInfo = computed(() => getReactionInfo(props.reaction)); -const hasNativeEmoji = computed(() => reactionInfo.value.hasNative); -const baseReaction = computed(() => reactionInfo.value.base); - -const canToggle = computed(() => $i != null && hasNativeEmoji.value); - -const isReacted = computed(() => { - if (!props.note.myReaction) return false; - const myInfo = getReactionInfo(props.note.myReaction); - return myInfo.base === reactionInfo.value.base; +const canToggle = computed(() => { + return !props.reaction.match(/@\w/) && $i && emoji.value && checkReactionPermissions($i, props.note, emoji.value); }); - -let lastCount = props.count; +const canGetInfo = computed(() => !props.reaction.match(/@\w/) && props.reaction.includes(':')); async function toggleReaction() { if (!canToggle.value) return; const oldReaction = props.note.myReaction; - - if (isReacted.value) { + if (oldReaction) { const confirm = await os.confirm({ type: 'warning', - text: i18n.ts.cancelReactionConfirm, + text: oldReaction !== props.reaction ? i18n.ts.changeReactionConfirm : i18n.ts.cancelReactionConfirm, }); if (confirm.canceled) return; + if (oldReaction !== props.reaction) { + sound.playMisskeySfx('reaction'); + } + if (mock) { emit('reactionToggled', props.reaction, (props.count - 1)); return; } - await misskeyApi('notes/reactions/delete', { + misskeyApi('notes/reactions/delete', { noteId: props.note.id, + }).then(() => { + if (oldReaction !== props.reaction) { + misskeyApi('notes/reactions/create', { + noteId: props.note.id, + reaction: props.reaction, + }); + } }); } else { if (prefer.s.confirmOnReact) { @@ -121,28 +95,10 @@ async function toggleReaction() { type: 'question', text: i18n.tsx.reactAreYouSure({ emoji: props.reaction.replace('@.', '') }), }); + if (confirm.canceled) return; } - if (oldReaction) { - const confirm = await os.confirm({ - type: 'warning', - text: i18n.ts.changeReactionConfirm, - }); - if (confirm.canceled) return; - - sound.playMisskeySfx('reaction'); - - if (mock) { - emit('reactionToggled', props.reaction, (props.count + 1)); - return; - } - - await misskeyApi('notes/reactions/delete', { - noteId: props.note.id, - }); - } - sound.playMisskeySfx('reaction'); if (mock) { @@ -152,9 +108,8 @@ async function toggleReaction() { misskeyApi('notes/reactions/create', { noteId: props.note.id, - reaction: baseReaction.value, + reaction: props.reaction, }); - if (props.note.text && props.note.text.length > 100 && (Date.now() - new Date(props.note.createdAt).getTime() < 1000 * 3)) { claimAchievement('reactWithoutRead'); } @@ -162,7 +117,7 @@ async function toggleReaction() { } async function menu(ev) { - if (!props.reaction.includes(':')) return; + if (!canGetInfo.value) return; os.popupMenu([{ text: i18n.ts.info, @@ -191,54 +146,28 @@ function anime() { } watch(() => props.count, (newCount, oldCount) => { - if (oldCount < newCount && !props.isInitial) anime(); - lastCount = newCount; -}, { immediate: true }); + if (oldCount < newCount) anime(); +}); -onBeforeMount(() => { - getReactionInfo(props.reaction); - if (props.note.myReaction) { - getReactionInfo(props.note.myReaction); - } - Object.keys(props.note.reactions).forEach(reaction => { - getReactionInfo(reaction); - }); +onMounted(() => { + if (!props.isInitial) anime(); }); if (!mock) { useTooltip(buttonEl, async (showing) => { - const allVariants = new Set([props.reaction]); + const reactions = await misskeyApiGet('notes/reactions', { + noteId: props.note.id, + type: props.reaction, + limit: 10, + _cacheKey_: props.count, + }); - if (reactionInfo.value.hasNative) { - allVariants.add(reactionInfo.value.base); - - Object.keys(props.note.reactions).forEach(reaction => { - const info = getReactionInfo(reaction); - if (info.hasNative && info.base === reactionInfo.value.base) { - allVariants.add(reaction); - } - }); - } - - const reactionPromises = Array.from(allVariants).map(variant => - misskeyApiGet('notes/reactions', { - noteId: props.note.id, - type: variant, - limit: 10, - _cacheKey_: props.count, - }), - ); - - const allReactions = await Promise.all(reactionPromises); - - const allUsers = [...new Map( - allReactions.flat().map(x => [x.user.id, x.user]), - ).values()]; + const users = reactions.map(x => x.user); const { dispose } = os.popup(XDetails, { showing, reaction: props.reaction, - users: allUsers, + users, count: props.count, targetElement: buttonEl.value, }, { diff --git a/packages/frontend/src/components/MkReactionsViewer.vue b/packages/frontend/src/components/MkReactionsViewer.vue index c3266c13e3..8acbdd5a05 100644 --- a/packages/frontend/src/components/MkReactionsViewer.vue +++ b/packages/frontend/src/components/MkReactionsViewer.vue @@ -12,43 +12,16 @@ SPDX-License-Identifier: AGPL-3.0-only :moveClass="prefer.s.animation ? $style.transition_x_move : ''" tag="div" :class="$style.root" > - <XReaction v-for="[reaction, count] in mergedReactions" :key="reaction" :reaction="reaction" :count="count" :isInitial="initialReactions.has(reaction)" :note="note" @reactionToggled="onMockToggleReaction"/> + <XReaction v-for="[reaction, count] in reactions" :key="reaction" :reaction="reaction" :count="count" :isInitial="initialReactions.has(reaction)" :note="note" @reactionToggled="onMockToggleReaction"/> <slot v-if="hasMoreReactions" name="more"/> </TransitionGroup> </template> <script lang="ts" setup> import * as Misskey from 'misskey-js'; -import { inject, watch, ref, computed, onBeforeMount } from 'vue'; +import { inject, watch, ref } from 'vue'; import XReaction from '@/components/MkReactionsViewer.reaction.vue'; import { prefer } from '@/preferences.js'; -import { customEmojisMap } from '@/custom-emojis.js'; - -const localEmojiSet = new Set(Array.from(customEmojisMap.keys())); -const emojiCache = new Map<string, boolean>(); - -function hasLocalEmoji(reaction: string): boolean { - if (emojiCache.has(reaction)) { - const cachedResult = emojiCache.get(reaction); - if (cachedResult !== undefined) return cachedResult; - } - - let result: boolean; - if (!reaction.includes(':')) { - result = true; - } else { - const emojiName = reaction.split('@')[0].split(':')[1]; - result = localEmojiSet.has(emojiName); - } - - emojiCache.set(reaction, result); - return result; -} - -function getBaseReaction(reaction: string): string { - if (!reaction.includes(':')) return reaction; - return `:${reaction.split('@')[0].split(':')[1]}:`; -} const props = withDefaults(defineProps<{ note: Misskey.entities.Note; @@ -63,44 +36,16 @@ const emit = defineEmits<{ (ev: 'mockUpdateMyReaction', emoji: string, delta: number): void; }>(); -const initialReactions = new Set(Object.keys(props.note.reactions)); +const initialReactions = ref(new Set<string>()); const reactions = ref<[string, number][]>([]); const hasMoreReactions = ref(false); -const mergedReactions = computed(() => { - const reactionMap = new Map(); - - reactions.value.forEach(([reaction, count]) => { - if (!hasLocalEmoji(reaction)) { - if (reactionMap.has(reaction)) { - reactionMap.set(reaction, reactionMap.get(reaction) + count); - } else { - reactionMap.set(reaction, count); - } - return; - } - - const baseReaction = getBaseReaction(reaction); - if (reactionMap.has(baseReaction)) { - reactionMap.set(baseReaction, reactionMap.get(baseReaction) + count); - } else { - reactionMap.set(baseReaction, count); - } - }); - - return Array.from(reactionMap.entries()); -}); - -if (props.note.myReaction && !Object.keys(reactions.value).includes(props.note.myReaction)) { - reactions.value[props.note.myReaction] = props.note.reactions[props.note.myReaction]; -} - -onBeforeMount(() => { - Object.keys(props.note.reactions).forEach(reaction => { - hasLocalEmoji(reaction); - }); -}); +watch(() => props.note.myReaction, (newMyReaction) => { + if (newMyReaction && !Object.keys(reactions.value).includes(newMyReaction)) { + reactions.value[newMyReaction] = props.note.reactions[newMyReaction]; + } +}, { immediate: true }); function onMockToggleReaction(emoji: string, count: number) { if (!mock) return; @@ -112,6 +57,7 @@ function onMockToggleReaction(emoji: string, count: number) { } watch([() => props.note.reactions, () => props.maxNumber], ([newSource, maxNumber]) => { + initialReactions.value = new Set(Object.keys(newSource)); let newReactions: [string, number][] = []; hasMoreReactions.value = Object.keys(newSource).length > maxNumber; @@ -128,7 +74,7 @@ watch([() => props.note.reactions, () => props.maxNumber], ([newSource, maxNumbe ...newReactions, ...Object.entries(newSource) .sort(([, a], [, b]) => b - a) - .filter(([y], i) => i < maxNumber && !newReactionsNames.includes(y)) as [string, number][], + .filter(([y], i) => i < maxNumber && !newReactionsNames.includes(y)), ]; newReactions = newReactions.slice(0, props.maxNumber); @@ -166,4 +112,4 @@ watch([() => props.note.reactions, () => props.maxNumber], ([newSource, maxNumbe display: none; } } -</style> +</style> \ No newline at end of file diff --git a/packages/frontend/src/custom-emojis.ts b/packages/frontend/src/custom-emojis.ts index c226b711a1..45d4b40fd7 100644 --- a/packages/frontend/src/custom-emojis.ts +++ b/packages/frontend/src/custom-emojis.ts @@ -8,128 +8,69 @@ import * as Misskey from 'misskey-js'; import { misskeyApi, misskeyApiGet } from '@/utility/misskey-api.js'; import { get, set } from '@/utility/idb-proxy.js'; -const CACHE_EXPIRE_TIME = 12 * 60 * 60 * 1000; -const BATCH_SIZE = 1000; - const storageCache = await get('emojis'); -export const customEmojis = shallowRef<Misskey.entities.EmojiSimple[]>( - Array.isArray(storageCache) ? storageCache : [], -); - -const categoriesMap = new Map<string, Misskey.entities.EmojiSimple[]>(); - -export const customEmojiCategories = computed<[...string[], null]>(() => { - if (categoriesMap.size === 0 && customEmojis.value.length > 0) { - for (const emoji of customEmojis.value) { - const category = emoji.category && emoji.category !== 'null' - ? emoji.category - : 'null'; - - if (!categoriesMap.has(category)) { - categoriesMap.set(category, []); - } - categoriesMap.get(category)?.push(emoji); +export const customEmojis = shallowRef<Misskey.entities.EmojiSimple[]>(Array.isArray(storageCache) ? storageCache : []); +export const customEmojiCategories = computed<[ ...string[], null ]>(() => { + const categories = new Set<string>(); + for (const emoji of customEmojis.value) { + if (emoji.category && emoji.category !== 'null') { + categories.add(emoji.category); } } - return markRaw([...Array.from(categoriesMap.keys()), null]); + return markRaw([...Array.from(categories), null]); }); export const customEmojisMap = new Map<string, Misskey.entities.EmojiSimple>(); - -function batchUpdateMap(emojis: Misskey.entities.EmojiSimple[]): void { - customEmojisMap.clear(); - categoriesMap.clear(); - - for (let i = 0; i < emojis.length; i += BATCH_SIZE) { - const batch = emojis.slice(i, i + BATCH_SIZE); - for (const emoji of batch) { - customEmojisMap.set(emoji.name, emoji); - } - } -} - watch(customEmojis, emojis => { - batchUpdateMap(emojis); + customEmojisMap.clear(); + for (const emoji of emojis) { + customEmojisMap.set(emoji.name, emoji); + } }, { immediate: true }); -export function addCustomEmoji(emoji: Misskey.entities.EmojiSimple): void { - const newEmojis = [emoji, ...customEmojis.value]; - customEmojis.value = newEmojis; - customEmojisMap.set(emoji.name, emoji); - void set('emojis', newEmojis); +export function addCustomEmoji(emoji: Misskey.entities.EmojiSimple) { + customEmojis.value = [emoji, ...customEmojis.value]; + set('emojis', customEmojis.value); } -export function updateCustomEmojis(emojis: Misskey.entities.EmojiSimple[]): void { - const updateMap = new Map(emojis.map(emoji => [emoji.name, emoji])); - - const newEmojis = customEmojis.value.map(item => - updateMap.get(item.name) ?? item, - ); - - customEmojis.value = newEmojis; - batchUpdateMap(newEmojis); - void set('emojis', newEmojis); +export function updateCustomEmojis(emojis: Misskey.entities.EmojiSimple[]) { + customEmojis.value = customEmojis.value.map(item => emojis.find(search => search.name === item.name) ?? item); + set('emojis', customEmojis.value); } -export function removeCustomEmojis(emojis: Misskey.entities.EmojiSimple[]): void { - const removedNames = new Set(emojis.map(e => e.name)); - - const filteredEmojis = customEmojis.value.filter( - item => !removedNames.has(item.name), - ); - - customEmojis.value = filteredEmojis; - batchUpdateMap(filteredEmojis); - void set('emojis', filteredEmojis); +export function removeCustomEmojis(emojis: Misskey.entities.EmojiSimple[]) { + customEmojis.value = customEmojis.value.filter(item => !emojis.some(search => search.name === item.name)); + set('emojis', customEmojis.value); } -export async function fetchCustomEmojis(force = false): Promise<void> { +export async function fetchCustomEmojis(force = false) { const now = Date.now(); - if (!force) { + let res; + if (force) { + res = await misskeyApi('emojis', {}); + } else { const lastFetchedAt = await get('lastEmojisFetchedAt'); - if (lastFetchedAt && (now - lastFetchedAt) < CACHE_EXPIRE_TIME) { - return; - } + if (lastFetchedAt && (now - lastFetchedAt) < 1000 * 60 * 60) return; + res = await misskeyApiGet('emojis', {}); } - try { - const res = await (force ? misskeyApi : misskeyApiGet)('emojis', {}); - - if (res.emojis.length > 0) { - customEmojis.value = res.emojis; - await Promise.all([ - set('emojis', res.emojis), - set('lastEmojisFetchedAt', now), - ]); - } - } catch (error) { - console.error('Failed to fetch emojis:', error); - } + customEmojis.value = res.emojis; + set('emojis', res.emojis); + set('lastEmojisFetchedAt', now); } -const tagsCache = new Map<string, string[]>(); +let cachedTags; +export function getCustomEmojiTags() { + if (cachedTags) return cachedTags; -export function getCustomEmojiTags(): string[] { - const cacheKey = `${customEmojis.value.length}`; - - if (tagsCache.has(cacheKey)) { - const cached = tagsCache.get(cacheKey); - if (cached) return cached; - } - - const tags = new Set<string>(); - - for (let i = 0; i < customEmojis.value.length; i += BATCH_SIZE) { - const batch = customEmojis.value.slice(i, i + BATCH_SIZE); - for (const emoji of batch) { - for (const tag of emoji.aliases) { - tags.add(tag); - } + const tags = new Set(); + for (const emoji of customEmojis.value) { + for (const tag of emoji.aliases) { + tags.add(tag); } } - - const result = Array.from(tags); - tagsCache.set(cacheKey, result); - return result; + const res = Array.from(tags); + cachedTags = res; + return res; }