summaryrefslogtreecommitdiff
path: root/packages/frontend/src/composables/use-note-capture.ts
diff options
context:
space:
mode:
authorかっこかり <67428053+kakkokari-gtyih@users.noreply.github.com>2025-05-10 07:58:26 +0900
committerGitHub <noreply@github.com>2025-05-10 07:58:26 +0900
commite1cd7c94fb13f8e49667b17554d22ce8de627a2a (patch)
treea064c4b0937160cf1e26697dcfe18de8f2eb0144 /packages/frontend/src/composables/use-note-capture.ts
parentBump version to 2025.5.1-alpha.0 (diff)
downloadmisskey-e1cd7c94fb13f8e49667b17554d22ce8de627a2a.tar.gz
misskey-e1cd7c94fb13f8e49667b17554d22ce8de627a2a.tar.bz2
misskey-e1cd7c94fb13f8e49667b17554d22ce8de627a2a.zip
refactor(frontend): use* 関数の格納場所のフォルダ名を composables に変更 (#16004)
* refactor(frontend): use* 関数の格納場所を正式名称(composables)に変更 * migrate * move useLoading
Diffstat (limited to 'packages/frontend/src/composables/use-note-capture.ts')
-rw-r--r--packages/frontend/src/composables/use-note-capture.ts283
1 files changed, 283 insertions, 0 deletions
diff --git a/packages/frontend/src/composables/use-note-capture.ts b/packages/frontend/src/composables/use-note-capture.ts
new file mode 100644
index 0000000000..2f33c25a0a
--- /dev/null
+++ b/packages/frontend/src/composables/use-note-capture.ts
@@ -0,0 +1,283 @@
+/*
+ * 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 { Reactive, Ref } from 'vue';
+import { useStream } from '@/stream.js';
+import { $i } from '@/i.js';
+import { store } from '@/store.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
+import { prefer } from '@/preferences.js';
+import { globalEvents } from '@/events.js';
+
+export const noteEvents = new EventEmitter<{
+ [ev: `reacted:${string}`]: (ctx: { userId: Misskey.entities.User['id']; reaction: string; emoji?: { name: string; url: string; }; }) => void;
+ [ev: `unreacted:${string}`]: (ctx: { userId: Misskey.entities.User['id']; reaction: string; emoji?: { name: string; url: string; }; }) => void;
+ [ev: `pollVoted:${string}`]: (ctx: { userId: Misskey.entities.User['id']; choice: string; }) => void;
+}>();
+
+const fetchEvent = new EventEmitter<{
+ [id: string]: Pick<Misskey.entities.Note, 'reactions' | 'reactionEmojis'>;
+}>();
+
+const pollingQueue = new Map<string, {
+ referenceCount: number;
+ lastAddedAt: number;
+}>();
+
+function pollingEnqueue(note: Pick<Misskey.entities.Note, 'id' | 'createdAt'>) {
+ if (pollingQueue.has(note.id)) {
+ const data = pollingQueue.get(note.id)!;
+ pollingQueue.set(note.id, {
+ ...data,
+ referenceCount: data.referenceCount + 1,
+ lastAddedAt: Date.now(),
+ });
+ } else {
+ pollingQueue.set(note.id, {
+ referenceCount: 1,
+ lastAddedAt: Date.now(),
+ });
+ }
+}
+
+function pollingDequeue(note: Pick<Misskey.entities.Note, 'id' | 'createdAt'>) {
+ const data = pollingQueue.get(note.id);
+ if (data == null) return;
+
+ if (data.referenceCount === 1) {
+ pollingQueue.delete(note.id);
+ } else {
+ pollingQueue.set(note.id, {
+ ...data,
+ referenceCount: data.referenceCount - 1,
+ });
+ }
+}
+
+const CAPTURE_MAX = 30;
+const MIN_POLLING_INTERVAL = 1000 * 10;
+const POLLING_INTERVAL =
+ prefer.s.pollingInterval === 1 ? MIN_POLLING_INTERVAL * 1.5 * 1.5 :
+ prefer.s.pollingInterval === 2 ? MIN_POLLING_INTERVAL * 1.5 :
+ prefer.s.pollingInterval === 3 ? MIN_POLLING_INTERVAL :
+ MIN_POLLING_INTERVAL;
+
+window.setInterval(() => {
+ const ids = [...pollingQueue.entries()]
+ .filter(([k, v]) => Date.now() - v.lastAddedAt < 1000 * 60 * 5) // 追加されてから一定時間経過したものは省く
+ .map(([k, v]) => k)
+ .sort((a, b) => (a > b ? -1 : 1)) // 新しいものを優先するためにIDで降順ソート
+ .slice(0, CAPTURE_MAX);
+
+ 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: Pick<Misskey.entities.Note, 'id' | 'createdAt'>;
+ $note: ReactiveNoteData;
+}) {
+ const { note, $note } = props;
+
+ function onFetched(data: Pick<Misskey.entities.Note, 'reactions' | 'reactionEmojis'>): void {
+ $note.reactions = data.reactions;
+ $note.reactionCount = Object.values(data.reactions).reduce((a, b) => a + b, 0);
+ $note.reactionEmojis = data.reactionEmojis;
+ }
+
+ pollingEnqueue(note);
+ fetchEvent.on(note.id, onFetched);
+
+ onUnmounted(() => {
+ pollingDequeue(note);
+ fetchEvent.off(note.id, onFetched);
+ });
+}
+
+function realtimeSubscribe(props: {
+ note: Pick<Misskey.entities.Note, 'id' | 'createdAt'>;
+}): void {
+ const note = props.note;
+ const connection = useStream();
+
+ function onStreamNoteUpdated(noteData): void {
+ const { type, id, body } = noteData;
+
+ if (id !== note.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': {
+ globalEvents.emit('noteDeleted', id);
+ break;
+ }
+ }
+ }
+
+ function capture(withHandler = false): void {
+ connection.send('sr', { id: note.id });
+ if (withHandler) connection.on('noteUpdated', onStreamNoteUpdated);
+ }
+
+ function decapture(withHandler = false): void {
+ connection.send('un', { id: note.id });
+ if (withHandler) connection.off('noteUpdated', onStreamNoteUpdated);
+ }
+
+ function onStreamConnected() {
+ capture(false);
+ }
+
+ capture(true);
+ connection.on('_connected_', onStreamConnected);
+
+ onUnmounted(() => {
+ decapture(true);
+ connection.off('_connected_', onStreamConnected);
+ });
+}
+
+type ReactiveNoteData = Reactive<{
+ reactions: Misskey.entities.Note['reactions'];
+ reactionCount: Misskey.entities.Note['reactionCount'];
+ reactionEmojis: Misskey.entities.Note['reactionEmojis'];
+ myReaction: Misskey.entities.Note['myReaction'];
+ pollChoices: NonNullable<Misskey.entities.Note['poll']>['choices'];
+}>;
+
+export function useNoteCapture(props: {
+ note: Pick<Misskey.entities.Note, 'id' | 'createdAt'>;
+ parentNote: Misskey.entities.Note | null;
+ $note: ReactiveNoteData;
+}) {
+ const { note, parentNote, $note } = props;
+
+ noteEvents.on(`reacted:${note.id}`, onReacted);
+ noteEvents.on(`unreacted:${note.id}`, onUnreacted);
+ noteEvents.on(`pollVoted:${note.id}`, onPollVoted);
+
+ 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 {
+ const newReactedKey = `${ctx.userId}:${ctx.reaction}`;
+ if (newReactedKey === latestReactedKey) return;
+ latestReactedKey = newReactedKey;
+
+ if (ctx.emoji && !(ctx.emoji.name in $note.reactionEmojis)) {
+ $note.reactionEmojis[ctx.emoji.name] = ctx.emoji.url;
+ }
+
+ const currentCount = $note.reactions[ctx.reaction] || 0;
+
+ $note.reactions[ctx.reaction] = currentCount + 1;
+ $note.reactionCount += 1;
+
+ if ($i && (ctx.userId === $i.id)) {
+ $note.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.reactions[ctx.reaction] || 0;
+
+ $note.reactions[ctx.reaction] = Math.max(0, currentCount - 1);
+ $note.reactionCount = Math.max(0, $note.reactionCount - 1);
+ if ($note.reactions[ctx.reaction] === 0) delete $note.reactions[ctx.reaction];
+
+ if ($i && (ctx.userId === $i.id)) {
+ $note.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.pollChoices];
+ choices[ctx.choice] = {
+ ...choices[ctx.choice],
+ votes: choices[ctx.choice].votes + 1,
+ ...($i && (ctx.userId === $i.id) ? {
+ isVoted: true,
+ } : {}),
+ };
+
+ $note.pollChoices = choices;
+ }
+
+ onUnmounted(() => {
+ noteEvents.off(`reacted:${note.id}`, onReacted);
+ noteEvents.off(`unreacted:${note.id}`, onUnreacted);
+ noteEvents.off(`pollVoted:${note.id}`, onPollVoted);
+ });
+
+ // 投稿からある程度経過している(=タイムラインを遡って表示した)ノートは、イベントが発生する可能性が低いためそもそも購読しない
+ // ただし「リノートされたばかりの過去のノート」(= parentNoteが存在し、かつparentNoteの投稿日時が最近)はイベント発生が考えられるため購読する
+ // TODO: デバイスとサーバーの時計がズレていると不具合の元になるため、ズレを検知して警告を表示するなどのケアが必要かもしれない
+ if (parentNote == null) {
+ if ((Date.now() - new Date(note.createdAt).getTime()) > 1000 * 60 * 5) { // 5min
+ // リノートで表示されているノートでもないし、投稿からある程度経過しているので購読しない
+ return;
+ }
+ } else {
+ if ((Date.now() - new Date(parentNote.createdAt).getTime()) > 1000 * 60 * 5) { // 5min
+ // リノートで表示されているノートだが、リノートされてからある程度経過しているので購読しない
+ return;
+ }
+ }
+
+ if ($i && store.s.realtimeMode) {
+ realtimeSubscribe(props);
+ } else {
+ pollingSubscribe(props);
+ }
+}