From e159f1560018361de573c182b085c5aa12dc7840 Mon Sep 17 00:00:00 2001
From: tamaina <tamaina@hotmail.co.jp>
Date: Wed, 29 Dec 2021 22:13:09 +0900
Subject: enhance: pizzaxでstreamingのuser storage updateイベントを監視して更新
 (#8095)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* wip

* wip?

* ?

* streamingのuser storage updateイベントを監視して更新

* 必要な時以外はストレージを更新しない

* fix?

* wip

* fix

* fix
---
 packages/client/src/components/note.vue | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

(limited to 'packages/client/src/components/note.vue')

diff --git a/packages/client/src/components/note.vue b/packages/client/src/components/note.vue
index c4040388a9..3cf924928a 100644
--- a/packages/client/src/components/note.vue
+++ b/packages/client/src/components/note.vue
@@ -122,6 +122,7 @@ import copyToClipboard from '@/scripts/copy-to-clipboard';
 import { checkWordMute } from '@/scripts/check-word-mute';
 import { userPage } from '@/filters/user';
 import * as os from '@/os';
+import { stream } from '@/stream';
 import { noteActions, noteViewInterruptors } from '@/store';
 import { reactionPicker } from '@/scripts/reaction-picker';
 import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm';
@@ -245,7 +246,7 @@ export default defineComponent({
 
 	async created() {
 		if (this.$i) {
-			this.connection = os.stream;
+			this.connection = stream;
 		}
 
 		this.collapsed = this.appearNote.cw == null && this.appearNote.text && (
-- 
cgit v1.2.3-freya


From 28193f12ca0fd20544b0c9807661fb9a7e7c2a0e Mon Sep 17 00:00:00 2001
From: syuilo <Syuilotan@yahoo.co.jp>
Date: Fri, 14 Jan 2022 10:25:51 +0900
Subject: wip: refactor(client): migrate paging components to composition api

---
 packages/client/src/components/MkNoteSub.vue     | 129 ++++
 packages/client/src/components/note-detailed.vue | 876 ++++------------------
 packages/client/src/components/note-header.vue   |  28 +-
 packages/client/src/components/note-simple.vue   |   6 +-
 packages/client/src/components/note.sub.vue      | 156 ----
 packages/client/src/components/note.vue          | 882 ++++-------------------
 packages/client/src/components/notes.vue         |   6 +-
 packages/client/src/components/notifications.vue |   9 +-
 packages/client/src/components/ui/pagination.vue |   5 -
 packages/client/src/pages/favorites.vue          |  11 +-
 packages/client/src/pages/user/reactions.vue     |   2 +-
 packages/client/src/scripts/check-word-mute.ts   |   2 +-
 packages/client/src/scripts/get-note-menu.ts     | 310 ++++++++
 packages/client/src/scripts/use-note-capture.ts  | 123 ++++
 packages/client/src/store.ts                     |   2 +-
 15 files changed, 871 insertions(+), 1676 deletions(-)
 create mode 100644 packages/client/src/components/MkNoteSub.vue
 delete mode 100644 packages/client/src/components/note.sub.vue
 create mode 100644 packages/client/src/scripts/get-note-menu.ts
 create mode 100644 packages/client/src/scripts/use-note-capture.ts

(limited to 'packages/client/src/components/note.vue')

diff --git a/packages/client/src/components/MkNoteSub.vue b/packages/client/src/components/MkNoteSub.vue
new file mode 100644
index 0000000000..30c27e6235
--- /dev/null
+++ b/packages/client/src/components/MkNoteSub.vue
@@ -0,0 +1,129 @@
+<template>
+<div v-size="{ max: [450] }" class="wrpstxzv" :class="{ children: depth > 1 }">
+	<div class="main">
+		<MkAvatar class="avatar" :user="note.user"/>
+		<div class="body">
+			<XNoteHeader class="header" :note="note" :mini="true"/>
+			<div class="body">
+				<p v-if="note.cw != null" class="cw">
+					<Mfm v-if="note.cw != ''" class="text" :text="note.cw" :author="note.user" :i="$i" :custom-emojis="note.emojis" />
+					<XCwButton v-model="showContent" :note="note"/>
+				</p>
+				<div v-show="note.cw == null || showContent" class="content">
+					<MkNoteSubNoteContent class="text" :note="note"/>
+				</div>
+			</div>
+		</div>
+	</div>
+	<template v-if="depth < 5">
+		<MkNoteSub v-for="reply in replies" :key="reply.id" :note="reply" class="reply" :detail="true" :depth="depth + 1"/>
+	</template>
+	<div v-else class="more">
+		<MkA class="text _link" :to="notePage(note)">{{ $ts.continueThread }} <i class="fas fa-angle-double-right"></i></MkA>
+	</div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import * as misskey from 'misskey-js';
+import { notePage } from '@/filters/note';
+import XNoteHeader from './note-header.vue';
+import MkNoteSubNoteContent from './sub-note-content.vue';
+import XCwButton from './cw-button.vue';
+import * as os from '@/os';
+
+const props = withDefaults(defineProps<{
+	note: misskey.entities.Note;
+	detail?: boolean;
+
+	// how many notes are in between this one and the note being viewed in detail
+	depth?: number;
+}>(), {
+	depth: 1,
+});
+
+let showContent = $ref(false);
+let replies: misskey.entities.Note[] = $ref([]);
+
+if (props.detail) {
+	os.api('notes/children', {
+		noteId: props.note.id,
+		limit: 5
+	}).then(res => {
+		replies = res;
+	});
+}
+</script>
+
+<style lang="scss" scoped>
+.wrpstxzv {
+	padding: 16px 32px;
+	font-size: 0.9em;
+
+	&.max-width_450px {
+		padding: 14px 16px;
+	}
+
+	&.children {
+		padding: 10px 0 0 16px;
+		font-size: 1em;
+
+		&.max-width_450px {
+			padding: 10px 0 0 8px;
+		}
+	}
+
+	> .main {
+		display: flex;
+
+		> .avatar {
+			flex-shrink: 0;
+			display: block;
+			margin: 0 8px 0 0;
+			width: 38px;
+			height: 38px;
+			border-radius: 8px;
+		}
+
+		> .body {
+			flex: 1;
+			min-width: 0;
+
+			> .header {
+				margin-bottom: 2px;
+			}
+
+			> .body {
+				> .cw {
+					cursor: default;
+					display: block;
+					margin: 0;
+					padding: 0;
+					overflow-wrap: break-word;
+
+					> .text {
+						margin-right: 8px;
+					}
+				}
+
+				> .content {
+					> .text {
+						margin: 0;
+						padding: 0;
+					}
+				}
+			}
+		}
+	}
+
+	> .reply, > .more {
+		border-left: solid 0.5px var(--divider);
+		margin-top: 10px;
+	}
+
+	> .more {
+		padding: 10px 0 0 16px;
+	}
+}
+</style>
diff --git a/packages/client/src/components/note-detailed.vue b/packages/client/src/components/note-detailed.vue
index a5cb2f0426..07e9920f65 100644
--- a/packages/client/src/components/note-detailed.vue
+++ b/packages/client/src/components/note-detailed.vue
@@ -8,8 +8,8 @@
 	:tabindex="!isDeleted ? '-1' : null"
 	:class="{ renote: isRenote }"
 >
-	<XSub v-for="note in conversation" :key="note.id" class="reply-to-more" :note="note"/>
-	<XSub v-if="appearNote.reply" :note="appearNote.reply" class="reply-to"/>
+	<MkNoteSub v-for="note in conversation" :key="note.id" class="reply-to-more" :note="note"/>
+	<MkNoteSub v-if="appearNote.reply" :note="appearNote.reply" class="reply-to"/>
 	<div v-if="isRenote" class="renote">
 		<MkAvatar class="avatar" :user="note.user"/>
 		<i class="fas fa-retweet"></i>
@@ -107,7 +107,7 @@
 			</footer>
 		</div>
 	</article>
-	<XSub v-for="note in replies" :key="note.id" :note="note" class="reply" :detail="true"/>
+	<MkNoteSub v-for="note in replies" :key="note.id" :note="note" class="reply" :detail="true"/>
 </div>
 <div v-else class="_panel muted" @click="muted = false">
 	<I18n :src="$ts.userSaysSomething" tag="small">
@@ -120,765 +120,171 @@
 </div>
 </template>
 
-<script lang="ts">
-import { defineAsyncComponent, defineComponent, markRaw } from 'vue';
+<script lang="ts" setup>
+import { computed, inject, onMounted, onUnmounted, reactive, ref } from 'vue';
 import * as mfm from 'mfm-js';
-import { sum } from '@/scripts/array';
-import XSub from './note.sub.vue';
-import XNoteHeader from './note-header.vue';
+import * as misskey from 'misskey-js';
+import MkNoteSub from './MkNoteSub.vue';
 import XNoteSimple from './note-simple.vue';
 import XReactionsViewer from './reactions-viewer.vue';
 import XMediaList from './media-list.vue';
 import XCwButton from './cw-button.vue';
 import XPoll from './poll.vue';
 import XRenoteButton from './renote-button.vue';
+import MkUrlPreview from '@/components/url-preview.vue';
+import MkInstanceTicker from '@/components/instance-ticker.vue';
 import { pleaseLogin } from '@/scripts/please-login';
-import { focusPrev, focusNext } from '@/scripts/focus';
-import { url } from '@/config';
-import copyToClipboard from '@/scripts/copy-to-clipboard';
 import { checkWordMute } from '@/scripts/check-word-mute';
 import { userPage } from '@/filters/user';
 import { notePage } from '@/filters/note';
 import * as os from '@/os';
-import { stream } from '@/stream';
-import { noteActions, noteViewInterruptors } from '@/store';
+import { defaultStore, noteViewInterruptors } from '@/store';
 import { reactionPicker } from '@/scripts/reaction-picker';
 import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm';
+import { $i } from '@/account';
+import { i18n } from '@/i18n';
+import { getNoteMenu } from '@/scripts/get-note-menu';
+import { useNoteCapture } from '@/scripts/use-note-capture';
+
+const props = defineProps<{
+	note: misskey.entities.Note;
+	pinned?: boolean;
+}>();
+
+const inChannel = inject('inChannel', null);
+
+const isRenote = (
+	props.note.renote != null &&
+	props.note.text == null &&
+	props.note.fileIds.length === 0 &&
+	props.note.poll == null
+);
+
+const el = ref<HTMLElement>();
+const menuButton = ref<HTMLElement>();
+const renoteButton = ref<InstanceType<typeof XRenoteButton>>();
+const renoteTime = ref<HTMLElement>();
+const reactButton = ref<HTMLElement>();
+let appearNote = $ref(isRenote ? props.note.renote as misskey.entities.Note : props.note);
+const isMyRenote = $i && ($i.id === props.note.userId);
+const showContent = ref(false);
+const isDeleted = ref(false);
+const muted = ref(checkWordMute(appearNote, $i, defaultStore.state.mutedWords));
+const translation = ref(null);
+const translating = ref(false);
+const urls = appearNote.text ? extractUrlFromMfm(mfm.parse(appearNote.text)) : null;
+const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.user.instance);
+const conversation = ref<misskey.entities.Note[]>([]);
+const replies = ref<misskey.entities.Note[]>([]);
+
+const keymap = {
+	'r': () => reply(true),
+	'e|a|plus': () => react(true),
+	'q': () => renoteButton.value.renote(true),
+	'esc': blur,
+	'm|o': () => menu(true),
+	's': () => showContent.value != showContent.value,
+};
+
+useNoteCapture({
+	appearNote: $$(appearNote),
+	rootEl: el,
+});
 
-// TODO: note.vueとほぼ同じなので共通化したい
-export default defineComponent({
-	components: {
-		XSub,
-		XNoteHeader,
-		XNoteSimple,
-		XReactionsViewer,
-		XMediaList,
-		XCwButton,
-		XPoll,
-		XRenoteButton,
-		MkUrlPreview: defineAsyncComponent(() => import('@/components/url-preview.vue')),
-		MkInstanceTicker: defineAsyncComponent(() => import('@/components/instance-ticker.vue')),
-	},
-
-	inject: {
-		inChannel: {
-			default: null
-		},
-	},
-
-	props: {
-		note: {
-			type: Object,
-			required: true
-		},
-	},
-
-	emits: ['update:note'],
-
-	data() {
-		return {
-			connection: null,
-			conversation: [],
-			replies: [],
-			showContent: false,
-			isDeleted: false,
-			muted: false,
-			translation: null,
-			translating: false,
-			notePage,
-		};
-	},
-
-	computed: {
-		rs() {
-			return this.$store.state.reactions;
-		},
-		keymap(): any {
-			return {
-				'r': () => this.reply(true),
-				'e|a|plus': () => this.react(true),
-				'q': () => this.$refs.renoteButton.renote(true),
-				'f|b': this.favorite,
-				'delete|ctrl+d': this.del,
-				'ctrl+q': this.renoteDirectly,
-				'up|k|shift+tab': this.focusBefore,
-				'down|j|tab': this.focusAfter,
-				'esc': this.blur,
-				'm|o': () => this.menu(true),
-				's': this.toggleShowContent,
-				'1': () => this.reactDirectly(this.rs[0]),
-				'2': () => this.reactDirectly(this.rs[1]),
-				'3': () => this.reactDirectly(this.rs[2]),
-				'4': () => this.reactDirectly(this.rs[3]),
-				'5': () => this.reactDirectly(this.rs[4]),
-				'6': () => this.reactDirectly(this.rs[5]),
-				'7': () => this.reactDirectly(this.rs[6]),
-				'8': () => this.reactDirectly(this.rs[7]),
-				'9': () => this.reactDirectly(this.rs[8]),
-				'0': () => this.reactDirectly(this.rs[9]),
-			};
-		},
-
-		isRenote(): boolean {
-			return (this.note.renote &&
-				this.note.text == null &&
-				this.note.fileIds.length == 0 &&
-				this.note.poll == null);
-		},
-
-		appearNote(): any {
-			return this.isRenote ? this.note.renote : this.note;
-		},
-
-		isMyNote(): boolean {
-			return this.$i && (this.$i.id === this.appearNote.userId);
-		},
-
-		isMyRenote(): boolean {
-			return this.$i && (this.$i.id === this.note.userId);
-		},
-
-		reactionsCount(): number {
-			return this.appearNote.reactions
-				? sum(Object.values(this.appearNote.reactions))
-				: 0;
-		},
-
-		urls(): string[] {
-			if (this.appearNote.text) {
-				return extractUrlFromMfm(mfm.parse(this.appearNote.text));
-			} else {
-				return null;
-			}
-		},
-
-		showTicker() {
-			if (this.$store.state.instanceTicker === 'always') return true;
-			if (this.$store.state.instanceTicker === 'remote' && this.appearNote.user.instance) return true;
-			return false;
-		}
-	},
-
-	async created() {
-		if (this.$i) {
-			this.connection = stream;
-		}
-
-		this.muted = await checkWordMute(this.appearNote, this.$i, this.$store.state.mutedWords);
-
-		// plugin
-		if (noteViewInterruptors.length > 0) {
-			let result = this.note;
-			for (const interruptor of noteViewInterruptors) {
-				result = await interruptor.handler(JSON.parse(JSON.stringify(result)));
-			}
-			this.$emit('update:note', Object.freeze(result));
-		}
+function reply(viaKeyboard = false): void {
+	pleaseLogin();
+	os.post({
+		reply: appearNote,
+		animation: !viaKeyboard,
+	}, () => {
+		focus();
+	});
+}
 
-		os.api('notes/children', {
-			noteId: this.appearNote.id,
-			limit: 30
-		}).then(replies => {
-			this.replies = replies;
+function react(viaKeyboard = false): void {
+	pleaseLogin();
+	blur();
+	reactionPicker.show(reactButton.value, reaction => {
+		os.api('notes/reactions/create', {
+			noteId: appearNote.id,
+			reaction: reaction
 		});
+	}, () => {
+		focus();
+	});
+}
 
-		if (this.appearNote.replyId) {
-			os.api('notes/conversation', {
-				noteId: this.appearNote.replyId
-			}).then(conversation => {
-				this.conversation = conversation.reverse();
-			});
-		}
-	},
-
-	mounted() {
-		this.capture(true);
-
-		if (this.$i) {
-			this.connection.on('_connected_', this.onStreamConnected);
-		}
-	},
-
-	beforeUnmount() {
-		this.decapture(true);
+function undoReact(note): void {
+	const oldReaction = note.myReaction;
+	if (!oldReaction) return;
+	os.api('notes/reactions/delete', {
+		noteId: note.id
+	});
+}
 
-		if (this.$i) {
-			this.connection.off('_connected_', this.onStreamConnected);
+function onContextmenu(e): void {
+	const isLink = (el: HTMLElement) => {
+		if (el.tagName === 'A') return true;
+		if (el.parentElement) {
+			return isLink(el.parentElement);
 		}
-	},
-
-	methods: {
-		updateAppearNote(v) {
-			this.$emit('update:note', Object.freeze(this.isRenote ? {
-				...this.note,
-				renote: {
-					...this.note.renote,
-					...v
-				}
-			} : {
-				...this.note,
-				...v
-			}));
-		},
-
-		readPromo() {
-			os.api('promo/read', {
-				noteId: this.appearNote.id
-			});
-			this.isDeleted = true;
-		},
-
-		capture(withHandler = false) {
-			if (this.$i) {
-				// TODO: このノートがストリーミング経由で流れてきた場合のみ sr する
-				this.connection.send(document.body.contains(this.$el) ? 'sr' : 's', { id: this.appearNote.id });
-				if (withHandler) this.connection.on('noteUpdated', this.onStreamNoteUpdated);
-			}
-		},
-
-		decapture(withHandler = false) {
-			if (this.$i) {
-				this.connection.send('un', {
-					id: this.appearNote.id
-				});
-				if (withHandler) this.connection.off('noteUpdated', this.onStreamNoteUpdated);
-			}
-		},
-
-		onStreamConnected() {
-			this.capture();
-		},
-
-		onStreamNoteUpdated(data) {
-			const { type, id, body } = data;
-
-			if (id !== this.appearNote.id) return;
-
-			switch (type) {
-				case 'reacted': {
-					const reaction = body.reaction;
-
-					// DeepではなくShallowコピーであることに注意。n.reactions[reaction] = hogeとかしないように(親からもらったデータをミューテートすることになるので)
-					let n = {
-						...this.appearNote,
-					};
-
-					if (body.emoji) {
-						const emojis = this.appearNote.emojis || [];
-						if (!emojis.includes(body.emoji)) {
-							n.emojis = [...emojis, body.emoji];
-						}
-					}
-
-					// TODO: reactionsプロパティがない場合ってあったっけ？ なければ || {} は消せる
-					const currentCount = (this.appearNote.reactions || {})[reaction] || 0;
-
-					// Increment the count
-					n.reactions = {
-						...this.appearNote.reactions,
-						[reaction]: currentCount + 1
-					};
-
-					if (body.userId === this.$i.id) {
-						n.myReaction = reaction;
-					}
-
-					this.updateAppearNote(n);
-					break;
-				}
-
-				case 'unreacted': {
-					const reaction = body.reaction;
-
-					// DeepではなくShallowコピーであることに注意。n.reactions[reaction] = hogeとかしないように(親からもらったデータをミューテートすることになるので)
-					let n = {
-						...this.appearNote,
-					};
-
-					// TODO: reactionsプロパティがない場合ってあったっけ？ なければ || {} は消せる
-					const currentCount = (this.appearNote.reactions || {})[reaction] || 0;
-
-					// Decrement the count
-					n.reactions = {
-						...this.appearNote.reactions,
-						[reaction]: Math.max(0, currentCount - 1)
-					};
-
-					if (body.userId === this.$i.id) {
-						n.myReaction = null;
-					}
-
-					this.updateAppearNote(n);
-					break;
-				}
-
-				case 'pollVoted': {
-					const choice = body.choice;
-
-					// DeepではなくShallowコピーであることに注意。n.reactions[reaction] = hogeとかしないように(親からもらったデータをミューテートすることになるので)
-					let n = {
-						...this.appearNote,
-					};
-
-					const choices = [...this.appearNote.poll.choices];
-					choices[choice] = {
-						...choices[choice],
-						votes: choices[choice].votes + 1,
-						...(body.userId === this.$i.id ? {
-							isVoted: true
-						} : {})
-					};
-
-					n.poll = {
-						...this.appearNote.poll,
-						choices: choices
-					};
-
-					this.updateAppearNote(n);
-					break;
-				}
-
-				case 'deleted': {
-					this.isDeleted = true;
-					break;
-				}
-			}
-		},
-
-		reply(viaKeyboard = false) {
-			pleaseLogin();
-			os.post({
-				reply: this.appearNote,
-				animation: !viaKeyboard,
-			}, () => {
-				this.focus();
-			});
-		},
-
-		renoteDirectly() {
-			os.apiWithDialog('notes/create', {
-				renoteId: this.appearNote.id
-			}, undefined, (res: any) => {
-				os.alert({
-					type: 'success',
-					text: this.$ts.renoted,
-				});
-			}, (e: Error) => {
-				if (e.id === 'b5c90186-4ab0-49c8-9bba-a1f76c282ba4') {
-					os.alert({
-						type: 'error',
-						text: this.$ts.cantRenote,
-					});
-				} else if (e.id === 'fd4cc33e-2a37-48dd-99cc-9b806eb2031a') {
-					os.alert({
-						type: 'error',
-						text: this.$ts.cantReRenote,
-					});
-				}
-			});
-		},
-
-		react(viaKeyboard = false) {
-			pleaseLogin();
-			this.blur();
-			reactionPicker.show(this.$refs.reactButton, reaction => {
-				os.api('notes/reactions/create', {
-					noteId: this.appearNote.id,
-					reaction: reaction
-				});
-			}, () => {
-				this.focus();
-			});
-		},
-
-		reactDirectly(reaction) {
-			os.api('notes/reactions/create', {
-				noteId: this.appearNote.id,
-				reaction: reaction
-			});
-		},
-
-		undoReact(note) {
-			const oldReaction = note.myReaction;
-			if (!oldReaction) return;
-			os.api('notes/reactions/delete', {
-				noteId: note.id
-			});
-		},
-
-		favorite() {
-			pleaseLogin();
-			os.apiWithDialog('notes/favorites/create', {
-				noteId: this.appearNote.id
-			}, undefined, (res: any) => {
-				os.alert({
-					type: 'success',
-					text: this.$ts.favorited,
-				});
-			}, (e: Error) => {
-				if (e.id === 'a402c12b-34dd-41d2-97d8-4d2ffd96a1a6') {
-					os.alert({
-						type: 'error',
-						text: this.$ts.alreadyFavorited,
-					});
-				} else if (e.id === '6dd26674-e060-4816-909a-45ba3f4da458') {
-					os.alert({
-						type: 'error',
-						text: this.$ts.cantFavorite,
-					});
-				}
-			});
-		},
-
-		del() {
-			os.confirm({
-				type: 'warning',
-				text: this.$ts.noteDeleteConfirm,
-			}).then(({ canceled }) => {
-				if (canceled) return;
-
-				os.api('notes/delete', {
-					noteId: this.appearNote.id
-				});
-			});
-		},
-
-		delEdit() {
-			os.confirm({
-				type: 'warning',
-				text: this.$ts.deleteAndEditConfirm,
-			}).then(({ canceled }) => {
-				if (canceled) return;
-
-				os.api('notes/delete', {
-					noteId: this.appearNote.id
-				});
-
-				os.post({ initialNote: this.appearNote, renote: this.appearNote.renote, reply: this.appearNote.reply, channel: this.appearNote.channel });
-			});
-		},
-
-		toggleFavorite(favorite: boolean) {
-			os.apiWithDialog(favorite ? 'notes/favorites/create' : 'notes/favorites/delete', {
-				noteId: this.appearNote.id
-			});
-		},
-
-		toggleWatch(watch: boolean) {
-			os.apiWithDialog(watch ? 'notes/watching/create' : 'notes/watching/delete', {
-				noteId: this.appearNote.id
-			});
-		},
-
-		toggleThreadMute(mute: boolean) {
-			os.apiWithDialog(mute ? 'notes/thread-muting/create' : 'notes/thread-muting/delete', {
-				noteId: this.appearNote.id
-			});
-		},
-
-		getMenu() {
-			let menu;
-			if (this.$i) {
-				const statePromise = os.api('notes/state', {
-					noteId: this.appearNote.id
-				});
-
-				menu = [{
-					icon: 'fas fa-copy',
-					text: this.$ts.copyContent,
-					action: this.copyContent
-				}, {
-					icon: 'fas fa-link',
-					text: this.$ts.copyLink,
-					action: this.copyLink
-				}, (this.appearNote.url || this.appearNote.uri) ? {
-					icon: 'fas fa-external-link-square-alt',
-					text: this.$ts.showOnRemote,
-					action: () => {
-						window.open(this.appearNote.url || this.appearNote.uri, '_blank');
-					}
-				} : undefined,
-				{
-					icon: 'fas fa-share-alt',
-					text: this.$ts.share,
-					action: this.share
-				},
-				this.$instance.translatorAvailable ? {
-					icon: 'fas fa-language',
-					text: this.$ts.translate,
-					action: this.translate
-				} : undefined,
-				null,
-				statePromise.then(state => state.isFavorited ? {
-					icon: 'fas fa-star',
-					text: this.$ts.unfavorite,
-					action: () => this.toggleFavorite(false)
-				} : {
-					icon: 'fas fa-star',
-					text: this.$ts.favorite,
-					action: () => this.toggleFavorite(true)
-				}),
-				{
-					icon: 'fas fa-paperclip',
-					text: this.$ts.clip,
-					action: () => this.clip()
-				},
-				(this.appearNote.userId != this.$i.id) ? statePromise.then(state => state.isWatching ? {
-					icon: 'fas fa-eye-slash',
-					text: this.$ts.unwatch,
-					action: () => this.toggleWatch(false)
-				} : {
-					icon: 'fas fa-eye',
-					text: this.$ts.watch,
-					action: () => this.toggleWatch(true)
-				}) : undefined,
-				statePromise.then(state => state.isMutedThread ? {
-					icon: 'fas fa-comment-slash',
-					text: this.$ts.unmuteThread,
-					action: () => this.toggleThreadMute(false)
-				} : {
-					icon: 'fas fa-comment-slash',
-					text: this.$ts.muteThread,
-					action: () => this.toggleThreadMute(true)
-				}),
-				this.appearNote.userId == this.$i.id ? (this.$i.pinnedNoteIds || []).includes(this.appearNote.id) ? {
-					icon: 'fas fa-thumbtack',
-					text: this.$ts.unpin,
-					action: () => this.togglePin(false)
-				} : {
-					icon: 'fas fa-thumbtack',
-					text: this.$ts.pin,
-					action: () => this.togglePin(true)
-				} : undefined,
-				/*...(this.$i.isModerator || this.$i.isAdmin ? [
-					null,
-					{
-						icon: 'fas fa-bullhorn',
-						text: this.$ts.promote,
-						action: this.promote
-					}]
-					: []
-				),*/
-				...(this.appearNote.userId != this.$i.id ? [
-					null,
-					{
-						icon: 'fas fa-exclamation-circle',
-						text: this.$ts.reportAbuse,
-						action: () => {
-							const u = `${url}/notes/${this.appearNote.id}`;
-							os.popup(import('@/components/abuse-report-window.vue'), {
-								user: this.appearNote.user,
-								initialComment: `Note: ${u}\n-----\n`
-							}, {}, 'closed');
-						}
-					}]
-					: []
-				),
-				...(this.appearNote.userId == this.$i.id || this.$i.isModerator || this.$i.isAdmin ? [
-					null,
-					this.appearNote.userId == this.$i.id ? {
-						icon: 'fas fa-edit',
-						text: this.$ts.deleteAndEdit,
-						action: this.delEdit
-					} : undefined,
-					{
-						icon: 'fas fa-trash-alt',
-						text: this.$ts.delete,
-						danger: true,
-						action: this.del
-					}]
-					: []
-				)]
-				.filter(x => x !== undefined);
-			} else {
-				menu = [{
-					icon: 'fas fa-copy',
-					text: this.$ts.copyContent,
-					action: this.copyContent
-				}, {
-					icon: 'fas fa-link',
-					text: this.$ts.copyLink,
-					action: this.copyLink
-				}, (this.appearNote.url || this.appearNote.uri) ? {
-					icon: 'fas fa-external-link-square-alt',
-					text: this.$ts.showOnRemote,
-					action: () => {
-						window.open(this.appearNote.url || this.appearNote.uri, '_blank');
-					}
-				} : undefined]
-				.filter(x => x !== undefined);
-			}
-
-			if (noteActions.length > 0) {
-				menu = menu.concat([null, ...noteActions.map(action => ({
-					icon: 'fas fa-plug',
-					text: action.title,
-					action: () => {
-						action.handler(this.appearNote);
-					}
-				}))]);
-			}
-
-			return menu;
-		},
-
-		onContextmenu(e) {
-			const isLink = (el: HTMLElement) => {
-				if (el.tagName === 'A') return true;
-				if (el.parentElement) {
-					return isLink(el.parentElement);
-				}
-			};
-			if (isLink(e.target)) return;
-			if (window.getSelection().toString() !== '') return;
-
-			if (this.$store.state.useReactionPickerForContextMenu) {
-				e.preventDefault();
-				this.react();
-			} else {
-				os.contextMenu(this.getMenu(), e).then(this.focus);
-			}
-		},
-
-		menu(viaKeyboard = false) {
-			os.popupMenu(this.getMenu(), this.$refs.menuButton, {
-				viaKeyboard
-			}).then(this.focus);
-		},
-
-		showRenoteMenu(viaKeyboard = false) {
-			if (!this.isMyRenote) return;
-			os.popupMenu([{
-				text: this.$ts.unrenote,
-				icon: 'fas fa-trash-alt',
-				danger: true,
-				action: () => {
-					os.api('notes/delete', {
-						noteId: this.note.id
-					});
-					this.isDeleted = true;
-				}
-			}], this.$refs.renoteTime, {
-				viaKeyboard: viaKeyboard
-			});
-		},
-
-		toggleShowContent() {
-			this.showContent = !this.showContent;
-		},
-
-		copyContent() {
-			copyToClipboard(this.appearNote.text);
-			os.success();
-		},
-
-		copyLink() {
-			copyToClipboard(`${url}/notes/${this.appearNote.id}`);
-			os.success();
-		},
-
-		togglePin(pin: boolean) {
-			os.apiWithDialog(pin ? 'i/pin' : 'i/unpin', {
-				noteId: this.appearNote.id
-			}, undefined, null, e => {
-				if (e.id === '72dab508-c64d-498f-8740-a8eec1ba385a') {
-					os.alert({
-						type: 'error',
-						text: this.$ts.pinLimitExceeded
-					});
-				}
-			});
-		},
-
-		async clip() {
-			const clips = await os.api('clips/list');
-			os.popupMenu([{
-				icon: 'fas fa-plus',
-				text: this.$ts.createNew,
-				action: async () => {
-					const { canceled, result } = await os.form(this.$ts.createNewClip, {
-						name: {
-							type: 'string',
-							label: this.$ts.name
-						},
-						description: {
-							type: 'string',
-							required: false,
-							multiline: true,
-							label: this.$ts.description
-						},
-						isPublic: {
-							type: 'boolean',
-							label: this.$ts.public,
-							default: false
-						}
-					});
-					if (canceled) return;
-
-					const clip = await os.apiWithDialog('clips/create', result);
-
-					os.apiWithDialog('clips/add-note', { clipId: clip.id, noteId: this.appearNote.id });
-				}
-			}, null, ...clips.map(clip => ({
-				text: clip.name,
-				action: () => {
-					os.apiWithDialog('clips/add-note', { clipId: clip.id, noteId: this.appearNote.id });
-				}
-			}))], this.$refs.menuButton, {
-			}).then(this.focus);
-		},
-
-		async promote() {
-			const { canceled, result: days } = await os.inputNumber({
-				title: this.$ts.numberOfDays,
-			});
-
-			if (canceled) return;
+	};
+	if (isLink(e.target)) return;
+	if (window.getSelection().toString() !== '') return;
+
+	if (defaultStore.state.useReactionPickerForContextMenu) {
+		e.preventDefault();
+		react();
+	} else {
+		os.contextMenu(getNoteMenu({ note: props.note, translating, translation, menuButton }), e).then(focus);
+	}
+}
 
-			os.apiWithDialog('admin/promo/create', {
-				noteId: this.appearNote.id,
-				expiresAt: Date.now() + (86400000 * days)
-			});
-		},
+function menu(viaKeyboard = false): void {
+	os.popupMenu(getNoteMenu({ note: props.note, translating, translation, menuButton }), menuButton.value, {
+		viaKeyboard
+	}).then(focus);
+}
 
-		share() {
-			navigator.share({
-				title: this.$t('noteOf', { user: this.appearNote.user.name }),
-				text: this.appearNote.text,
-				url: `${url}/notes/${this.appearNote.id}`
-			});
-		},
-
-		async translate() {
-			if (this.translation != null) return;
-			this.translating = true;
-			const res = await os.api('notes/translate', {
-				noteId: this.appearNote.id,
-				targetLang: localStorage.getItem('lang') || navigator.language,
+function showRenoteMenu(viaKeyboard = false): void {
+	if (!isMyRenote) return;
+	os.popupMenu([{
+		text: i18n.locale.unrenote,
+		icon: 'fas fa-trash-alt',
+		danger: true,
+		action: () => {
+			os.api('notes/delete', {
+				noteId: props.note.id
 			});
-			this.translating = false;
-			this.translation = res;
-		},
-
-		focus() {
-			this.$el.focus();
-		},
-
-		blur() {
-			this.$el.blur();
-		},
+			isDeleted.value = true;
+		}
+	}], renoteTime.value, {
+		viaKeyboard: viaKeyboard
+	});
+}
 
-		focusBefore() {
-			focusPrev(this.$el);
-		},
+function focus() {
+	el.value.focus();
+}
 
-		focusAfter() {
-			focusNext(this.$el);
-		},
+function blur() {
+	el.value.blur();
+}
 
-		userPage
-	}
+os.api('notes/children', {
+	noteId: appearNote.id,
+	limit: 30
+}).then(res => {
+	replies.value = res;
 });
+
+if (appearNote.replyId) {
+	os.api('notes/conversation', {
+		noteId: appearNote.replyId
+	}).then(res => {
+		conversation.value = res.reverse();
+	});
+}
 </script>
 
 <style lang="scss" scoped>
diff --git a/packages/client/src/components/note-header.vue b/packages/client/src/components/note-header.vue
index 26e725c6b8..56a3a37e75 100644
--- a/packages/client/src/components/note-header.vue
+++ b/packages/client/src/components/note-header.vue
@@ -19,30 +19,16 @@
 </header>
 </template>
 
-<script lang="ts">
-import { defineComponent } from 'vue';
+<script lang="ts" setup>
+import { } from 'vue';
+import * as misskey from 'misskey-js';
 import { notePage } from '@/filters/note';
 import { userPage } from '@/filters/user';
-import * as os from '@/os';
 
-export default defineComponent({
-	props: {
-		note: {
-			type: Object,
-			required: true
-		},
-	},
-
-	data() {
-		return {
-		};
-	},
-
-	methods: {
-		notePage,
-		userPage
-	}
-});
+defineProps<{
+	note: misskey.entities.Note;
+	pinned?: boolean;
+}>();
 </script>
 
 <style lang="scss" scoped>
diff --git a/packages/client/src/components/note-simple.vue b/packages/client/src/components/note-simple.vue
index 135f06602d..d8236a2096 100644
--- a/packages/client/src/components/note-simple.vue
+++ b/packages/client/src/components/note-simple.vue
@@ -9,7 +9,7 @@
 				<XCwButton v-model="showContent" :note="note"/>
 			</p>
 			<div v-show="note.cw == null || showContent" class="content">
-				<XSubNote-content class="text" :note="note"/>
+				<MkNoteSubNoteContent class="text" :note="note"/>
 			</div>
 		</div>
 	</div>
@@ -19,14 +19,14 @@
 <script lang="ts">
 import { defineComponent } from 'vue';
 import XNoteHeader from './note-header.vue';
-import XSubNoteContent from './sub-note-content.vue';
+import MkNoteSubNoteContent from './sub-note-content.vue';
 import XCwButton from './cw-button.vue';
 import * as os from '@/os';
 
 export default defineComponent({
 	components: {
 		XNoteHeader,
-		XSubNoteContent,
+		MkNoteSubNoteContent,
 		XCwButton,
 	},
 
diff --git a/packages/client/src/components/note.sub.vue b/packages/client/src/components/note.sub.vue
deleted file mode 100644
index de4218e535..0000000000
--- a/packages/client/src/components/note.sub.vue
+++ /dev/null
@@ -1,156 +0,0 @@
-<template>
-<div v-size="{ max: [450] }" class="wrpstxzv" :class="{ children: depth > 1 }">
-	<div class="main">
-		<MkAvatar class="avatar" :user="note.user"/>
-		<div class="body">
-			<XNoteHeader class="header" :note="note" :mini="true"/>
-			<div class="body">
-				<p v-if="note.cw != null" class="cw">
-					<Mfm v-if="note.cw != ''" class="text" :text="note.cw" :author="note.user" :i="$i" :custom-emojis="note.emojis" />
-					<XCwButton v-model="showContent" :note="note"/>
-				</p>
-				<div v-show="note.cw == null || showContent" class="content">
-					<XSubNote-content class="text" :note="note"/>
-				</div>
-			</div>
-		</div>
-	</div>
-	<template v-if="depth < 5">
-		<XSub v-for="reply in replies" :key="reply.id" :note="reply" class="reply" :detail="true" :depth="depth + 1"/>
-	</template>
-	<div v-else class="more">
-		<MkA class="text _link" :to="notePage(note)">{{ $ts.continueThread }} <i class="fas fa-angle-double-right"></i></MkA>
-	</div>
-</div>
-</template>
-
-<script lang="ts">
-import { defineComponent } from 'vue';
-import { notePage } from '@/filters/note';
-import XNoteHeader from './note-header.vue';
-import XSubNoteContent from './sub-note-content.vue';
-import XCwButton from './cw-button.vue';
-import * as os from '@/os';
-
-export default defineComponent({
-	name: 'XSub',
-
-	components: {
-		XNoteHeader,
-		XSubNoteContent,
-		XCwButton,
-	},
-
-	props: {
-		note: {
-			type: Object,
-			required: true
-		},
-		detail: {
-			type: Boolean,
-			required: false,
-			default: false
-		},
-		// how many notes are in between this one and the note being viewed in detail
-		depth: {
-			type: Number,
-			required: false,
-			default: 1
-		},
-	},
-
-	data() {
-		return {
-			showContent: false,
-			replies: [],
-		};
-	},
-
-	created() {
-		if (this.detail) {
-			os.api('notes/children', {
-				noteId: this.note.id,
-				limit: 5
-			}).then(replies => {
-				this.replies = replies;
-			});
-		}
-	},
-
-	methods: {
-		notePage,
-	}
-});
-</script>
-
-<style lang="scss" scoped>
-.wrpstxzv {
-	padding: 16px 32px;
-	font-size: 0.9em;
-
-	&.max-width_450px {
-		padding: 14px 16px;
-	}
-
-	&.children {
-		padding: 10px 0 0 16px;
-		font-size: 1em;
-
-		&.max-width_450px {
-			padding: 10px 0 0 8px;
-		}
-	}
-
-	> .main {
-		display: flex;
-
-		> .avatar {
-			flex-shrink: 0;
-			display: block;
-			margin: 0 8px 0 0;
-			width: 38px;
-			height: 38px;
-			border-radius: 8px;
-		}
-
-		> .body {
-			flex: 1;
-			min-width: 0;
-
-			> .header {
-				margin-bottom: 2px;
-			}
-
-			> .body {
-				> .cw {
-					cursor: default;
-					display: block;
-					margin: 0;
-					padding: 0;
-					overflow-wrap: break-word;
-
-					> .text {
-						margin-right: 8px;
-					}
-				}
-
-				> .content {
-					> .text {
-						margin: 0;
-						padding: 0;
-					}
-				}
-			}
-		}
-	}
-
-	> .reply, > .more {
-		border-left: solid 0.5px var(--divider);
-		margin-top: 10px;
-	}
-
-	> .more {
-		padding: 10px 0 0 16px;
-	}
-}
-</style>
diff --git a/packages/client/src/components/note.vue b/packages/client/src/components/note.vue
index 3cf924928a..b309afe051 100644
--- a/packages/client/src/components/note.vue
+++ b/packages/client/src/components/note.vue
@@ -2,20 +2,21 @@
 <div
 	v-if="!muted"
 	v-show="!isDeleted"
+	ref="el"
 	v-hotkey="keymap"
 	v-size="{ max: [500, 450, 350, 300] }"
 	class="tkcbzcuz"
 	:tabindex="!isDeleted ? '-1' : null"
 	:class="{ renote: isRenote }"
 >
-	<XSub v-if="appearNote.reply" :note="appearNote.reply" class="reply-to"/>
-	<div v-if="pinned" class="info"><i class="fas fa-thumbtack"></i> {{ $ts.pinnedNote }}</div>
-	<div v-if="appearNote._prId_" class="info"><i class="fas fa-bullhorn"></i> {{ $ts.promotion }}<button class="_textButton hide" @click="readPromo()">{{ $ts.hideThisNote }} <i class="fas fa-times"></i></button></div>
-	<div v-if="appearNote._featuredId_" class="info"><i class="fas fa-bolt"></i> {{ $ts.featured }}</div>
+	<MkNoteSub v-if="appearNote.reply" :note="appearNote.reply" class="reply-to"/>
+	<div v-if="pinned" class="info"><i class="fas fa-thumbtack"></i> {{ i18n.locale.pinnedNote }}</div>
+	<div v-if="appearNote._prId_" class="info"><i class="fas fa-bullhorn"></i> {{ i18n.locale.promotion }}<button class="_textButton hide" @click="readPromo()">{{ i18n.locale.hideThisNote }} <i class="fas fa-times"></i></button></div>
+	<div v-if="appearNote._featuredId_" class="info"><i class="fas fa-bolt"></i> {{ i18n.locale.featured }}</div>
 	<div v-if="isRenote" class="renote">
 		<MkAvatar class="avatar" :user="note.user"/>
 		<i class="fas fa-retweet"></i>
-		<I18n :src="$ts.renotedBy" tag="span">
+		<I18n :src="i18n.locale.renotedBy" tag="span">
 			<template #user>
 				<MkA v-user-preview="note.userId" class="name" :to="userPage(note.user)">
 					<MkUserName :user="note.user"/>
@@ -47,7 +48,7 @@
 				</p>
 				<div v-show="appearNote.cw == null || showContent" class="content" :class="{ collapsed }">
 					<div class="text">
-						<span v-if="appearNote.isHidden" style="opacity: 0.5">({{ $ts.private }})</span>
+						<span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.locale.private }})</span>
 						<MkA v-if="appearNote.replyId" class="reply" :to="`/notes/${appearNote.replyId}`"><i class="fas fa-reply"></i></MkA>
 						<Mfm v-if="appearNote.text" :text="appearNote.text" :author="appearNote.user" :i="$i" :custom-emojis="appearNote.emojis"/>
 						<a v-if="appearNote.renote != null" class="rp">RN:</a>
@@ -66,7 +67,7 @@
 					<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" class="url-preview"/>
 					<div v-if="appearNote.renote" class="renote"><XNoteSimple :note="appearNote.renote"/></div>
 					<button v-if="collapsed" class="fade _button" @click="collapsed = false">
-						<span>{{ $ts.showMore }}</span>
+						<span>{{ i18n.locale.showMore }}</span>
 					</button>
 				</div>
 				<MkA v-if="appearNote.channel && !inChannel" class="channel" :to="`/channels/${appearNote.channel.id}`"><i class="fas fa-satellite-dish"></i> {{ appearNote.channel.name }}</MkA>
@@ -93,7 +94,7 @@
 	</article>
 </div>
 <div v-else class="muted" @click="muted = false">
-	<I18n :src="$ts.userSaysSomething" tag="small">
+	<I18n :src="i18n.locale.userSaysSomething" tag="small">
 		<template #name>
 			<MkA v-user-preview="appearNote.userId" class="name" :to="userPage(appearNote.user)">
 				<MkUserName :user="appearNote.user"/>
@@ -103,11 +104,11 @@
 </div>
 </template>
 
-<script lang="ts">
-import { defineAsyncComponent, defineComponent, markRaw } from 'vue';
+<script lang="ts" setup>
+import { computed, inject, onMounted, onUnmounted, reactive, ref } from 'vue';
 import * as mfm from 'mfm-js';
-import { sum } from '@/scripts/array';
-import XSub from './note.sub.vue';
+import * as misskey from 'misskey-js';
+import MkNoteSub from './MkNoteSub.vue';
 import XNoteHeader from './note-header.vue';
 import XNoteSimple from './note-simple.vue';
 import XReactionsViewer from './reactions-viewer.vue';
@@ -115,745 +116,164 @@ import XMediaList from './media-list.vue';
 import XCwButton from './cw-button.vue';
 import XPoll from './poll.vue';
 import XRenoteButton from './renote-button.vue';
+import MkUrlPreview from '@/components/url-preview.vue';
+import MkInstanceTicker from '@/components/instance-ticker.vue';
 import { pleaseLogin } from '@/scripts/please-login';
 import { focusPrev, focusNext } from '@/scripts/focus';
-import { url } from '@/config';
-import copyToClipboard from '@/scripts/copy-to-clipboard';
 import { checkWordMute } from '@/scripts/check-word-mute';
 import { userPage } from '@/filters/user';
 import * as os from '@/os';
-import { stream } from '@/stream';
-import { noteActions, noteViewInterruptors } from '@/store';
+import { defaultStore, noteViewInterruptors } from '@/store';
 import { reactionPicker } from '@/scripts/reaction-picker';
 import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm';
+import { $i } from '@/account';
+import { i18n } from '@/i18n';
+import { getNoteMenu } from '@/scripts/get-note-menu';
+import { useNoteCapture } from '@/scripts/use-note-capture';
+
+const props = defineProps<{
+	note: misskey.entities.Note;
+	pinned?: boolean;
+}>();
+
+const inChannel = inject('inChannel', null);
+
+const isRenote = (
+	props.note.renote != null &&
+	props.note.text == null &&
+	props.note.fileIds.length === 0 &&
+	props.note.poll == null
+);
+
+const el = ref<HTMLElement>();
+const menuButton = ref<HTMLElement>();
+const renoteButton = ref<InstanceType<typeof XRenoteButton>>();
+const renoteTime = ref<HTMLElement>();
+const reactButton = ref<HTMLElement>();
+let appearNote = $ref(isRenote ? props.note.renote as misskey.entities.Note : props.note);
+const isMyRenote = $i && ($i.id === props.note.userId);
+const showContent = ref(false);
+const collapsed = ref(appearNote.cw == null && appearNote.text != null && (
+	(appearNote.text.split('\n').length > 9) ||
+	(appearNote.text.length > 500)
+));
+const isDeleted = ref(false);
+const muted = ref(checkWordMute(appearNote, $i, defaultStore.state.mutedWords));
+const translation = ref(null);
+const translating = ref(false);
+const urls = appearNote.text ? extractUrlFromMfm(mfm.parse(appearNote.text)) : null;
+const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.user.instance);
+
+const keymap = {
+	'r': () => reply(true),
+	'e|a|plus': () => react(true),
+	'q': () => renoteButton.value.renote(true),
+	'up|k|shift+tab': focusBefore,
+	'down|j|tab': focusAfter,
+	'esc': blur,
+	'm|o': () => menu(true),
+	's': () => showContent.value != showContent.value,
+};
+
+useNoteCapture({
+	appearNote: $$(appearNote),
+	rootEl: el,
+});
 
-export default defineComponent({
-	components: {
-		XSub,
-		XNoteHeader,
-		XNoteSimple,
-		XReactionsViewer,
-		XMediaList,
-		XCwButton,
-		XPoll,
-		XRenoteButton,
-		MkUrlPreview: defineAsyncComponent(() => import('@/components/url-preview.vue')),
-		MkInstanceTicker: defineAsyncComponent(() => import('@/components/instance-ticker.vue')),
-	},
-
-	inject: {
-		inChannel: {
-			default: null
-		},
-	},
-
-	props: {
-		note: {
-			type: Object,
-			required: true
-		},
-		pinned: {
-			type: Boolean,
-			required: false,
-			default: false
-		},
-	},
-
-	emits: ['update:note'],
-
-	data() {
-		return {
-			connection: null,
-			replies: [],
-			showContent: false,
-			collapsed: false,
-			isDeleted: false,
-			muted: false,
-			translation: null,
-			translating: false,
-		};
-	},
-
-	computed: {
-		rs() {
-			return this.$store.state.reactions;
-		},
-		keymap(): any {
-			return {
-				'r': () => this.reply(true),
-				'e|a|plus': () => this.react(true),
-				'q': () => this.$refs.renoteButton.renote(true),
-				'f|b': this.favorite,
-				'delete|ctrl+d': this.del,
-				'ctrl+q': this.renoteDirectly,
-				'up|k|shift+tab': this.focusBefore,
-				'down|j|tab': this.focusAfter,
-				'esc': this.blur,
-				'm|o': () => this.menu(true),
-				's': this.toggleShowContent,
-				'1': () => this.reactDirectly(this.rs[0]),
-				'2': () => this.reactDirectly(this.rs[1]),
-				'3': () => this.reactDirectly(this.rs[2]),
-				'4': () => this.reactDirectly(this.rs[3]),
-				'5': () => this.reactDirectly(this.rs[4]),
-				'6': () => this.reactDirectly(this.rs[5]),
-				'7': () => this.reactDirectly(this.rs[6]),
-				'8': () => this.reactDirectly(this.rs[7]),
-				'9': () => this.reactDirectly(this.rs[8]),
-				'0': () => this.reactDirectly(this.rs[9]),
-			};
-		},
-
-		isRenote(): boolean {
-			return (this.note.renote &&
-				this.note.text == null &&
-				this.note.fileIds.length == 0 &&
-				this.note.poll == null);
-		},
-
-		appearNote(): any {
-			return this.isRenote ? this.note.renote : this.note;
-		},
-
-		isMyNote(): boolean {
-			return this.$i && (this.$i.id === this.appearNote.userId);
-		},
-
-		isMyRenote(): boolean {
-			return this.$i && (this.$i.id === this.note.userId);
-		},
-
-		reactionsCount(): number {
-			return this.appearNote.reactions
-				? sum(Object.values(this.appearNote.reactions))
-				: 0;
-		},
-
-		urls(): string[] {
-			if (this.appearNote.text) {
-				return extractUrlFromMfm(mfm.parse(this.appearNote.text));
-			} else {
-				return null;
-			}
-		},
-
-		showTicker() {
-			if (this.$store.state.instanceTicker === 'always') return true;
-			if (this.$store.state.instanceTicker === 'remote' && this.appearNote.user.instance) return true;
-			return false;
-		}
-	},
-
-	async created() {
-		if (this.$i) {
-			this.connection = stream;
-		}
-
-		this.collapsed = this.appearNote.cw == null && this.appearNote.text && (
-			(this.appearNote.text.split('\n').length > 9) ||
-			(this.appearNote.text.length > 500)
-		);
-		this.muted = await checkWordMute(this.appearNote, this.$i, this.$store.state.mutedWords);
-
-		// plugin
-		if (noteViewInterruptors.length > 0) {
-			let result = this.note;
-			for (const interruptor of noteViewInterruptors) {
-				result = await interruptor.handler(JSON.parse(JSON.stringify(result)));
-			}
-			this.$emit('update:note', Object.freeze(result));
-		}
-	},
-
-	mounted() {
-		this.capture(true);
+function reply(viaKeyboard = false): void {
+	pleaseLogin();
+	os.post({
+		reply: appearNote,
+		animation: !viaKeyboard,
+	}, () => {
+		focus();
+	});
+}
 
-		if (this.$i) {
-			this.connection.on('_connected_', this.onStreamConnected);
-		}
-	},
+function react(viaKeyboard = false): void {
+	pleaseLogin();
+	blur();
+	reactionPicker.show(reactButton.value, reaction => {
+		os.api('notes/reactions/create', {
+			noteId: appearNote.id,
+			reaction: reaction
+		});
+	}, () => {
+		focus();
+	});
+}
 
-	beforeUnmount() {
-		this.decapture(true);
+function undoReact(note): void {
+	const oldReaction = note.myReaction;
+	if (!oldReaction) return;
+	os.api('notes/reactions/delete', {
+		noteId: note.id
+	});
+}
 
-		if (this.$i) {
-			this.connection.off('_connected_', this.onStreamConnected);
+function onContextmenu(e): void {
+	const isLink = (el: HTMLElement) => {
+		if (el.tagName === 'A') return true;
+		if (el.parentElement) {
+			return isLink(el.parentElement);
 		}
-	},
-
-	methods: {
-		updateAppearNote(v) {
-			this.$emit('update:note', Object.freeze(this.isRenote ? {
-				...this.note,
-				renote: {
-					...this.note.renote,
-					...v
-				}
-			} : {
-				...this.note,
-				...v
-			}));
-		},
-
-		readPromo() {
-			os.api('promo/read', {
-				noteId: this.appearNote.id
-			});
-			this.isDeleted = true;
-		},
-
-		capture(withHandler = false) {
-			if (this.$i) {
-				// TODO: このノートがストリーミング経由で流れてきた場合のみ sr する
-				this.connection.send(document.body.contains(this.$el) ? 'sr' : 's', { id: this.appearNote.id });
-				if (withHandler) this.connection.on('noteUpdated', this.onStreamNoteUpdated);
-			}
-		},
-
-		decapture(withHandler = false) {
-			if (this.$i) {
-				this.connection.send('un', {
-					id: this.appearNote.id
-				});
-				if (withHandler) this.connection.off('noteUpdated', this.onStreamNoteUpdated);
-			}
-		},
-
-		onStreamConnected() {
-			this.capture();
-		},
-
-		onStreamNoteUpdated(data) {
-			const { type, id, body } = data;
-
-			if (id !== this.appearNote.id) return;
-
-			switch (type) {
-				case 'reacted': {
-					const reaction = body.reaction;
-
-					// DeepではなくShallowコピーであることに注意。n.reactions[reaction] = hogeとかしないように(親からもらったデータをミューテートすることになるので)
-					let n = {
-						...this.appearNote,
-					};
-
-					if (body.emoji) {
-						const emojis = this.appearNote.emojis || [];
-						if (!emojis.includes(body.emoji)) {
-							n.emojis = [...emojis, body.emoji];
-						}
-					}
-
-					// TODO: reactionsプロパティがない場合ってあったっけ？ なければ || {} は消せる
-					const currentCount = (this.appearNote.reactions || {})[reaction] || 0;
-
-					// Increment the count
-					n.reactions = {
-						...this.appearNote.reactions,
-						[reaction]: currentCount + 1
-					};
-
-					if (body.userId === this.$i.id) {
-						n.myReaction = reaction;
-					}
-
-					this.updateAppearNote(n);
-					break;
-				}
-
-				case 'unreacted': {
-					const reaction = body.reaction;
-
-					// DeepではなくShallowコピーであることに注意。n.reactions[reaction] = hogeとかしないように(親からもらったデータをミューテートすることになるので)
-					let n = {
-						...this.appearNote,
-					};
-
-					// TODO: reactionsプロパティがない場合ってあったっけ？ なければ || {} は消せる
-					const currentCount = (this.appearNote.reactions || {})[reaction] || 0;
-
-					// Decrement the count
-					n.reactions = {
-						...this.appearNote.reactions,
-						[reaction]: Math.max(0, currentCount - 1)
-					};
-
-					if (body.userId === this.$i.id) {
-						n.myReaction = null;
-					}
-
-					this.updateAppearNote(n);
-					break;
-				}
-
-				case 'pollVoted': {
-					const choice = body.choice;
-
-					// DeepではなくShallowコピーであることに注意。n.reactions[reaction] = hogeとかしないように(親からもらったデータをミューテートすることになるので)
-					let n = {
-						...this.appearNote,
-					};
-
-					const choices = [...this.appearNote.poll.choices];
-					choices[choice] = {
-						...choices[choice],
-						votes: choices[choice].votes + 1,
-						...(body.userId === this.$i.id ? {
-							isVoted: true
-						} : {})
-					};
-
-					n.poll = {
-						...this.appearNote.poll,
-						choices: choices
-					};
-
-					this.updateAppearNote(n);
-					break;
-				}
-
-				case 'deleted': {
-					this.isDeleted = true;
-					break;
-				}
-			}
-		},
-
-		reply(viaKeyboard = false) {
-			pleaseLogin();
-			os.post({
-				reply: this.appearNote,
-				animation: !viaKeyboard,
-			}, () => {
-				this.focus();
-			});
-		},
-
-		renoteDirectly() {
-			os.apiWithDialog('notes/create', {
-				renoteId: this.appearNote.id
-			}, undefined, (res: any) => {
-				os.alert({
-					type: 'success',
-					text: this.$ts.renoted,
-				});
-			}, (e: Error) => {
-				if (e.id === 'b5c90186-4ab0-49c8-9bba-a1f76c282ba4') {
-					os.alert({
-						type: 'error',
-						text: this.$ts.cantRenote,
-					});
-				} else if (e.id === 'fd4cc33e-2a37-48dd-99cc-9b806eb2031a') {
-					os.alert({
-						type: 'error',
-						text: this.$ts.cantReRenote,
-					});
-				}
-			});
-		},
-
-		react(viaKeyboard = false) {
-			pleaseLogin();
-			this.blur();
-			reactionPicker.show(this.$refs.reactButton, reaction => {
-				os.api('notes/reactions/create', {
-					noteId: this.appearNote.id,
-					reaction: reaction
-				});
-			}, () => {
-				this.focus();
-			});
-		},
-
-		reactDirectly(reaction) {
-			os.api('notes/reactions/create', {
-				noteId: this.appearNote.id,
-				reaction: reaction
-			});
-		},
-
-		undoReact(note) {
-			const oldReaction = note.myReaction;
-			if (!oldReaction) return;
-			os.api('notes/reactions/delete', {
-				noteId: note.id
-			});
-		},
-
-		favorite() {
-			pleaseLogin();
-			os.apiWithDialog('notes/favorites/create', {
-				noteId: this.appearNote.id
-			}, undefined, (res: any) => {
-				os.alert({
-					type: 'success',
-					text: this.$ts.favorited,
-				});
-			}, (e: Error) => {
-				if (e.id === 'a402c12b-34dd-41d2-97d8-4d2ffd96a1a6') {
-					os.alert({
-						type: 'error',
-						text: this.$ts.alreadyFavorited,
-					});
-				} else if (e.id === '6dd26674-e060-4816-909a-45ba3f4da458') {
-					os.alert({
-						type: 'error',
-						text: this.$ts.cantFavorite,
-					});
-				}
-			});
-		},
-
-		del() {
-			os.confirm({
-				type: 'warning',
-				text: this.$ts.noteDeleteConfirm,
-			}).then(({ canceled }) => {
-				if (canceled) return;
-
-				os.api('notes/delete', {
-					noteId: this.appearNote.id
-				});
-			});
-		},
-
-		delEdit() {
-			os.confirm({
-				type: 'warning',
-				text: this.$ts.deleteAndEditConfirm,
-			}).then(({ canceled }) => {
-				if (canceled) return;
-
-				os.api('notes/delete', {
-					noteId: this.appearNote.id
-				});
-
-				os.post({ initialNote: this.appearNote, renote: this.appearNote.renote, reply: this.appearNote.reply, channel: this.appearNote.channel });
-			});
-		},
-
-		toggleFavorite(favorite: boolean) {
-			os.apiWithDialog(favorite ? 'notes/favorites/create' : 'notes/favorites/delete', {
-				noteId: this.appearNote.id
-			});
-		},
-
-		toggleWatch(watch: boolean) {
-			os.apiWithDialog(watch ? 'notes/watching/create' : 'notes/watching/delete', {
-				noteId: this.appearNote.id
-			});
-		},
-
-		toggleThreadMute(mute: boolean) {
-			os.apiWithDialog(mute ? 'notes/thread-muting/create' : 'notes/thread-muting/delete', {
-				noteId: this.appearNote.id
-			});
-		},
-
-		getMenu() {
-			let menu;
-			if (this.$i) {
-				const statePromise = os.api('notes/state', {
-					noteId: this.appearNote.id
-				});
-
-				menu = [{
-					icon: 'fas fa-copy',
-					text: this.$ts.copyContent,
-					action: this.copyContent
-				}, {
-					icon: 'fas fa-link',
-					text: this.$ts.copyLink,
-					action: this.copyLink
-				}, (this.appearNote.url || this.appearNote.uri) ? {
-					icon: 'fas fa-external-link-square-alt',
-					text: this.$ts.showOnRemote,
-					action: () => {
-						window.open(this.appearNote.url || this.appearNote.uri, '_blank');
-					}
-				} : undefined,
-				{
-					icon: 'fas fa-share-alt',
-					text: this.$ts.share,
-					action: this.share
-				},
-				this.$instance.translatorAvailable ? {
-					icon: 'fas fa-language',
-					text: this.$ts.translate,
-					action: this.translate
-				} : undefined,
-				null,
-				statePromise.then(state => state.isFavorited ? {
-					icon: 'fas fa-star',
-					text: this.$ts.unfavorite,
-					action: () => this.toggleFavorite(false)
-				} : {
-					icon: 'fas fa-star',
-					text: this.$ts.favorite,
-					action: () => this.toggleFavorite(true)
-				}),
-				{
-					icon: 'fas fa-paperclip',
-					text: this.$ts.clip,
-					action: () => this.clip()
-				},
-				(this.appearNote.userId != this.$i.id) ? statePromise.then(state => state.isWatching ? {
-					icon: 'fas fa-eye-slash',
-					text: this.$ts.unwatch,
-					action: () => this.toggleWatch(false)
-				} : {
-					icon: 'fas fa-eye',
-					text: this.$ts.watch,
-					action: () => this.toggleWatch(true)
-				}) : undefined,
-				statePromise.then(state => state.isMutedThread ? {
-					icon: 'fas fa-comment-slash',
-					text: this.$ts.unmuteThread,
-					action: () => this.toggleThreadMute(false)
-				} : {
-					icon: 'fas fa-comment-slash',
-					text: this.$ts.muteThread,
-					action: () => this.toggleThreadMute(true)
-				}),
-				this.appearNote.userId == this.$i.id ? (this.$i.pinnedNoteIds || []).includes(this.appearNote.id) ? {
-					icon: 'fas fa-thumbtack',
-					text: this.$ts.unpin,
-					action: () => this.togglePin(false)
-				} : {
-					icon: 'fas fa-thumbtack',
-					text: this.$ts.pin,
-					action: () => this.togglePin(true)
-				} : undefined,
-				/*
-				...(this.$i.isModerator || this.$i.isAdmin ? [
-					null,
-					{
-						icon: 'fas fa-bullhorn',
-						text: this.$ts.promote,
-						action: this.promote
-					}]
-					: []
-				),*/
-				...(this.appearNote.userId != this.$i.id ? [
-					null,
-					{
-						icon: 'fas fa-exclamation-circle',
-						text: this.$ts.reportAbuse,
-						action: () => {
-							const u = `${url}/notes/${this.appearNote.id}`;
-							os.popup(import('@/components/abuse-report-window.vue'), {
-								user: this.appearNote.user,
-								initialComment: `Note: ${u}\n-----\n`
-							}, {}, 'closed');
-						}
-					}]
-					: []
-				),
-				...(this.appearNote.userId == this.$i.id || this.$i.isModerator || this.$i.isAdmin ? [
-					null,
-					this.appearNote.userId == this.$i.id ? {
-						icon: 'fas fa-edit',
-						text: this.$ts.deleteAndEdit,
-						action: this.delEdit
-					} : undefined,
-					{
-						icon: 'fas fa-trash-alt',
-						text: this.$ts.delete,
-						danger: true,
-						action: this.del
-					}]
-					: []
-				)]
-				.filter(x => x !== undefined);
-			} else {
-				menu = [{
-					icon: 'fas fa-copy',
-					text: this.$ts.copyContent,
-					action: this.copyContent
-				}, {
-					icon: 'fas fa-link',
-					text: this.$ts.copyLink,
-					action: this.copyLink
-				}, (this.appearNote.url || this.appearNote.uri) ? {
-					icon: 'fas fa-external-link-square-alt',
-					text: this.$ts.showOnRemote,
-					action: () => {
-						window.open(this.appearNote.url || this.appearNote.uri, '_blank');
-					}
-				} : undefined]
-				.filter(x => x !== undefined);
-			}
-
-			if (noteActions.length > 0) {
-				menu = menu.concat([null, ...noteActions.map(action => ({
-					icon: 'fas fa-plug',
-					text: action.title,
-					action: () => {
-						action.handler(this.appearNote);
-					}
-				}))]);
-			}
-
-			return menu;
-		},
-
-		onContextmenu(e) {
-			const isLink = (el: HTMLElement) => {
-				if (el.tagName === 'A') return true;
-				if (el.parentElement) {
-					return isLink(el.parentElement);
-				}
-			};
-			if (isLink(e.target)) return;
-			if (window.getSelection().toString() !== '') return;
-
-			if (this.$store.state.useReactionPickerForContextMenu) {
-				e.preventDefault();
-				this.react();
-			} else {
-				os.contextMenu(this.getMenu(), e).then(this.focus);
-			}
-		},
-
-		menu(viaKeyboard = false) {
-			os.popupMenu(this.getMenu(), this.$refs.menuButton, {
-				viaKeyboard
-			}).then(this.focus);
-		},
-
-		showRenoteMenu(viaKeyboard = false) {
-			if (!this.isMyRenote) return;
-			os.popupMenu([{
-				text: this.$ts.unrenote,
-				icon: 'fas fa-trash-alt',
-				danger: true,
-				action: () => {
-					os.api('notes/delete', {
-						noteId: this.note.id
-					});
-					this.isDeleted = true;
-				}
-			}], this.$refs.renoteTime, {
-				viaKeyboard: viaKeyboard
-			});
-		},
-
-		toggleShowContent() {
-			this.showContent = !this.showContent;
-		},
-
-		copyContent() {
-			copyToClipboard(this.appearNote.text);
-			os.success();
-		},
-
-		copyLink() {
-			copyToClipboard(`${url}/notes/${this.appearNote.id}`);
-			os.success();
-		},
-
-		togglePin(pin: boolean) {
-			os.apiWithDialog(pin ? 'i/pin' : 'i/unpin', {
-				noteId: this.appearNote.id
-			}, undefined, null, e => {
-				if (e.id === '72dab508-c64d-498f-8740-a8eec1ba385a') {
-					os.alert({
-						type: 'error',
-						text: this.$ts.pinLimitExceeded
-					});
-				}
-			});
-		},
-
-		async clip() {
-			const clips = await os.api('clips/list');
-			os.popupMenu([{
-				icon: 'fas fa-plus',
-				text: this.$ts.createNew,
-				action: async () => {
-					const { canceled, result } = await os.form(this.$ts.createNewClip, {
-						name: {
-							type: 'string',
-							label: this.$ts.name
-						},
-						description: {
-							type: 'string',
-							required: false,
-							multiline: true,
-							label: this.$ts.description
-						},
-						isPublic: {
-							type: 'boolean',
-							label: this.$ts.public,
-							default: false
-						}
-					});
-					if (canceled) return;
-
-					const clip = await os.apiWithDialog('clips/create', result);
-
-					os.apiWithDialog('clips/add-note', { clipId: clip.id, noteId: this.appearNote.id });
-				}
-			}, null, ...clips.map(clip => ({
-				text: clip.name,
-				action: () => {
-					os.apiWithDialog('clips/add-note', { clipId: clip.id, noteId: this.appearNote.id });
-				}
-			}))], this.$refs.menuButton, {
-			}).then(this.focus);
-		},
-
-		async promote() {
-			const { canceled, result: days } = await os.inputNumber({
-				title: this.$ts.numberOfDays,
-			});
-
-			if (canceled) return;
+	};
+	if (isLink(e.target)) return;
+	if (window.getSelection().toString() !== '') return;
+
+	if (defaultStore.state.useReactionPickerForContextMenu) {
+		e.preventDefault();
+		react();
+	} else {
+		os.contextMenu(getNoteMenu({ note: props.note, translating, translation, menuButton }), e).then(focus);
+	}
+}
 
-			os.apiWithDialog('admin/promo/create', {
-				noteId: this.appearNote.id,
-				expiresAt: Date.now() + (86400000 * days)
-			});
-		},
+function menu(viaKeyboard = false): void {
+	os.popupMenu(getNoteMenu({ note: props.note, translating, translation, menuButton }), menuButton.value, {
+		viaKeyboard
+	}).then(focus);
+}
 
-		share() {
-			navigator.share({
-				title: this.$t('noteOf', { user: this.appearNote.user.name }),
-				text: this.appearNote.text,
-				url: `${url}/notes/${this.appearNote.id}`
-			});
-		},
-
-		async translate() {
-			if (this.translation != null) return;
-			this.translating = true;
-			const res = await os.api('notes/translate', {
-				noteId: this.appearNote.id,
-				targetLang: localStorage.getItem('lang') || navigator.language,
+function showRenoteMenu(viaKeyboard = false): void {
+	if (!isMyRenote) return;
+	os.popupMenu([{
+		text: i18n.locale.unrenote,
+		icon: 'fas fa-trash-alt',
+		danger: true,
+		action: () => {
+			os.api('notes/delete', {
+				noteId: props.note.id
 			});
-			this.translating = false;
-			this.translation = res;
-		},
+			isDeleted.value = true;
+		}
+	}], renoteTime.value, {
+		viaKeyboard: viaKeyboard
+	});
+}
 
-		focus() {
-			this.$el.focus();
-		},
+function focus() {
+	el.value.focus();
+}
 
-		blur() {
-			this.$el.blur();
-		},
+function blur() {
+	el.value.blur();
+}
 
-		focusBefore() {
-			focusPrev(this.$el);
-		},
+function focusBefore() {
+	focusPrev(el.value);
+}
 
-		focusAfter() {
-			focusNext(this.$el);
-		},
+function focusAfter() {
+	focusNext(el.value);
+}
 
-		userPage
-	}
-});
+function readPromo() {
+	os.api('promo/read', {
+		noteId: appearNote.id
+	});
+	isDeleted.value = true;
+}
 </script>
 
 <style lang="scss" scoped>
diff --git a/packages/client/src/components/notes.vue b/packages/client/src/components/notes.vue
index d6107216e2..aec478ac95 100644
--- a/packages/client/src/components/notes.vue
+++ b/packages/client/src/components/notes.vue
@@ -10,7 +10,7 @@
 	<template #default="{ items: notes }">
 		<div class="giivymft" :class="{ noGap }">
 			<XList ref="notes" v-slot="{ item: note }" :items="notes" :direction="pagination.reversed ? 'up' : 'down'" :reversed="pagination.reversed" :no-gap="noGap" :ad="true" class="notes">
-				<XNote :key="note._featuredId_ || note._prId_ || note.id" class="qtqtichx" :note="note" @update:note="updated(note, $event)"/>
+				<XNote :key="note._featuredId_ || note._prId_ || note.id" class="qtqtichx" :note="note"/>
 			</XList>
 		</div>
 	</template>
@@ -31,10 +31,6 @@ const props = defineProps<{
 
 const pagingComponent = ref<InstanceType<typeof MkPagination>>();
 
-const updated = (oldValue, newValue) => {
-	pagingComponent.value?.updateItem(oldValue.id, () => newValue);
-};
-
 defineExpose({
 	prepend: (note) => {
 		pagingComponent.value?.prepend(note);
diff --git a/packages/client/src/components/notifications.vue b/packages/client/src/components/notifications.vue
index 31511fb515..5a77b5487e 100644
--- a/packages/client/src/components/notifications.vue
+++ b/packages/client/src/components/notifications.vue
@@ -9,7 +9,7 @@
 
 	<template #default="{ items: notifications }">
 		<XList v-slot="{ item: notification }" class="elsfgstc" :items="notifications" :no-gap="true">
-			<XNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :key="notification.id" :note="notification.note" @update:note="noteUpdated(notification, $event)"/>
+			<XNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :key="notification.id" :note="notification.note"/>
 			<XNotification v-else :key="notification.id" :notification="notification" :with-time="true" :full="true" class="_panel notification"/>
 		</XList>
 	</template>
@@ -62,13 +62,6 @@ const onNotification = (notification) => {
 	}
 };
 
-const noteUpdated = (item, note) => {
-	pagingComponent.value?.updateItem(item.id, old => ({
-		...old,
-		note: note,
-	}));
-};
-
 onMounted(() => {
 	const connection = stream.useChannel('main');
 	connection.on('notification', onNotification);
diff --git a/packages/client/src/components/ui/pagination.vue b/packages/client/src/components/ui/pagination.vue
index d4451e27cb..571ef71eab 100644
--- a/packages/client/src/components/ui/pagination.vue
+++ b/packages/client/src/components/ui/pagination.vue
@@ -90,7 +90,6 @@ const init = async (): Promise<void> => {
 	}).then(res => {
 		for (let i = 0; i < res.length; i++) {
 			const item = res[i];
-			markRaw(item);
 			if (props.pagination.reversed) {
 				if (i === res.length - 2) item._shouldInsertAd_ = true;
 			} else {
@@ -134,7 +133,6 @@ const fetchMore = async (): Promise<void> => {
 	}).then(res => {
 		for (let i = 0; i < res.length; i++) {
 			const item = res[i];
-			markRaw(item);
 			if (props.pagination.reversed) {
 				if (i === res.length - 9) item._shouldInsertAd_ = true;
 			} else {
@@ -169,9 +167,6 @@ const fetchMoreAhead = async (): Promise<void> => {
 			sinceId: props.pagination.reversed ? items.value[0].id : items.value[items.value.length - 1].id,
 		}),
 	}).then(res => {
-		for (const item of res) {
-			markRaw(item);
-		}
 		if (res.length > SECOND_FETCH_LIMIT) {
 			res.pop();
 			items.value = props.pagination.reversed ? [...res].reverse().concat(items.value) : items.value.concat(res);
diff --git a/packages/client/src/pages/favorites.vue b/packages/client/src/pages/favorites.vue
index f530da2a19..8965b30d60 100644
--- a/packages/client/src/pages/favorites.vue
+++ b/packages/client/src/pages/favorites.vue
@@ -10,7 +10,7 @@
 
 		<template #default="{ items }">
 			<XList v-slot="{ item }" :items="items" :direction="'down'" :no-gap="false" :ad="false">
-				<XNote :key="item.id" :note="item.note" :class="$style.note" @update:note="noteUpdated(item, $event)"/>
+				<XNote :key="item.id" :note="item.note" :class="$style.note"/>
 			</XList>
 		</template>
 	</MkPagination>
@@ -32,13 +32,6 @@ const pagination = {
 
 const pagingComponent = ref<InstanceType<typeof MkPagination>>();
 
-const noteUpdated = (item, note) => {
-	pagingComponent.value?.updateItem(item.id, old => ({
-		...old,
-		note: note,
-	}));
-};
-
 defineExpose({
 	[symbols.PAGE_INFO]: {
 		title: i18n.locale.favorites,
@@ -53,4 +46,4 @@ defineExpose({
 	background: var(--panel);
 	border-radius: var(--radius);
 }
-</style>
\ No newline at end of file
+</style>
diff --git a/packages/client/src/pages/user/reactions.vue b/packages/client/src/pages/user/reactions.vue
index 0381542b4d..d2c1f92ebb 100644
--- a/packages/client/src/pages/user/reactions.vue
+++ b/packages/client/src/pages/user/reactions.vue
@@ -7,7 +7,7 @@
 				<MkReactionIcon class="reaction" :reaction="item.type" :custom-emojis="item.note.emojis" :no-style="true"/>
 				<MkTime :time="item.createdAt" class="createdAt"/>
 			</div>
-			<MkNote :key="item.id" :note="item.note" @update:note="updated(note, $event)"/>
+			<MkNote :key="item.id" :note="item.note"/>
 		</div>
 	</MkPagination>
 </div>
diff --git a/packages/client/src/scripts/check-word-mute.ts b/packages/client/src/scripts/check-word-mute.ts
index 3b1fa75b1e..55637bb3b3 100644
--- a/packages/client/src/scripts/check-word-mute.ts
+++ b/packages/client/src/scripts/check-word-mute.ts
@@ -1,4 +1,4 @@
-export async function checkWordMute(note: Record<string, any>, me: Record<string, any> | null | undefined, mutedWords: string[][]): Promise<boolean> {
+export function checkWordMute(note: Record<string, any>, me: Record<string, any> | null | undefined, mutedWords: string[][]): boolean {
 	// 自分自身
 	if (me && (note.userId === me.id)) return false;
 
diff --git a/packages/client/src/scripts/get-note-menu.ts b/packages/client/src/scripts/get-note-menu.ts
new file mode 100644
index 0000000000..61120d53ba
--- /dev/null
+++ b/packages/client/src/scripts/get-note-menu.ts
@@ -0,0 +1,310 @@
+import { Ref } from 'vue';
+import * as misskey from 'misskey-js';
+import { $i } from '@/account';
+import { i18n } from '@/i18n';
+import { instance } from '@/instance';
+import * as os from '@/os';
+import copyToClipboard from '@/scripts/copy-to-clipboard';
+import { url } from '@/config';
+import { noteActions } from '@/store';
+import { pleaseLogin } from './please-login';
+
+export function getNoteMenu(props: {
+	note: misskey.entities.Note;
+	menuButton: Ref<HTMLElement>;
+	translation: Ref<any>;
+	translating: Ref<boolean>;
+}) {
+	const isRenote = (
+		props.note.renote != null &&
+		props.note.text == null &&
+		props.note.fileIds.length === 0 &&
+		props.note.poll == null
+	);
+
+	let appearNote = isRenote ? props.note.renote as misskey.entities.Note : props.note;
+
+	function del(): void {
+		os.confirm({
+			type: 'warning',
+			text: i18n.locale.noteDeleteConfirm,
+		}).then(({ canceled }) => {
+			if (canceled) return;
+
+			os.api('notes/delete', {
+				noteId: appearNote.id
+			});
+		});
+	}
+
+	function delEdit(): void {
+		os.confirm({
+			type: 'warning',
+			text: i18n.locale.deleteAndEditConfirm,
+		}).then(({ canceled }) => {
+			if (canceled) return;
+
+			os.api('notes/delete', {
+				noteId: appearNote.id
+			});
+
+			os.post({ initialNote: appearNote, renote: appearNote.renote, reply: appearNote.reply, channel: appearNote.channel });
+		});
+	}
+
+	function toggleFavorite(favorite: boolean): void {
+		os.apiWithDialog(favorite ? 'notes/favorites/create' : 'notes/favorites/delete', {
+			noteId: appearNote.id
+		});
+	}
+
+	function toggleWatch(watch: boolean): void {
+		os.apiWithDialog(watch ? 'notes/watching/create' : 'notes/watching/delete', {
+			noteId: appearNote.id
+		});
+	}
+
+	function toggleThreadMute(mute: boolean): void {
+		os.apiWithDialog(mute ? 'notes/thread-muting/create' : 'notes/thread-muting/delete', {
+			noteId: appearNote.id
+		});
+	}
+
+	function copyContent(): void {
+		copyToClipboard(appearNote.text);
+		os.success();
+	}
+
+	function copyLink(): void {
+		copyToClipboard(`${url}/notes/${appearNote.id}`);
+		os.success();
+	}
+
+	function togglePin(pin: boolean): void {
+		os.apiWithDialog(pin ? 'i/pin' : 'i/unpin', {
+			noteId: appearNote.id
+		}, undefined, null, e => {
+			if (e.id === '72dab508-c64d-498f-8740-a8eec1ba385a') {
+				os.alert({
+					type: 'error',
+					text: i18n.locale.pinLimitExceeded
+				});
+			}
+		});
+	}
+
+	async function clip(): Promise<void> {
+		const clips = await os.api('clips/list');
+		os.popupMenu([{
+			icon: 'fas fa-plus',
+			text: i18n.locale.createNew,
+			action: async () => {
+				const { canceled, result } = await os.form(i18n.locale.createNewClip, {
+					name: {
+						type: 'string',
+						label: i18n.locale.name
+					},
+					description: {
+						type: 'string',
+						required: false,
+						multiline: true,
+						label: i18n.locale.description
+					},
+					isPublic: {
+						type: 'boolean',
+						label: i18n.locale.public,
+						default: false
+					}
+				});
+				if (canceled) return;
+
+				const clip = await os.apiWithDialog('clips/create', result);
+
+				os.apiWithDialog('clips/add-note', { clipId: clip.id, noteId: appearNote.id });
+			}
+		}, null, ...clips.map(clip => ({
+			text: clip.name,
+			action: () => {
+				os.apiWithDialog('clips/add-note', { clipId: clip.id, noteId: appearNote.id });
+			}
+		}))], props.menuButton.value, {
+		}).then(focus);
+	}
+
+	async function promote(): Promise<void> {
+		const { canceled, result: days } = await os.inputNumber({
+			title: i18n.locale.numberOfDays,
+		});
+
+		if (canceled) return;
+
+		os.apiWithDialog('admin/promo/create', {
+			noteId: appearNote.id,
+			expiresAt: Date.now() + (86400000 * days),
+		});
+	}
+
+	function share(): void {
+		navigator.share({
+			title: i18n.t('noteOf', { user: appearNote.user.name }),
+			text: appearNote.text,
+			url: `${url}/notes/${appearNote.id}`,
+		});
+	}
+
+	async function translate(): Promise<void> {
+		if (props.translation.value != null) return;
+		props.translating.value = true;
+		const res = await os.api('notes/translate', {
+			noteId: appearNote.id,
+			targetLang: localStorage.getItem('lang') || navigator.language,
+		});
+		props.translating.value = false;
+		props.translation.value = res;
+	}
+
+	let menu;
+	if ($i) {
+		const statePromise = os.api('notes/state', {
+			noteId: appearNote.id
+		});
+
+		menu = [{
+			icon: 'fas fa-copy',
+			text: i18n.locale.copyContent,
+			action: copyContent
+		}, {
+			icon: 'fas fa-link',
+			text: i18n.locale.copyLink,
+			action: copyLink
+		}, (appearNote.url || appearNote.uri) ? {
+			icon: 'fas fa-external-link-square-alt',
+			text: i18n.locale.showOnRemote,
+			action: () => {
+				window.open(appearNote.url || appearNote.uri, '_blank');
+			}
+		} : undefined,
+		{
+			icon: 'fas fa-share-alt',
+			text: i18n.locale.share,
+			action: share
+		},
+		instance.translatorAvailable ? {
+			icon: 'fas fa-language',
+			text: i18n.locale.translate,
+			action: translate
+		} : undefined,
+		null,
+		statePromise.then(state => state.isFavorited ? {
+			icon: 'fas fa-star',
+			text: i18n.locale.unfavorite,
+			action: () => toggleFavorite(false)
+		} : {
+			icon: 'fas fa-star',
+			text: i18n.locale.favorite,
+			action: () => toggleFavorite(true)
+		}),
+		{
+			icon: 'fas fa-paperclip',
+			text: i18n.locale.clip,
+			action: () => clip()
+		},
+		(appearNote.userId != $i.id) ? statePromise.then(state => state.isWatching ? {
+			icon: 'fas fa-eye-slash',
+			text: i18n.locale.unwatch,
+			action: () => toggleWatch(false)
+		} : {
+			icon: 'fas fa-eye',
+			text: i18n.locale.watch,
+			action: () => toggleWatch(true)
+		}) : undefined,
+		statePromise.then(state => state.isMutedThread ? {
+			icon: 'fas fa-comment-slash',
+			text: i18n.locale.unmuteThread,
+			action: () => toggleThreadMute(false)
+		} : {
+			icon: 'fas fa-comment-slash',
+			text: i18n.locale.muteThread,
+			action: () => toggleThreadMute(true)
+		}),
+		appearNote.userId == $i.id ? ($i.pinnedNoteIds || []).includes(appearNote.id) ? {
+			icon: 'fas fa-thumbtack',
+			text: i18n.locale.unpin,
+			action: () => togglePin(false)
+		} : {
+			icon: 'fas fa-thumbtack',
+			text: i18n.locale.pin,
+			action: () => togglePin(true)
+		} : undefined,
+		/*
+		...($i.isModerator || $i.isAdmin ? [
+			null,
+			{
+				icon: 'fas fa-bullhorn',
+				text: i18n.locale.promote,
+				action: promote
+			}]
+			: []
+		),*/
+		...(appearNote.userId != $i.id ? [
+			null,
+			{
+				icon: 'fas fa-exclamation-circle',
+				text: i18n.locale.reportAbuse,
+				action: () => {
+					const u = `${url}/notes/${appearNote.id}`;
+					os.popup(import('@/components/abuse-report-window.vue'), {
+						user: appearNote.user,
+						initialComment: `Note: ${u}\n-----\n`
+					}, {}, 'closed');
+				}
+			}]
+			: []
+		),
+		...(appearNote.userId == $i.id || $i.isModerator || $i.isAdmin ? [
+			null,
+			appearNote.userId == $i.id ? {
+				icon: 'fas fa-edit',
+				text: i18n.locale.deleteAndEdit,
+				action: delEdit
+			} : undefined,
+			{
+				icon: 'fas fa-trash-alt',
+				text: i18n.locale.delete,
+				danger: true,
+				action: del
+			}]
+			: []
+		)]
+		.filter(x => x !== undefined);
+	} else {
+		menu = [{
+			icon: 'fas fa-copy',
+			text: i18n.locale.copyContent,
+			action: copyContent
+		}, {
+			icon: 'fas fa-link',
+			text: i18n.locale.copyLink,
+			action: copyLink
+		}, (appearNote.url || appearNote.uri) ? {
+			icon: 'fas fa-external-link-square-alt',
+			text: i18n.locale.showOnRemote,
+			action: () => {
+				window.open(appearNote.url || appearNote.uri, '_blank');
+			}
+		} : undefined]
+		.filter(x => x !== undefined);
+	}
+
+	if (noteActions.length > 0) {
+		menu = menu.concat([null, ...noteActions.map(action => ({
+			icon: 'fas fa-plug',
+			text: action.title,
+			action: () => {
+				action.handler(appearNote);
+			}
+		}))]);
+	}
+
+	return menu;
+}
diff --git a/packages/client/src/scripts/use-note-capture.ts b/packages/client/src/scripts/use-note-capture.ts
new file mode 100644
index 0000000000..bb00e464e3
--- /dev/null
+++ b/packages/client/src/scripts/use-note-capture.ts
@@ -0,0 +1,123 @@
+import { onUnmounted, Ref } from 'vue';
+import * as misskey from 'misskey-js';
+import { stream } from '@/stream';
+import { $i } from '@/account';
+
+export function useNoteCapture(props: {
+	rootEl: Ref<HTMLElement>;
+	appearNote: Ref<misskey.entities.Note>;
+}) {
+	const appearNote = props.appearNote;
+	const connection = $i ? stream : null;
+
+	function onStreamNoteUpdated(data): void {
+		const { type, id, body } = data;
+
+		if (id !== appearNote.value.id) return;
+
+		switch (type) {
+			case 'reacted': {
+				const reaction = body.reaction;
+
+				const updated = JSON.parse(JSON.stringify(appearNote.value));
+
+				if (body.emoji) {
+					const emojis = appearNote.value.emojis || [];
+					if (!emojis.includes(body.emoji)) {
+						updated.emojis = [...emojis, body.emoji];
+					}
+				}
+
+				// TODO: reactionsプロパティがない場合ってあったっけ？ なければ || {} は消せる
+				const currentCount = (appearNote.value.reactions || {})[reaction] || 0;
+
+				updated.reactions[reaction] = currentCount + 1;
+
+				if ($i && (body.userId === $i.id)) {
+					updated.myReaction = reaction;
+				}
+
+				appearNote.value = updated;
+				break;
+			}
+
+			case 'unreacted': {
+				const reaction = body.reaction;
+
+				const updated = JSON.parse(JSON.stringify(appearNote.value));
+
+				// TODO: reactionsプロパティがない場合ってあったっけ？ なければ || {} は消せる
+				const currentCount = (appearNote.value.reactions || {})[reaction] || 0;
+
+				updated.reactions[reaction] = Math.max(0, currentCount - 1);
+
+				if ($i && (body.userId === $i.id)) {
+					updated.myReaction = null;
+				}
+
+				appearNote.value = updated;
+				break;
+			}
+
+			case 'pollVoted': {
+				const choice = body.choice;
+
+				const updated = JSON.parse(JSON.stringify(appearNote.value));
+
+				const choices = [...appearNote.value.poll.choices];
+				choices[choice] = {
+					...choices[choice],
+					votes: choices[choice].votes + 1,
+					...($i && (body.userId === $i.id) ? {
+						isVoted: true
+					} : {})
+				};
+
+				updated.poll.choices = choices;
+
+				appearNote.value = updated;
+				break;
+			}
+
+			case 'deleted': {
+				const updated = JSON.parse(JSON.stringify(appearNote.value));
+				updated.value = true;
+				appearNote.value = updated;
+				break;
+			}
+		}
+	}
+
+	function capture(withHandler = false): void {
+		if (connection) {
+			// TODO: このノートがストリーミング経由で流れてきた場合のみ sr する
+			connection.send(document.body.contains(props.rootEl.value) ? 'sr' : 's', { id: appearNote.value.id });
+			if (withHandler) connection.on('noteUpdated', onStreamNoteUpdated);
+		}
+	}
+
+	function decapture(withHandler = false): void {
+		if (connection) {
+			connection.send('un', {
+				id: appearNote.value.id,
+			});
+			if (withHandler) connection.off('noteUpdated', onStreamNoteUpdated);
+		}
+	}
+
+	function onStreamConnected() {
+		capture(false);
+	}
+	
+	capture(true);
+	if (connection) {
+		connection.on('_connected_', onStreamConnected);
+	}
+	
+	onUnmounted(() => {
+		decapture(true);
+		if (connection) {
+			connection.off('_connected_', onStreamConnected);
+		}
+	});
+}
diff --git a/packages/client/src/store.ts b/packages/client/src/store.ts
index 745d323100..a57e8ec62b 100644
--- a/packages/client/src/store.ts
+++ b/packages/client/src/store.ts
@@ -160,7 +160,7 @@ export const defaultStore = markRaw(new Storage('base', {
 	},
 	useReactionPickerForContextMenu: {
 		where: 'device',
-		default: true
+		default: false
 	},
 	showGapBetweenNotesInTimeline: {
 		where: 'device',
-- 
cgit v1.2.3-freya


From a8fad1b61c554b9e42d310a522f3c2c4e8879892 Mon Sep 17 00:00:00 2001
From: syuilo <Syuilotan@yahoo.co.jp>
Date: Tue, 18 Jan 2022 21:35:57 +0900
Subject: refactor

---
 packages/client/src/components/modal-page-window.vue | 4 ++--
 packages/client/src/components/note-detailed.vue     | 8 ++++----
 packages/client/src/components/note.vue              | 8 ++++----
 packages/client/src/components/ui/window.vue         | 4 ++--
 packages/client/src/ui/classic.side.vue              | 4 ++--
 packages/client/src/ui/classic.vue                   | 8 ++++----
 packages/client/src/ui/deck/column.vue               | 4 ++--
 packages/client/src/ui/deck/main-column.vue          | 8 ++++----
 8 files changed, 24 insertions(+), 24 deletions(-)

(limited to 'packages/client/src/components/note.vue')

diff --git a/packages/client/src/components/modal-page-window.vue b/packages/client/src/components/modal-page-window.vue
index 3de1980820..2e17d5d030 100644
--- a/packages/client/src/components/modal-page-window.vue
+++ b/packages/client/src/components/modal-page-window.vue
@@ -153,8 +153,8 @@ export default defineComponent({
 			this.$refs.window.close();
 		},
 
-		onContextmenu(e) {
-			os.contextMenu(this.contextmenu, e);
+		onContextmenu(ev: MouseEvent) {
+			os.contextMenu(this.contextmenu, ev);
 		}
 	},
 });
diff --git a/packages/client/src/components/note-detailed.vue b/packages/client/src/components/note-detailed.vue
index b33257915d..a3b30f726e 100644
--- a/packages/client/src/components/note-detailed.vue
+++ b/packages/client/src/components/note-detailed.vue
@@ -223,21 +223,21 @@ function undoReact(note): void {
 	});
 }
 
-function onContextmenu(e): void {
+function onContextmenu(ev: MouseEvent): void {
 	const isLink = (el: HTMLElement) => {
 		if (el.tagName === 'A') return true;
 		if (el.parentElement) {
 			return isLink(el.parentElement);
 		}
 	};
-	if (isLink(e.target)) return;
+	if (isLink(ev.target)) return;
 	if (window.getSelection().toString() !== '') return;
 
 	if (defaultStore.state.useReactionPickerForContextMenu) {
-		e.preventDefault();
+		ev.preventDefault();
 		react();
 	} else {
-		os.contextMenu(getNoteMenu({ note: props.note, translating, translation, menuButton }), e).then(focus);
+		os.contextMenu(getNoteMenu({ note: props.note, translating, translation, menuButton }), ev).then(focus);
 	}
 }
 
diff --git a/packages/client/src/components/note.vue b/packages/client/src/components/note.vue
index b309afe051..fc89c2777b 100644
--- a/packages/client/src/components/note.vue
+++ b/packages/client/src/components/note.vue
@@ -211,21 +211,21 @@ function undoReact(note): void {
 	});
 }
 
-function onContextmenu(e): void {
+function onContextmenu(ev: MouseEvent): void {
 	const isLink = (el: HTMLElement) => {
 		if (el.tagName === 'A') return true;
 		if (el.parentElement) {
 			return isLink(el.parentElement);
 		}
 	};
-	if (isLink(e.target)) return;
+	if (isLink(ev.target)) return;
 	if (window.getSelection().toString() !== '') return;
 
 	if (defaultStore.state.useReactionPickerForContextMenu) {
-		e.preventDefault();
+		ev.preventDefault();
 		react();
 	} else {
-		os.contextMenu(getNoteMenu({ note: props.note, translating, translation, menuButton }), e).then(focus);
+		os.contextMenu(getNoteMenu({ note: props.note, translating, translation, menuButton }), ev).then(focus);
 	}
 }
 
diff --git a/packages/client/src/components/ui/window.vue b/packages/client/src/components/ui/window.vue
index bd33289ccc..fa32ecfdef 100644
--- a/packages/client/src/components/ui/window.vue
+++ b/packages/client/src/components/ui/window.vue
@@ -147,9 +147,9 @@ export default defineComponent({
 			}
 		},
 
-		onContextmenu(e) {
+		onContextmenu(ev: MouseEvent) {
 			if (this.contextmenu) {
-				os.contextMenu(this.contextmenu, e);
+				os.contextMenu(this.contextmenu, ev);
 			}
 		},
 
diff --git a/packages/client/src/ui/classic.side.vue b/packages/client/src/ui/classic.side.vue
index ede658626a..f816834141 100644
--- a/packages/client/src/ui/classic.side.vue
+++ b/packages/client/src/ui/classic.side.vue
@@ -72,7 +72,7 @@ export default defineComponent({
 			this.props = {};
 		},
 
-		onContextmenu(e) {
+		onContextmenu(ev: MouseEvent) {
 			os.contextMenu([{
 				type: 'label',
 				text: this.path,
@@ -103,7 +103,7 @@ export default defineComponent({
 				action: () => {
 					copyToClipboard(this.url);
 				}
-			}], e);
+			}], ev);
 		}
 	}
 });
diff --git a/packages/client/src/ui/classic.vue b/packages/client/src/ui/classic.vue
index 41da973152..1603ea6399 100644
--- a/packages/client/src/ui/classic.vue
+++ b/packages/client/src/ui/classic.vue
@@ -167,15 +167,15 @@ export default defineComponent({
 			if (window._scroll) window._scroll();
 		},
 
-		onContextmenu(e) {
+		onContextmenu(ev: MouseEvent) {
 			const isLink = (el: HTMLElement) => {
 				if (el.tagName === 'A') return true;
 				if (el.parentElement) {
 					return isLink(el.parentElement);
 				}
 			};
-			if (isLink(e.target)) return;
-			if (['INPUT', 'TEXTAREA', 'IMG', 'VIDEO', 'CANVAS'].includes(e.target.tagName) || e.target.attributes['contenteditable']) return;
+			if (isLink(ev.target)) return;
+			if (['INPUT', 'TEXTAREA', 'IMG', 'VIDEO', 'CANVAS'].includes(ev.target.tagName) || ev.target.attributes['contenteditable']) return;
 			if (window.getSelection().toString() !== '') return;
 			const path = this.$route.path;
 			os.contextMenu([{
@@ -193,7 +193,7 @@ export default defineComponent({
 				action: () => {
 					os.pageWindow(path);
 				}
-			}], e);
+			}], ev);
 		},
 
 		onAiClick(ev) {
diff --git a/packages/client/src/ui/deck/column.vue b/packages/client/src/ui/deck/column.vue
index 1982d92ad3..f1ce3ca838 100644
--- a/packages/client/src/ui/deck/column.vue
+++ b/packages/client/src/ui/deck/column.vue
@@ -207,8 +207,8 @@ export default defineComponent({
 			return items;
 		},
 
-		onContextmenu(e) {
-			os.contextMenu(this.getMenu(), e);
+		onContextmenu(ev: MouseEvent) {
+			os.contextMenu(this.getMenu(), ev);
 		},
 
 		goTop() {
diff --git a/packages/client/src/ui/deck/main-column.vue b/packages/client/src/ui/deck/main-column.vue
index 744056881c..b77d6f35cc 100644
--- a/packages/client/src/ui/deck/main-column.vue
+++ b/packages/client/src/ui/deck/main-column.vue
@@ -64,15 +64,15 @@ export default defineComponent({
 			history.back();
 		},
 
-		onContextmenu(e) {
+		onContextmenu(ev: MouseEvent) {
 			const isLink = (el: HTMLElement) => {
 				if (el.tagName === 'A') return true;
 				if (el.parentElement) {
 					return isLink(el.parentElement);
 				}
 			};
-			if (isLink(e.target)) return;
-			if (['INPUT', 'TEXTAREA', 'IMG', 'VIDEO', 'CANVAS'].includes(e.target.tagName) || e.target.attributes['contenteditable']) return;
+			if (isLink(ev.target)) return;
+			if (['INPUT', 'TEXTAREA', 'IMG', 'VIDEO', 'CANVAS'].includes(ev.target.tagName) || ev.target.attributes['contenteditable']) return;
 			if (window.getSelection().toString() !== '') return;
 			const path = this.$route.path;
 			os.contextMenu([{
@@ -84,7 +84,7 @@ export default defineComponent({
 				action: () => {
 					os.pageWindow(path);
 				}
-			}], e);
+			}], ev);
 		},
 	}
 });
-- 
cgit v1.2.3-freya

