misskey/packages/frontend/src/scripts/entity-manager.ts

408 lines
13 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, tamaina and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Note, UserLite, DriveFile } from "misskey-js/built/entities";
import { Ref, ref, ComputedRef, computed, watch, unref } from "vue";
import { api } from "./api";
import { useStream } from '@/stream';
import { Stream } from "misskey-js";
import { $i } from "@/account";
import { defaultStore, noteViewInterruptors } from '@/store';
import { deepClone } from "./clone";
import { shouldCollapsed } from "./collapsed";
import { extractUrlFromMfm } from "./extract-url-from-mfm";
import * as mfm from 'mfm-js';
export class EntitiyManager<T extends { id: string }> {
private entities: Map<T['id'], Ref<T>>;
constructor(
public key: string,
) {
this.entities = new Map();
}
public set(item: T): Ref<T> {
const cached = this.entities.get(item.id);
if (cached) {
cached.value = item;
} else {
this.entities.set(item.id, ref(item) as Ref<T>);
}
return this.get(item.id)!;
}
public get(id: string): Ref<T> | undefined {
return this.entities.get(id);
}
}
export const userLiteManager = new EntitiyManager<UserLite>('userLite');
export const driveFileManager = new EntitiyManager<DriveFile>('driveFile');
type OmittedNote = Omit<Note, 'user' | 'renote' | 'reply'>;
type CachedNoteSource = Ref<OmittedNote | null>;
type CachedNote = ComputedRef<Note | null>;
type InterruptedCachedNote = Ref<Note | null>;
export function isRenote(note: Note | OmittedNote | null): boolean {
return note != null &&
note.renoteId != null &&
note.text == null &&
note.fileIds?.length === 0 &&
note.poll == null;
}
/**
* ノートのキャッシュを管理する
* 基本的な使い方:
* 1. setでートのデータをセットする
* 2. useNoteでデータを取得+監視
*/
export class NoteManager {
/**
* ートのソースとなるRef
* user, renote, replyを持たない
* nullは削除済みであることを表す
*
* 削除する機構はないので溜まる一方だが、メモリ使用量はそこまで気にしなくて良さそう
*/
private notesSource: Map<Note['id'], CachedNoteSource>;
/**
* ソースからuser, renote, replyを取得したComputedRefのキャッシュを保持しておく
* nullは削除済みであることを表す
* キャプチャが0になったら削除される
*/
private notesComputed: Map<Note['id'], CachedNote>;
private updatedAt: Map<Note['id'], number>;
private captureing: Map<Note['id'], number>;
private connection: Stream | null;
constructor() {
this.notesSource = new Map();
this.notesComputed = new Map();
this.updatedAt = new Map();
this.captureing = new Map();
this.connection = $i ? useStream() : null;
this.connection?.on('noteUpdated', noteData => this.onStreamNoteUpdated(noteData));
this.connection?.on('_connected_', () => {
// 再接続時に再キャプチャ
for (const [id, captureingNumber] of Array.from(this.captureing)) {
if (captureingNumber === 0) {
this.captureing.delete(id);
continue;
}
this.connection?.send('s', { id });
}
});
}
public set(_note: Note): void {
const note: Note = { ..._note };
userLiteManager.set(note.user);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
delete note.user;
if (note.fileIds.length > 0) {
for (const file of note.files) {
driveFileManager.set(file);
}
}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
delete note.files;
if (note.renote) this.set(note.renote);
delete note.renote;
if (note.reply) this.set(note.reply);
delete note.reply;
const cached = this.notesSource.get(note.id);
if (cached) {
cached.value = note;
} else {
this.notesSource.set(note.id, ref(note));
}
this.updatedAt.set(note.id, Date.now());
}
public get(id: string): CachedNote {
if (!this.notesComputed.has(id)) {
const note = this.notesSource.get(id) ?? this.notesSource.set(id, ref(null)).get(id)!;
this.notesComputed.set(id, computed<Note | null>(() => {
if (!note.value) return null;
const user = userLiteManager.get(note.value.userId)!;
const renote = note.value.renoteId ? this.get(note.value.renoteId) : undefined;
// renoteが削除されている場合はCASCADE削除されるためnullを返す
if (renote && !renote.value) return null;
const reply = note.value.replyId ? this.get(note.value.replyId) : undefined;
if (reply && !reply.value) return null;
const files = note.value.fileIds.map(id => driveFileManager.get(id)?.value);
return {
...note.value,
user: user.value,
renote: renote?.value ?? undefined,
reply: reply?.value ?? undefined,
files: files.filter(file => file) as DriveFile[],
};
}));
}
return this.notesComputed.get(id)!;
}
/**
* Interruptorを適用する
* 管理が面倒なのでキャッシュはしない
*/
public getInterrupted(id: string): {
interruptedNote: InterruptedCachedNote,
interruptorUnwatch: () => void,
executeInterruptor: () => Promise<void>,
} {
const note = this.get(id);
const interruptedNote = ref<Note | null>(note.value);
async function executeInterruptor() {
if (note.value == null) {
interruptedNote.value = null;
return;
}
if (noteViewInterruptors.length === 0) {
interruptedNote.value = note.value;
return;
}
let result = deepClone(note.value);
for (const interruptor of noteViewInterruptors) {
result = await interruptor.handler(result) as Note;
}
interruptedNote.value = result;
}
const interruptorUnwatch = watch(note, executeInterruptor);
return {
interruptedNote,
interruptorUnwatch,
executeInterruptor,
};
}
/**
* ノートの表示に必要なデータをお膳立てする
*/
public getNoteViewBase(id: string) {
const { interruptedNote: note, interruptorUnwatch, executeInterruptor } = this.getInterrupted(id);
const noteIsRenote = computed(() => isRenote(note.value));
const isMyRenote = computed(() => noteIsRenote.value && $i && ($i.id === note.value?.userId));
const appearNote = computed(() => (noteIsRenote.value ? note.value?.renote : note.value) ?? null);
return {
note, interruptorUnwatch, executeInterruptor,
isRenote: noteIsRenote, isMyRenote, appearNote,
urls: computed(() => appearNote.value?.text ? extractUrlFromMfm(mfm.parse(appearNote.value.text)) : null),
isLong: computed(() => appearNote.value ? shouldCollapsed(appearNote.value) : false),
canRenote: computed(() => (!!appearNote.value && !!$i) && (['public', 'home'].includes(appearNote.value.visibility) || appearNote.value.userId === $i.id)),
showTicker: computed(() => !!appearNote.value && ((defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.value.user.instance))),
};
}
public async fetch(id: string, force = false): Promise<CachedNote> {
if (!force) {
const cachedNote = this.get(id);
if (cachedNote.value === null) {
// 削除されている場合はnullを返す
return cachedNote;
}
// Renoteの場合はRenote元の更新日時も考慮する
const updatedAt = isRenote(cachedNote.value) ?
this.updatedAt.get(id) :
Math.max(this.updatedAt.get(id) ?? 0, this.updatedAt.get(cachedNote.value!.renoteId!) ?? 0);
// 2分以上経過していない場合はキャッシュを返す
if (updatedAt && Date.now() - updatedAt < 1000 * 120) {
if (cachedNote) {
return cachedNote;
}
}
}
return api('notes/show', { noteId: id })
.then(fetchedNote => {
this.set(fetchedNote);
return this.get(id)!;
})
.catch(() => {
// エラーが発生した場合は何もしない
return this.get(id)!;
});
}
private onStreamNoteUpdated(noteData: any): void {
const { type, id, body } = noteData;
const note = this.notesSource.get(id);
if (!note || !note.value) {
this.connection?.send('un', { id });
this.captureing.delete(id);
this.notesComputed.delete(id);
this.updatedAt.delete(id);
return;
}
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;
if ($i && (body.userId === $i.id)) {
note.value.myReaction = reaction;
}
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);
if (note.value.reactions[reaction] === 0) delete note.value.reactions[reaction];
if ($i && (body.userId === $i.id)) {
note.value.myReaction = undefined;
}
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;
break;
}
case 'deleted': {
note.value = null;
this.connection?.send('un', { id });
this.captureing.delete(id);
this.notesComputed.delete(id);
this.updatedAt.delete(id);
break;
}
}
this.updatedAt.set(id, Date.now());
}
private capture(id: string, markRead = true): void {
if (!this.notesSource.has(id)) return;
const captureingNumber = this.captureing.get(id);
if (typeof captureingNumber === 'number' && captureingNumber > 0) {
this.captureing.set(id, captureingNumber + 1);
return;
}
if (this.connection) {
// TODO: このノートがストリーミング経由で流れてきた場合のみ sr する
this.connection.send(markRead ? 'sr' : 's', { id });
}
this.captureing.set(id, 1);
}
private decapture(id: string, noDeletion = false): void {
if (!this.notesSource.has(id)) return;
const captureingNumber = this.captureing.get(id);
if (typeof captureingNumber === 'number' && captureingNumber > 1) {
this.captureing.set(id, captureingNumber - 1);
return;
}
if (this.connection) {
this.connection.send('un', { id });
}
this.captureing.delete(id);
// キャプチャが終わったらcomputedキャッシュも消してしまう
if (!noDeletion) this.notesComputed.delete(id);
}
/**
* ノートを取得・監視
* キャプチャが要らなくなったら必ずunuseすること
* @param id note id
* @returns { note, unuse } note: CachedNote | Promise<CachedNote>, unuse: () => void
*/
public useNote(id: string, shoudFetch: true): { note: Promise<CachedNote>, unuse: () => void };
public useNote(id: string, shoudFetch = false) {
const note = (!this.notesSource.has(id) || shoudFetch) ? this.fetch(id) : this.get(id)!;
let using = false;
const CapturePromise = Promise.resolve(note)
.catch(err => {
console.error(err);
})
.finally(() => {
this.capture(id);
using = true;
});
const unuse = (noDeletion = false) => {
CapturePromise.then(() => {
if (!using) return;
this.decapture(id, noDeletion);
using = false;
});
};
return {
note,
unuse,
};
}
}
export const noteManager = new NoteManager();
if (_DEV_) {
console.log('entity manager initialized', {
noteManager,
userLiteManager,
driveFileManager,
});
}