408 lines
13 KiB
TypeScript
408 lines
13 KiB
TypeScript
/*
|
||
* 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,
|
||
});
|
||
}
|