misskey/packages/frontend/src/use/use-note-capture.ts

252 lines
8.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* 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);
}
}