mirror of
https://github.com/paricafe/misskey.git
synced 2025-03-23 11:39:25 -05:00
frontend: restore custom emoji and reaction viewer
This commit is contained in:
parent
4c5b20b4e1
commit
fac7f92682
4 changed files with 86 additions and 270 deletions
packages/frontend/src
|
@ -100,4 +100,4 @@ function getReactionName(reaction: string): string {
|
|||
.more {
|
||||
padding-top: 4px;
|
||||
}
|
||||
</style>
|
||||
</style>
|
|
@ -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,
|
||||
}, {
|
||||
|
|
|
@ -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>
|
|
@ -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;
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue