This commit is contained in:
tamaina 2023-07-28 07:37:32 +00:00
parent 2d5c1fca68
commit 8ad49637c5
3 changed files with 327 additions and 78 deletions

View File

@ -4,8 +4,17 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div v-if="muted && appearNote" :class="$style.muted" @click="muted = false">
<I18n :src="i18n.ts.userSaysSomething" tag="small">
<template #name>
<MkA v-user-preview="appearNote.userId" :to="userPage(appearNote.user)">
<MkUserName :user="appearNote.user"/>
</MkA>
</template>
</I18n>
</div>
<div
v-if="!muted"
v-else-if="note && appearNote"
v-show="!isDeleted"
ref="el"
v-hotkey="keymap"
@ -126,19 +135,10 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</article>
</div>
<div v-else :class="$style.muted" @click="muted = false">
<I18n :src="i18n.ts.userSaysSomething" tag="small">
<template #name>
<MkA v-user-preview="appearNote.userId" :to="userPage(appearNote.user)">
<MkUserName :user="appearNote.user"/>
</MkA>
</template>
</I18n>
</div>
</template>
<script lang="ts" setup>
import { computed, inject, onMounted, ref, shallowRef, Ref, defineAsyncComponent } from 'vue';
import { computed, inject, onUnmounted, ref, shallowRef, Ref, defineAsyncComponent, watch, onActivated, onDeactivated } from 'vue';
import * as mfm from 'mfm-js';
import * as misskey from 'misskey-js';
import MkNoteSub from '@/components/MkNoteSub.vue';
@ -162,7 +162,7 @@ import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm';
import { $i } from '@/account';
import { i18n } from '@/i18n';
import { getNoteClipMenu, getNoteMenu } from '@/scripts/get-note-menu';
import { useNoteCapture } from '@/scripts/use-note-capture';
import { noteManager } from '@/scripts/entity-manager';
import { deepClone } from '@/scripts/clone';
import { useTooltip } from '@/scripts/use-tooltip';
import { claimAchievement } from '@/scripts/achievements';
@ -173,32 +173,51 @@ import { showMovedDialog } from '@/scripts/show-moved-dialog';
import { shouldCollapsed } from '@/scripts/collapsed';
const props = defineProps<{
note: misskey.entities.Note;
note: { id: string };
pinned?: boolean;
}>();
const inChannel = inject('inChannel', null);
const currentClip = inject<Ref<misskey.entities.Clip> | null>('currentClip', null);
let note = $ref(deepClone(props.note));
const cachedNote = noteManager.get(props.note.id);
const overridingNote = shallowRef<Partial<misskey.entities.Note>>({});
// plugin
if (noteViewInterruptors.length > 0) {
onMounted(async () => {
let result = deepClone(note);
for (const interruptor of noteViewInterruptors) {
result = await interruptor.handler(result);
}
note = result;
});
}
watch(cachedNote, async () => {
if (cachedNote.value == null) {
isDeleted.value = true;
overridingNote.value = {};
return;
}
if (noteViewInterruptors.length > 0) {
overridingNote.value = {};
return;
}
const isRenote = (
let result = deepClone(cachedNote.value);
for (const interruptor of noteViewInterruptors) {
result = await interruptor.handler(result) as misskey.entities.Note;
}
overridingNote.value = result;
});
const note = $computed<misskey.entities.Note | null>(() => {
if (cachedNote.value == null) {
return null;
}
return {
...cachedNote.value,
...overridingNote.value
};
});
const isRenote = computed(() => (
note != null &&
note.renote != null &&
note.text == null &&
note.fileIds.length === 0 &&
note.fileIds?.length === 0 &&
note.poll == null
);
));
const el = shallowRef<HTMLElement>();
const menuButton = shallowRef<HTMLElement>();
@ -206,19 +225,19 @@ const renoteButton = shallowRef<HTMLElement>();
const renoteTime = shallowRef<HTMLElement>();
const reactButton = shallowRef<HTMLElement>();
const clipButton = shallowRef<HTMLElement>();
let appearNote = $computed(() => isRenote ? note.renote as misskey.entities.Note : note);
const isMyRenote = $i && ($i.id === note.userId);
let appearNote = $computed(() => isRenote.value ? note?.renote as misskey.entities.Note : note);
const isMyRenote = $i && ($i.id === note?.userId);
const showContent = ref(false);
const urls = appearNote.text ? extractUrlFromMfm(mfm.parse(appearNote.text)) : null;
const isLong = shouldCollapsed(appearNote);
const collapsed = ref(appearNote.cw == null && isLong);
const isDeleted = ref(false);
const muted = ref(checkWordMute(appearNote, $i, defaultStore.state.mutedWords));
const urls = appearNote?.text ? extractUrlFromMfm(mfm.parse(appearNote.text)) : null;
const isLong = appearNote ? shouldCollapsed(appearNote) : false;
const collapsed = ref(appearNote?.cw == null && isLong);
const isDeleted = ref(note === null);
const muted = ref(appearNote ? checkWordMute(appearNote, $i, defaultStore.state.mutedWords) : false);
const translation = ref<any>(null);
const translating = ref(false);
const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.user.instance);
const canRenote = computed(() => ['public', 'home'].includes(appearNote.visibility) || appearNote.userId === $i.id);
let renoteCollapsed = $ref(defaultStore.state.collapseRenotes && isRenote && (($i && ($i.id === note.userId || $i.id === appearNote.userId)) || (appearNote.myReaction != null)));
const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && !!appearNote?.user.instance);
const canRenote = computed(() => (!!appearNote && !!$i) && (['public', 'home'].includes(appearNote.visibility) || appearNote.userId === $i.id));
let renoteCollapsed = $ref(note && appearNote && defaultStore.state.collapseRenotes && isRenote && (($i && ($i.id === note.userId || $i.id === appearNote.userId)) || (appearNote.myReaction != null)));
const keymap = {
'r': () => reply(true),
@ -231,13 +250,9 @@ const keymap = {
's': () => showContent.value !== showContent.value,
};
useNoteCapture({
rootEl: el,
note: $$(appearNote),
isDeletedRef: isDeleted,
});
useTooltip(renoteButton, async (showing) => {
if (!appearNote) return;
const renotes = await os.api('notes/renotes', {
noteId: appearNote.id,
limit: 11,
@ -267,6 +282,8 @@ function smallerVisibility(a: Visibility | string, b: Visibility | string): Visi
}
function renote(viaKeyboard = false) {
if (!appearNote || !canRenote.value) return;
pleaseLogin();
showMovedDialog();
@ -277,6 +294,8 @@ function renote(viaKeyboard = false) {
text: i18n.ts.inChannelRenote,
icon: 'ti ti-repeat',
action: () => {
if (!appearNote) return;
const el = renoteButton.value as HTMLElement | null | undefined;
if (el) {
const rect = el.getBoundingClientRect();
@ -296,6 +315,7 @@ function renote(viaKeyboard = false) {
text: i18n.ts.inChannelQuote,
icon: 'ti ti-quote',
action: () => {
if (!appearNote) return;
os.post({
renote: appearNote,
channel: appearNote.channel,
@ -308,6 +328,8 @@ function renote(viaKeyboard = false) {
text: i18n.ts.renote,
icon: 'ti ti-repeat',
action: () => {
if (!appearNote) return;
const el = renoteButton.value as HTMLElement | null | undefined;
if (el) {
const rect = el.getBoundingClientRect();
@ -331,6 +353,7 @@ function renote(viaKeyboard = false) {
text: i18n.ts.quote,
icon: 'ti ti-quote',
action: () => {
if (!appearNote) return;
os.post({
renote: appearNote,
});
@ -342,18 +365,19 @@ function renote(viaKeyboard = false) {
});
}
function reply(viaKeyboard = false): void {
async function reply(viaKeyboard = false): void {
if (!appearNote) return;
pleaseLogin();
os.post({
await os.post({
reply: appearNote,
channel: appearNote.channel,
animation: !viaKeyboard,
}, () => {
focus();
});
focus();
}
function react(viaKeyboard = false): void {
if (!appearNote) return;
pleaseLogin();
showMovedDialog();
if (appearNote.reactionAcceptance === 'likeOnly') {
@ -369,8 +393,10 @@ function react(viaKeyboard = false): void {
os.popup(MkRippleEffect, { x, y }, {}, 'end');
}
} else {
if (!reactButton.value) return;
blur();
reactionPicker.show(reactButton.value, reaction => {
if (!appearNote) return;
os.api('notes/reactions/create', {
noteId: appearNote.id,
reaction: reaction,
@ -408,17 +434,20 @@ function onContextmenu(ev: MouseEvent): void {
ev.preventDefault();
react();
} else {
os.contextMenu(getNoteMenu({ note: note, translating, translation, menuButton, isDeleted, currentClip: currentClip?.value }), ev).then(focus);
if (!note) return;
os.contextMenu(getNoteMenu({ note: note, translating, translation, isDeleted, currentClip: currentClip?.value }), ev).then(focus);
}
}
function menu(viaKeyboard = false): void {
os.popupMenu(getNoteMenu({ note: note, translating, translation, menuButton, isDeleted, currentClip: currentClip?.value }), menuButton.value, {
if (!note) return;
os.popupMenu(getNoteMenu({ note: note, translating, translation, isDeleted, currentClip: currentClip?.value }), menuButton.value, {
viaKeyboard,
}).then(focus);
}
async function clip() {
if (!note) return;
os.popupMenu(await getNoteClipMenu({ note: note, isDeleted, currentClip: currentClip?.value }), clipButton.value).then(focus);
}
@ -441,22 +470,23 @@ function showRenoteMenu(viaKeyboard = false): void {
}
function focus() {
el.value.focus();
el.value?.focus();
}
function blur() {
el.value.blur();
el.value?.blur();
}
function focusBefore() {
focusPrev(el.value);
focusPrev(el.value!);
}
function focusAfter() {
focusNext(el.value);
focusNext(el.value!);
}
function readPromo() {
if (!appearNote) return;
os.api('promo/read', {
noteId: appearNote.id,
});
@ -464,10 +494,35 @@ function readPromo() {
}
function showReactions(): void {
if (!appearNote) return;
os.popup(defineAsyncComponent(() => import('@/components/MkReactedUsersDialog.vue')), {
noteId: appearNote.id,
}, {}, 'closed');
}
const unuse = ref<() => void>();
unuse.value = noteManager.useNote(props.note.id, true).unuse;
onUnmounted(() => {
if (unuse.value) {
unuse.value();
unuse.value = undefined;
}
});
onActivated(() => {
if (!unuse.value) {
unuse.value = noteManager.useNote(props.note.id, true).unuse;
}
});
onDeactivated(() => {
if (unuse.value) {
unuse.value();
unuse.value = undefined;
}
});
</script>
<style lang="scss" module>

View File

@ -1,6 +1,14 @@
/*
* SPDX-FileCopyrightText: syuilo, tamaina and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Note, UserLite } from "misskey-js/built/entities";
import { Ref, ref, ComputedRef, computed } from "vue";
import { api } from "./api";
import { useStream } from '@/stream';
import { Stream } from "misskey-js";
import { $i } from "@/account";
export class EntitiyManager<T extends { id: string }> {
private entities: Map<T['id'], Ref<T>>;
@ -27,20 +35,53 @@ export class EntitiyManager<T extends { id: string }> {
export const userLiteManager = new EntitiyManager<UserLite>();
type OmittedNote = Omit<Note, 'user' | 'renote' | 'reply'>;
type InternalCachedNote = Ref<OmittedNote>;
type CachedNoteSource = Ref<OmittedNote | null>;
type CachedNote = ComputedRef<Note | null>;
/**
*
* 使:
* 1. setでートのデータをセットする
* 2. useNoteでデータを取得+
*/
export class NoteManager {
private notes: Map<Note['id'], InternalCachedNote>;
/**
* Ref
* user, renote, replyを持たない
* nullは削除済みであることを表す
*/
private notesSource: Map<Note['id'], CachedNoteSource>;
/**
* user, renote, replyを取得したComputedRef
* nullは削除済みであることを表す
*/
private notesComputed: Map<Note['id'], CachedNote>;
private updatedAt: Map<Note['id'], number>;
private captureing: Map<Note['id'], number>;
private connection: Stream | null;
constructor() {
this.notes = new Map();
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', this.onStreamNoteUpdated);
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): ComputedRef<Note> {
public set(_note: Note): CachedNote {
const note: Note = { ..._note };
userLiteManager.set(note.user);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
@ -50,45 +91,199 @@ export class NoteManager {
delete note.renote;
if (note.reply) this.set(note.reply);
delete note.reply;
const cached = this.notes.get(note.id);
const cached = this.notesSource.get(note.id);
if (cached) {
cached.value = note;
} else {
this.notes.set(note.id, ref(note));
this.notesSource.set(note.id, ref(note));
}
this.updatedAt.set(note.id, Date.now());
return this.get(note.id)!;
}
public get(id: string): ComputedRef<Note> | undefined {
const note: InternalCachedNote | undefined = this.notes.get(id);
if (!note) return undefined;
public get(id: string): CachedNote {
if (!this.notesComputed.has(id)) {
const note = this.notesSource.get(id);
return computed<Note>(() => {
const user = userLiteManager.get(note.value.userId)!;
const renote = note.value.renoteId ? this.get(note.value.renoteId) : undefined;
const reply = note.value.replyId ? this.get(note.value.replyId) : undefined;
this.notesComputed.set(id, computed<Note | null>(() => {
if (!note || !note.value) return null;
return {
...note.value,
user: user.value,
renote: renote?.value,
reply: reply?.value,
};
});
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;
return {
...note.value,
user: user.value,
renote: renote?.value ?? undefined,
reply: reply?.value ?? undefined,
};
}));
}
return this.notesComputed.get(id)!;
}
public async fetch(id: string, force = false): Promise<ComputedRef<Note>> {
public async fetch(id: string, force = false): Promise<CachedNote> {
if (!force) {
const updatedAt = this.updatedAt.get(id);
if (updatedAt && Date.now() - updatedAt < 1000 * 30) {
const cachedNote = this.get(id);
if (cachedNote) {
return cachedNote as ComputedRef<Note>;
return cachedNote;
}
}
}
const fetchedNote = await api('notes/show', { noteId: id });
return this.set(fetchedNote);
return api('notes/show', { noteId: id })
.then(fetchedNote => this.set(fetchedNote))
.catch(() => {
// エラーが発生した場合はとりあえず削除されたものとして扱う
const cached = this.notesSource.get(id);
if (cached) {
cached.value = null;
} else {
this.notesSource.set(id, ref(null));
}
// updateAtはしない
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);
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;
break;
}
}
}
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): 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);
}
/**
*
* 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)
.then(() => {
this.capture(id);
using = true;
})
.catch(err => console.error(err));
const unuse = () => {
CapturePromise.then(() => {
if (!using) return;
this.decapture(id);
using = false;
});
};
return {
note,
unuse,
};
}
}
export const noteManager = new NoteManager();

View File

@ -93,7 +93,6 @@ export async function getNoteClipMenu(props: {
export function getNoteMenu(props: {
note: misskey.entities.Note;
menuButton: Ref<HTMLElement>;
translation: Ref<any>;
translating: Ref<boolean>;
isDeleted: Ref<boolean>;