/* * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ import { onUnmounted } from 'vue'; import * as Misskey from 'misskey-js'; import { EventEmitter } from 'eventemitter3'; import type { Ref, ShallowRef } from 'vue'; import { useStream } from '@/stream.js'; import { $i } from '@/i.js'; import { store } from '@/store.js'; import { misskeyApi } from '@/utility/misskey-api.js'; 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<{ [id: string]: Pick<Misskey.entities.Note, 'reactions' | 'reactionEmojis'>; }>(); const capturedNoteIdMapForPolling = new Map<string, number>(); const CAPTURE_MAX = 30; const POLLING_INTERVAL = 1000 * 15; window.setInterval(() => { // TODO: IDごとにpriorityを付け、CAPTURE_MAXを超えた場合は優先度の低いものから削除する // priorityは 自分のノート > リノートされているノート > その他のノート > 投稿から3分以上経過しているノート の順で高くするとよさそう const ids = [...capturedNoteIdMapForPolling.keys()].sort((a, b) => (a > b ? -1 : 1)).slice(0, CAPTURE_MAX); // 新しいものを優先するためにIDで降順ソート if (ids.length === 0) return; if (window.document.hidden) return; // まとめてリクエストするのではなく、個別にHTTPリクエスト投げてCDNにキャッシュさせた方がサーバーの負荷低減には良いかもしれない? misskeyApi('notes/show-partial-bulk', { noteIds: ids, }).then((items) => { for (const item of items) { fetchEvent.emit(item.id, { reactions: item.reactions, reactionEmojis: item.reactionEmojis, }); } }); }, POLLING_INTERVAL); function pollingSubscribe(props: { note: Ref<Misskey.entities.Note>; isDeletedRef: Ref<boolean>; }) { const note = props.note; function onFetched(data: Pick<Misskey.entities.Note, 'reactions' | 'reactionEmojis'>): void { note.value.reactions = data.reactions; note.value.reactionCount = Object.values(data.reactions).reduce((a, b) => a + b, 0); note.value.reactionEmojis = data.reactionEmojis; } if (capturedNoteIdMapForPolling.has(note.value.id)) { capturedNoteIdMapForPolling.set(note.value.id, capturedNoteIdMapForPolling.get(note.value.id)! + 1); } else { capturedNoteIdMapForPolling.set(note.value.id, 1); } fetchEvent.on(note.value.id, onFetched); onUnmounted(() => { capturedNoteIdMapForPolling.set(note.value.id, capturedNoteIdMapForPolling.get(note.value.id)! - 1); if (capturedNoteIdMapForPolling.get(note.value.id) === 0) { capturedNoteIdMapForPolling.delete(note.value.id); } fetchEvent.off(note.value.id, onFetched); }); } function realtimeSubscribe(props: { note: Ref<Misskey.entities.Note>; isDeletedRef: Ref<boolean>; }): void { const note = props.note; const connection = useStream(); function onStreamNoteUpdated(noteData): void { const { type, id, body } = noteData; if (id !== note.value.id) return; switch (type) { case 'reacted': { noteEvents.emit(`reacted:${id}`, { userId: body.userId, reaction: body.reaction, emoji: body.emoji, }); break; } case 'unreacted': { noteEvents.emit(`unreacted:${id}`, { userId: body.userId, reaction: body.reaction, emoji: body.emoji, }); break; } case 'pollVoted': { noteEvents.emit(`pollVoted:${id}`, { userId: body.userId, choice: body.choice, }); break; } case 'deleted': { noteEvents.emit(`deleted:${id}`); break; } } } function capture(withHandler = false): void { connection.send('sr', { id: note.value.id }); if (withHandler) connection.on('noteUpdated', onStreamNoteUpdated); } function decapture(withHandler = false): void { connection.send('un', { id: note.value.id }); if (withHandler) connection.off('noteUpdated', onStreamNoteUpdated); } function onStreamConnected() { capture(false); } capture(true); connection.on('_connected_', onStreamConnected); onUnmounted(() => { decapture(true); connection.off('_connected_', onStreamConnected); }); } export function useNoteCapture(props: { note: Ref<Misskey.entities.Note>; parentNote: Ref<Misskey.entities.Note> | null; isDeletedRef: Ref<boolean>; }) { const note = props.note; const parentNote = props.parentNote; 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); }); // 投稿からある程度経過している(=タイムラインを遡って表示した)ノートは、イベントが発生する可能性が低いためそもそも購読しない // ただし「リノートされたばかりの過去のノート」(= parentNoteが存在し、かつparentNoteの投稿日時が最近)はイベント発生が考えられるため購読する // TODO: デバイスとサーバーの時計がズレていると不具合の元になるため、ズレを検知して警告を表示するなどのケアが必要かもしれない if (parentNote == null) { if ((Date.now() - new Date(note.value.createdAt).getTime()) > 1000 * 60 * 5) { // 5min // リノートで表示されているノートでもないし、投稿からある程度経過しているので購読しない return; } } else { if ((Date.now() - new Date(parentNote.value.createdAt).getTime()) > 1000 * 60 * 5) { // 5min // リノートで表示されているノートだが、リノートされてからある程度経過しているので購読しない return; } } if ($i && store.s.realtimeMode) { realtimeSubscribe(props); } else { pollingSubscribe(props); } }