diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue index efda191a07..d7e24a04b9 100644 --- a/packages/frontend/src/components/MkNote.vue +++ b/packages/frontend/src/components/MkNote.vue @@ -210,7 +210,7 @@ import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm.js'; import { $i } from '@/i.js'; import { i18n } from '@/i18n.js'; import { getAbuseNoteMenu, getCopyNoteLinkMenu, getNoteClipMenu, getNoteMenu, getRenoteMenu } from '@/utility/get-note-menu.js'; -import { useNoteCapture } from '@/use/use-note-capture.js'; +import { noteEvents, useNoteCapture } from '@/use/use-note-capture.js'; import { deepClone } from '@/utility/clone.js'; import { useTooltip } from '@/use/use-tooltip.js'; import { claimAchievement } from '@/utility/achievements.js'; @@ -382,6 +382,11 @@ provide(DI.mfmEmojiReactCallback, (reaction) => { misskeyApi('notes/reactions/create', { noteId: appearNote.value.id, reaction: reaction, + }).then(() => { + noteEvents.emit(`reacted:${appearNote.value.id}`, { + userId: $i!.id, + reaction: reaction, + }); }); }); @@ -480,6 +485,11 @@ function react(): void { misskeyApi('notes/reactions/create', { noteId: appearNote.value.id, reaction: '❤️', + }).then(() => { + noteEvents.emit(`reacted:${appearNote.value.id}`, { + userId: $i!.id, + reaction: '❤️', + }); }); const el = reactButton.value; if (el && prefer.s.animation) { @@ -513,7 +523,10 @@ function react(): void { noteId: appearNote.value.id, reaction: reaction, }).then(() => { - // 別にthenを待たなくても良いかも(楽観的更新) + noteEvents.emit(`reacted:${appearNote.value.id}`, { + userId: $i!.id, + reaction: reaction, + }); }); if (appearNote.value.text && appearNote.value.text.length > 100 && (Date.now() - new Date(appearNote.value.createdAt).getTime() < 1000 * 3)) { diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue index 17a348affe..6d211bcdde 100644 --- a/packages/frontend/src/components/MkNoteDetailed.vue +++ b/packages/frontend/src/components/MkNoteDetailed.vue @@ -242,7 +242,7 @@ import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm.js'; import { $i } from '@/i.js'; import { i18n } from '@/i18n.js'; import { getNoteClipMenu, getNoteMenu, getRenoteMenu } from '@/utility/get-note-menu.js'; -import { useNoteCapture } from '@/use/use-note-capture.js'; +import { noteEvents, useNoteCapture } from '@/use/use-note-capture.js'; import { deepClone } from '@/utility/clone.js'; import { useTooltip } from '@/use/use-tooltip.js'; import { claimAchievement } from '@/utility/achievements.js'; @@ -343,6 +343,11 @@ provide(DI.mfmEmojiReactCallback, (reaction) => { misskeyApi('notes/reactions/create', { noteId: appearNote.value.id, reaction: reaction, + }).then(() => { + noteEvents.emit(`reacted:${appearNote.value.id}`, { + userId: $i!.id, + reaction: reaction, + }); }); }); @@ -445,6 +450,11 @@ function react(): void { misskeyApi('notes/reactions/create', { noteId: appearNote.value.id, reaction: '❤️', + }).then(() => { + noteEvents.emit(`reacted:${appearNote.value.id}`, { + userId: $i!.id, + reaction: '❤️', + }); }); const el = reactButton.value; if (el && prefer.s.animation) { @@ -472,6 +482,11 @@ function react(): void { misskeyApi('notes/reactions/create', { noteId: appearNote.value.id, reaction: reaction, + }).then(() => { + noteEvents.emit(`reacted:${appearNote.value.id}`, { + userId: $i!.id, + reaction: reaction, + }); }); if (appearNote.value.text && appearNote.value.text.length > 100 && (Date.now() - new Date(appearNote.value.createdAt).getTime() < 1000 * 3)) { claimAchievement('reactWithoutRead'); diff --git a/packages/frontend/src/components/MkNotifications.vue b/packages/frontend/src/components/MkNotifications.vue index fce635dbea..0a176163ed 100644 --- a/packages/frontend/src/components/MkNotifications.vue +++ b/packages/frontend/src/components/MkNotifications.vue @@ -105,11 +105,7 @@ function onNotification(notification) { } function reload() { - return new Promise((res) => { - paginator.reload().then(() => { - res(); - }); - }); + return paginator.reload(); } let connection: Misskey.ChannelConnection | null = null; diff --git a/packages/frontend/src/components/MkPagination.vue b/packages/frontend/src/components/MkPagination.vue index 1252982b8a..b2d367343b 100644 --- a/packages/frontend/src/components/MkPagination.vue +++ b/packages/frontend/src/components/MkPagination.vue @@ -9,6 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only :leaveActiveClass="prefer.s.animation ? $style.transition_fade_leaveActive : ''" :enterFromClass="prefer.s.animation ? $style.transition_fade_enterFrom : ''" :leaveToClass="prefer.s.animation ? $style.transition_fade_leaveTo : ''" + :css="prefer.s.animation" mode="out-in" > diff --git a/packages/frontend/src/components/MkReactionsViewer.reaction.vue b/packages/frontend/src/components/MkReactionsViewer.reaction.vue index 951447f15a..e85e569f41 100644 --- a/packages/frontend/src/components/MkReactionsViewer.reaction.vue +++ b/packages/frontend/src/components/MkReactionsViewer.reaction.vue @@ -36,6 +36,7 @@ import { checkReactionPermissions } from '@/utility/check-reaction-permissions.j import { customEmojisMap } from '@/custom-emojis.js'; import { prefer } from '@/preferences.js'; import { DI } from '@/di.js'; +import { noteEvents } from '@/use/use-note-capture.js'; const props = defineProps<{ reaction: string; @@ -83,10 +84,21 @@ async function toggleReaction() { misskeyApi('notes/reactions/delete', { noteId: props.note.id, }).then(() => { + noteEvents.emit(`unreacted:${props.note.id}`, { + userId: $i!.id, + reaction: props.reaction, + emoji: emoji.value, + }); if (oldReaction !== props.reaction) { misskeyApi('notes/reactions/create', { noteId: props.note.id, reaction: props.reaction, + }).then(() => { + noteEvents.emit(`reacted:${props.note.id}`, { + userId: $i!.id, + reaction: props.reaction, + emoji: emoji.value, + }); }); } }); @@ -110,6 +122,12 @@ async function toggleReaction() { misskeyApi('notes/reactions/create', { noteId: props.note.id, reaction: props.reaction, + }).then(() => { + noteEvents.emit(`reacted:${props.note.id}`, { + userId: $i!.id, + reaction: props.reaction, + emoji: emoji.value, + }); }); if (props.note.text && props.note.text.length > 100 && (Date.now() - new Date(props.note.createdAt).getTime() < 1000 * 3)) { claimAchievement('reactWithoutRead'); diff --git a/packages/frontend/src/use/use-note-capture.ts b/packages/frontend/src/use/use-note-capture.ts index 904b32b04a..63a5c8f945 100644 --- a/packages/frontend/src/use/use-note-capture.ts +++ b/packages/frontend/src/use/use-note-capture.ts @@ -12,11 +12,11 @@ import { $i } from '@/i.js'; import { store } from '@/store.js'; import { misskeyApi } from '@/utility/misskey-api.js'; -const noteEvents = new EventEmitter<{ - reacted: Misskey.entities.Note; - unreacted: Misskey.entities.Note; - pollVoted: Misskey.entities.Note; - deleted: Misskey.entities.Note; +export const noteEvents = new EventEmitter<{ + [`reacted:${string}`]: (ctx: { userId: Misskey.entities.User['id']; reaction: string; emoji?: { name: string; url: string; }; }) => void; + [`unreacted:${string}`]: (ctx: { userId: Misskey.entities.User['id']; reaction: string; emoji?: { name: string; url: string; }; }) => void; + [`pollVoted:${string}`]: (ctx: { userId: Misskey.entities.User['id']; choice: string; }) => void; + [`deleted:${string}`]: () => void; }>(); const fetchEvent = new EventEmitter<{ @@ -55,10 +55,6 @@ function pseudoNoteCapture(props: { const note = props.note; const pureNote = props.pureNote; - function onReacted(): void { - - } - function onFetched(data: Pick): void { note.value.reactions = data.reactions; note.value.reactionCount = Object.values(data.reactions).reduce((a, b) => a + b, 0); @@ -100,58 +96,33 @@ function realtimeNoteCapture(props: { switch (type) { case 'reacted': { - const reaction = body.reaction; - - if (body.emoji && !(body.emoji.name in note.value.reactionEmojis)) { - note.value.reactionEmojis[body.emoji.name] = body.emoji.url; - } - - // TODO: reactionsプロパティがない場合ってあったっけ? なければ || {} は消せる - const currentCount = (note.value.reactions || {})[reaction] || 0; - - note.value.reactions[reaction] = currentCount + 1; - note.value.reactionCount += 1; - - if ($i && (body.userId === $i.id)) { - note.value.myReaction = reaction; - } + noteEvents.emit(`reacted:${id}`, { + userId: body.userId, + reaction: body.reaction, + emoji: body.emoji, + }); break; } case 'unreacted': { - const reaction = body.reaction; - - // TODO: reactionsプロパティがない場合ってあったっけ? なければ || {} は消せる - const currentCount = (note.value.reactions || {})[reaction] || 0; - - note.value.reactions[reaction] = Math.max(0, currentCount - 1); - note.value.reactionCount = Math.max(0, note.value.reactionCount - 1); - if (note.value.reactions[reaction] === 0) delete note.value.reactions[reaction]; - - if ($i && (body.userId === $i.id)) { - note.value.myReaction = null; - } + noteEvents.emit(`unreacted:${id}`, { + userId: body.userId, + reaction: body.reaction, + emoji: body.emoji, + }); break; } case 'pollVoted': { - const choice = body.choice; - - const choices = [...note.value.poll.choices]; - choices[choice] = { - ...choices[choice], - votes: choices[choice].votes + 1, - ...($i && (body.userId === $i.id) ? { - isVoted: true, - } : {}), - }; - - note.value.poll.choices = choices; + noteEvents.emit(`pollVoted:${id}`, { + userId: body.userId, + choice: body.choice, + }); break; } case 'deleted': { - props.isDeletedRef.value = true; + noteEvents.emit(`deleted:${id}`); break; } } @@ -195,6 +166,80 @@ export function useNoteCapture(props: { pureNote: Ref; isDeletedRef: Ref; }) { + const note = props.note; + noteEvents.on(`reacted:${note.value.id}`, onReacted); + noteEvents.on(`unreacted:${note.value.id}`, onUnreacted); + noteEvents.on(`pollVoted:${note.value.id}`, onPollVoted); + noteEvents.on(`deleted:${note.value.id}`, onDeleted); + + let latestReactedKey: string | null = null; + let latestUnreactedKey: string | null = null; + let latestPollVotedKey: string | null = null; + + function onReacted(ctx: { userId: Misskey.entities.User['id']; reaction: string; emoji?: { name: string; url: string; }; }): void { + console.log('reacted', ctx); + const newReactedKey = `${ctx.userId}:${ctx.reaction}`; + if (newReactedKey === latestReactedKey) return; + latestReactedKey = newReactedKey; + + if (ctx.emoji && !(ctx.emoji.name in note.value.reactionEmojis)) { + note.value.reactionEmojis[ctx.emoji.name] = ctx.emoji.url; + } + + const currentCount = note.value.reactions[ctx.reaction] || 0; + + note.value.reactions[ctx.reaction] = currentCount + 1; + note.value.reactionCount += 1; + + if ($i && (ctx.userId === $i.id)) { + note.value.myReaction = ctx.reaction; + } + } + + function onUnreacted(ctx: { userId: Misskey.entities.User['id']; reaction: string; emoji?: { name: string; url: string; }; }): void { + const newUnreactedKey = `${ctx.userId}:${ctx.reaction}`; + if (newUnreactedKey === latestUnreactedKey) return; + latestUnreactedKey = newUnreactedKey; + + const currentCount = note.value.reactions[ctx.reaction] || 0; + + note.value.reactions[ctx.reaction] = Math.max(0, currentCount - 1); + note.value.reactionCount = Math.max(0, note.value.reactionCount - 1); + if (note.value.reactions[ctx.reaction] === 0) delete note.value.reactions[ctx.reaction]; + + if ($i && (ctx.userId === $i.id)) { + note.value.myReaction = null; + } + } + + function onPollVoted(ctx: { userId: Misskey.entities.User['id']; choice: string; }): void { + const newPollVotedKey = `${ctx.userId}:${ctx.choice}`; + if (newPollVotedKey === latestPollVotedKey) return; + latestPollVotedKey = newPollVotedKey; + + const choices = [...note.value.poll.choices]; + choices[ctx.choice] = { + ...choices[ctx.choice], + votes: choices[ctx.choice].votes + 1, + ...($i && (ctx.userId === $i.id) ? { + isVoted: true, + } : {}), + }; + + note.value.poll.choices = choices; + } + + function onDeleted(): void { + props.isDeletedRef.value = true; + } + + onUnmounted(() => { + noteEvents.off(`reacted:${note.value.id}`, onReacted); + noteEvents.off(`unreacted:${note.value.id}`, onUnreacted); + noteEvents.off(`pollVoted:${note.value.id}`, onPollVoted); + noteEvents.off(`deleted:${note.value.id}`, onDeleted); + }); + if ($i && store.s.realtimeMode) { realtimeNoteCapture(props); } else {