mirror of
https://github.com/paricafe/misskey.git
synced 2024-11-28 09:36:43 -06:00
frontend: merge reactions
This commit is contained in:
parent
0cbb883609
commit
8f00aecd88
3 changed files with 167 additions and 43 deletions
|
@ -30,7 +30,7 @@ import MkReactionIcon from '@/components/MkReactionIcon.vue';
|
||||||
defineProps<{
|
defineProps<{
|
||||||
showing: boolean;
|
showing: boolean;
|
||||||
reaction: string;
|
reaction: string;
|
||||||
users: any[]; // TODO
|
users: any[];
|
||||||
count: number;
|
count: number;
|
||||||
targetElement: HTMLElement;
|
targetElement: HTMLElement;
|
||||||
}>();
|
}>();
|
||||||
|
|
|
@ -8,8 +8,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
ref="buttonEl"
|
ref="buttonEl"
|
||||||
v-ripple="canToggle"
|
v-ripple="canToggle"
|
||||||
class="_button"
|
class="_button"
|
||||||
:class="[$style.root, { [$style.reacted]: note.myReaction == reaction, [$style.canToggle]: canToggle, [$style.small]: defaultStore.state.reactionsDisplaySize === 'small', [$style.large]: defaultStore.state.reactionsDisplaySize === 'large' }]"
|
:class="[$style.root, { [$style.reacted]: isReacted, [$style.canToggle]: canToggle, [$style.small]: defaultStore.state.reactionsDisplaySize === 'small', [$style.large]: defaultStore.state.reactionsDisplaySize === 'large' }]"
|
||||||
@click.stop="toggleReaction()"
|
@click.stop="toggleReaction()"
|
||||||
@contextmenu.prevent.stop="menu"
|
@contextmenu.prevent.stop="menu"
|
||||||
>
|
>
|
||||||
<MkReactionIcon :class="defaultStore.state.limitWidthOfReaction ? $style.limitWidth : ''" :reaction="reaction" :emojiUrl="note.reactionEmojis[reaction.substring(1, reaction.length - 1)]"/>
|
<MkReactionIcon :class="defaultStore.state.limitWidthOfReaction ? $style.limitWidth : ''" :reaction="reaction" :emojiUrl="note.reactionEmojis[reaction.substring(1, reaction.length - 1)]"/>
|
||||||
|
@ -18,7 +18,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed, inject, onMounted, shallowRef, watch } from 'vue';
|
import { computed, inject, onMounted, onBeforeMount, shallowRef, watch } from 'vue';
|
||||||
import * as Misskey from 'misskey-js';
|
import * as Misskey from 'misskey-js';
|
||||||
import { getUnicodeEmoji } from '@@/js/emojilist.js';
|
import { getUnicodeEmoji } from '@@/js/emojilist.js';
|
||||||
import MkCustomEmojiDetailedDialog from './MkCustomEmojiDetailedDialog.vue';
|
import MkCustomEmojiDetailedDialog from './MkCustomEmojiDetailedDialog.vue';
|
||||||
|
@ -33,9 +33,33 @@ import { claimAchievement } from '@/scripts/achievements.js';
|
||||||
import { defaultStore } from '@/store.js';
|
import { defaultStore } from '@/store.js';
|
||||||
import { i18n } from '@/i18n.js';
|
import { i18n } from '@/i18n.js';
|
||||||
import * as sound from '@/scripts/sound.js';
|
import * as sound from '@/scripts/sound.js';
|
||||||
import { checkReactionPermissions } from '@/scripts/check-reaction-permissions.js';
|
|
||||||
import { customEmojisMap } from '@/custom-emojis.js';
|
import { customEmojisMap } from '@/custom-emojis.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)) {
|
||||||
|
return reactionCache.get(reaction)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<{
|
const props = defineProps<{
|
||||||
reaction: string;
|
reaction: string;
|
||||||
count: number;
|
count: number;
|
||||||
|
@ -54,26 +78,32 @@ const buttonEl = shallowRef<HTMLElement>();
|
||||||
const emojiName = computed(() => props.reaction.replace(/:/g, '').replace(/@\./, ''));
|
const emojiName = computed(() => props.reaction.replace(/:/g, '').replace(/@\./, ''));
|
||||||
const emoji = computed(() => customEmojisMap.get(emojiName.value) ?? getUnicodeEmoji(props.reaction));
|
const emoji = computed(() => customEmojisMap.get(emojiName.value) ?? getUnicodeEmoji(props.reaction));
|
||||||
|
|
||||||
const canToggle = computed(() => {
|
const reactionInfo = computed(() => getReactionInfo(props.reaction));
|
||||||
return !props.reaction.match(/@\w/) && $i && emoji.value && checkReactionPermissions($i, props.note, emoji.value);
|
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 canGetInfo = computed(() => !props.reaction.match(/@\w/) && props.reaction.includes(':'));
|
|
||||||
|
let lastCount = props.count;
|
||||||
|
|
||||||
async function toggleReaction() {
|
async function toggleReaction() {
|
||||||
if (!canToggle.value) return;
|
if (!canToggle.value) return;
|
||||||
|
|
||||||
const oldReaction = props.note.myReaction;
|
const oldReaction = props.note.myReaction;
|
||||||
if (oldReaction) {
|
|
||||||
|
if (isReacted.value) {
|
||||||
const confirm = await os.confirm({
|
const confirm = await os.confirm({
|
||||||
type: 'warning',
|
type: 'warning',
|
||||||
text: oldReaction !== props.reaction ? i18n.ts.changeReactionConfirm : i18n.ts.cancelReactionConfirm,
|
text: i18n.ts.cancelReactionConfirm,
|
||||||
});
|
});
|
||||||
if (confirm.canceled) return;
|
if (confirm.canceled) return;
|
||||||
|
|
||||||
if (oldReaction !== props.reaction) {
|
|
||||||
sound.playMisskeySfx('reaction');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mock) {
|
if (mock) {
|
||||||
emit('reactionToggled', props.reaction, (props.count - 1));
|
emit('reactionToggled', props.reaction, (props.count - 1));
|
||||||
return;
|
return;
|
||||||
|
@ -81,15 +111,17 @@ async function toggleReaction() {
|
||||||
|
|
||||||
misskeyApi('notes/reactions/delete', {
|
misskeyApi('notes/reactions/delete', {
|
||||||
noteId: props.note.id,
|
noteId: props.note.id,
|
||||||
}).then(() => {
|
|
||||||
if (oldReaction !== props.reaction) {
|
|
||||||
misskeyApi('notes/reactions/create', {
|
|
||||||
noteId: props.note.id,
|
|
||||||
reaction: props.reaction,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
} else {
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (oldReaction) {
|
||||||
|
const confirm = await os.confirm({
|
||||||
|
type: 'warning',
|
||||||
|
text: i18n.ts.changeReactionConfirm,
|
||||||
|
});
|
||||||
|
if (confirm.canceled) return;
|
||||||
|
|
||||||
sound.playMisskeySfx('reaction');
|
sound.playMisskeySfx('reaction');
|
||||||
|
|
||||||
if (mock) {
|
if (mock) {
|
||||||
|
@ -97,18 +129,30 @@ async function toggleReaction() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
misskeyApi('notes/reactions/create', {
|
await misskeyApi('notes/reactions/delete', {
|
||||||
noteId: props.note.id,
|
noteId: props.note.id,
|
||||||
reaction: props.reaction,
|
|
||||||
});
|
});
|
||||||
if (props.note.text && props.note.text.length > 100 && (Date.now() - new Date(props.note.createdAt).getTime() < 1000 * 3)) {
|
}
|
||||||
claimAchievement('reactWithoutRead');
|
|
||||||
}
|
sound.playMisskeySfx('reaction');
|
||||||
|
|
||||||
|
if (mock) {
|
||||||
|
emit('reactionToggled', props.reaction, (props.count + 1));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
misskeyApi('notes/reactions/create', {
|
||||||
|
noteId: props.note.id,
|
||||||
|
reaction: baseReaction.value,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (props.note.text && props.note.text.length > 100 && (Date.now() - new Date(props.note.createdAt).getTime() < 1000 * 3)) {
|
||||||
|
claimAchievement('reactWithoutRead');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function menu(ev) {
|
async function menu(ev) {
|
||||||
if (!canGetInfo.value) return;
|
if (!props.reaction.includes(':')) return;
|
||||||
|
|
||||||
os.popupMenu([{
|
os.popupMenu([{
|
||||||
text: i18n.ts.info,
|
text: i18n.ts.info,
|
||||||
|
@ -137,28 +181,54 @@ function anime() {
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(() => props.count, (newCount, oldCount) => {
|
watch(() => props.count, (newCount, oldCount) => {
|
||||||
if (oldCount < newCount) anime();
|
if (oldCount < newCount && !props.isInitial) anime();
|
||||||
});
|
lastCount = newCount;
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
onMounted(() => {
|
onBeforeMount(() => {
|
||||||
if (!props.isInitial) anime();
|
getReactionInfo(props.reaction);
|
||||||
|
if (props.note.myReaction) {
|
||||||
|
getReactionInfo(props.note.myReaction);
|
||||||
|
}
|
||||||
|
Object.keys(props.note.reactions).forEach(reaction => {
|
||||||
|
getReactionInfo(reaction);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!mock) {
|
if (!mock) {
|
||||||
useTooltip(buttonEl, async (showing) => {
|
useTooltip(buttonEl, async (showing) => {
|
||||||
const reactions = await misskeyApiGet('notes/reactions', {
|
const allVariants = new Set([props.reaction]);
|
||||||
noteId: props.note.id,
|
|
||||||
type: props.reaction,
|
if (reactionInfo.value.hasNative) {
|
||||||
limit: 10,
|
allVariants.add(reactionInfo.value.base);
|
||||||
_cacheKey_: props.count,
|
|
||||||
});
|
Object.keys(props.note.reactions).forEach(reaction => {
|
||||||
|
const info = getReactionInfo(reaction);
|
||||||
|
if (info.hasNative && info.base === reactionInfo.value.base) {
|
||||||
|
allVariants.add(reaction);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const users = reactions.map(x => x.user);
|
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 { dispose } = os.popup(XDetails, {
|
const { dispose } = os.popup(XDetails, {
|
||||||
showing,
|
showing,
|
||||||
reaction: props.reaction,
|
reaction: props.reaction,
|
||||||
users,
|
users: allUsers,
|
||||||
count: props.count,
|
count: props.count,
|
||||||
targetElement: buttonEl.value,
|
targetElement: buttonEl.value,
|
||||||
}, {
|
}, {
|
||||||
|
|
|
@ -12,16 +12,40 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
:moveClass="defaultStore.state.animation ? $style.transition_x_move : ''"
|
:moveClass="defaultStore.state.animation ? $style.transition_x_move : ''"
|
||||||
tag="div" :class="$style.root"
|
tag="div" :class="$style.root"
|
||||||
>
|
>
|
||||||
<XReaction v-for="[reaction, count] in reactions" :key="reaction" :reaction="reaction" :count="count" :isInitial="initialReactions.has(reaction)" :note="note" @reactionToggled="onMockToggleReaction"/>
|
<XReaction v-for="[reaction, count] in mergedReactions" :key="reaction" :reaction="reaction" :count="count" :isInitial="initialReactions.has(reaction)" :note="note" @reactionToggled="onMockToggleReaction"/>
|
||||||
<slot v-if="hasMoreReactions" name="more"/>
|
<slot v-if="hasMoreReactions" name="more"/>
|
||||||
</TransitionGroup>
|
</TransitionGroup>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import * as Misskey from 'misskey-js';
|
import * as Misskey from 'misskey-js';
|
||||||
import { inject, watch, ref } from 'vue';
|
import { inject, watch, ref, computed, onBeforeMount } from 'vue';
|
||||||
import XReaction from '@/components/MkReactionsViewer.reaction.vue';
|
import XReaction from '@/components/MkReactionsViewer.reaction.vue';
|
||||||
import { defaultStore } from '@/store.js';
|
import { defaultStore } from '@/store.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)) return emojiCache.get(reaction)!;
|
||||||
|
|
||||||
|
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<{
|
const props = withDefaults(defineProps<{
|
||||||
note: Misskey.entities.Note;
|
note: Misskey.entities.Note;
|
||||||
|
@ -41,10 +65,40 @@ const initialReactions = new Set(Object.keys(props.note.reactions));
|
||||||
const reactions = ref<[string, number][]>([]);
|
const reactions = ref<[string, number][]>([]);
|
||||||
const hasMoreReactions = ref(false);
|
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)) {
|
if (props.note.myReaction && !Object.keys(reactions.value).includes(props.note.myReaction)) {
|
||||||
reactions.value[props.note.myReaction] = props.note.reactions[props.note.myReaction];
|
reactions.value[props.note.myReaction] = props.note.reactions[props.note.myReaction];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onBeforeMount(() => {
|
||||||
|
Object.keys(props.note.reactions).forEach(reaction => {
|
||||||
|
hasLocalEmoji(reaction);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
function onMockToggleReaction(emoji: string, count: number) {
|
function onMockToggleReaction(emoji: string, count: number) {
|
||||||
if (!mock) return;
|
if (!mock) return;
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue