From 55da1897ae6dc11759c01e59ba3ce18c20c8f892 Mon Sep 17 00:00:00 2001
From: Lhcfl <Lhcfl@outlook.com>
Date: Wed, 22 May 2024 23:47:53 +0800
Subject: [PATCH] refactor: split MkNote into smaller components

---
 packages/client/src/components/MkNote.vue     | 672 ++----------------
 .../client/src/components/MkNoteDetailed.vue  |  10 +-
 .../client/src/components/MkNoteSimple.vue    |   6 +-
 packages/client/src/components/MkNoteSub.vue  | 334 +--------
 .../client/src/components/global/MkError.vue  |   6 +-
 .../MkNoteContent.vue}                        |   5 +-
 .../src/components/note/MkNoteFooter.vue      | 290 ++++++++
 .../src/components/note/MkNoteFooterInfo.vue  |  44 ++
 .../components/{ => note}/MkNoteHeader.vue    |   0
 .../src/components/note/MkNoteHeaderInfo.vue  |  71 ++
 .../src/components/{ => note}/MkNoteMedia.vue |   0
 .../components/{ => note}/MkNoteMediaList.vue |   2 +-
 .../src/components/note/MkNoteTranslation.vue | 110 +++
 .../src/components/note/MkRenoteBar.vue       | 165 +++++
 packages/client/src/pages/user/media-list.vue |   2 +-
 packages/client/src/scripts/get-note-menu.ts  |  43 +-
 .../src/scripts/show-note-context-menu.ts     |  89 +++
 17 files changed, 865 insertions(+), 984 deletions(-)
 rename packages/client/src/components/{MkSubNoteContent.vue => note/MkNoteContent.vue} (98%)
 create mode 100644 packages/client/src/components/note/MkNoteFooter.vue
 create mode 100644 packages/client/src/components/note/MkNoteFooterInfo.vue
 rename packages/client/src/components/{ => note}/MkNoteHeader.vue (100%)
 create mode 100644 packages/client/src/components/note/MkNoteHeaderInfo.vue
 rename packages/client/src/components/{ => note}/MkNoteMedia.vue (100%)
 rename packages/client/src/components/{ => note}/MkNoteMediaList.vue (88%)
 create mode 100644 packages/client/src/components/note/MkNoteTranslation.vue
 create mode 100644 packages/client/src/components/note/MkRenoteBar.vue
 create mode 100644 packages/client/src/scripts/show-note-context-menu.ts

diff --git a/packages/client/src/components/MkNote.vue b/packages/client/src/components/MkNote.vue
index 34b1a79831..0b3012da24 100644
--- a/packages/client/src/components/MkNote.vue
+++ b/packages/client/src/components/MkNote.vue
@@ -10,10 +10,12 @@
 		:aria-label="accessibleLabel"
 		class="tkcbzcuz note-container"
 		:tabindex="!isDeleted ? '-1' : undefined"
-		:class="{ renote: isRenote || (renotesSliced && renotesSliced.length > 0) }"
+		:class="{ renote: isRenote || (renotes && renotes.length > 0) }"
 	>
 		<MkNoteSub
-			v-if="appearNote.reply && !detailedView && !collapsedReply && !parents"
+			v-if="
+				appearNote.reply && !detailedView && !collapsedReply && !parents
+			"
 			:note="appearNote.reply"
 			class="reply-to"
 		/>
@@ -32,106 +34,11 @@
 			}"
 			@click="noteClick"
 		>
-			<div v-if="!collapsedReply" class="line"></div>
-			<div v-if="appearNote._prId_" class="info">
-				<i :class="icon('ph-megaphone-simple-bold')"></i>
-				{{ i18n.ts.promotion
-				}}<button class="_textButton hide" @click.stop="readPromo()">
-					{{ i18n.ts.hideThisNote }}
-					<i :class="icon('ph-x')"></i>
-				</button>
-			</div>
-			<div v-if="appearNote._featuredId_" class="info">
-				<i :class="icon('ph-lightning')"></i>
-				{{ i18n.ts.featured }}
-			</div>
-			<div v-if="pinned" class="info">
-				<i :class="icon('ph-push-pin')"></i>{{ i18n.ts.pinnedNote }}
-			</div>
-			<div v-if="collapsedReply && appearNote.reply" class="info">
-				<MkAvatar class="avatar" :user="appearNote.reply.user" />
-				<MkUserName
-					class="username"
-					:user="appearNote.reply.user"
-				></MkUserName>
-				<Mfm
-					class="summary"
-					:text="getNoteSummary(appearNote.reply)"
-					:plain="true"
-					:nowrap="true"
-					:lang="appearNote.reply.lang"
-					:custom-emojis="note.emojis"
-				/>
-			</div>
-			<div v-if="isRenote || (renotesSliced && renotesSliced.length > 0)" class="renote">
-				<i :class="icon('ph-rocket-launch')"></i>
-				<I18n
-					v-if="renotesSliced == null"
-					:src="i18n.ts.renotedBy"
-					tag="span"
-				>
-					<template #user>
-						<MkAvatar class="avatar" :user="note.user" />
-						<MkA
-							v-user-preview="note.userId"
-							class="name"
-							:to="userPage(note.user)"
-							@click.stop
-						>
-							<MkUserName :user="note.user" />
-						</MkA>
-					</template>
-				</I18n>
-				<I18n
-					v-else
-					:src="i18n.ts.renotedBy"
-					tag="span"
-				>
-					<template #user>
-						<template
-							v-for="(renote, index) in renotesSliced"
-						>
-							<MkAvatar
-								class="avatar"
-								:user="renote.user"
-							/>
-							<MkA
-								v-user-preview="renote.userId"
-								class="name"
-								:to="userPage(renote.user)"
-								@click.stop
-							>
-								<MkUserName :user="renote.user" />
-							</MkA>
-							{{
-								index !== renotesSliced.length - 1
-									? ", "
-									: renotesSliced.length < renotes!.length
-										? "..."
-										: ""
-							}}
-						</template>
-					</template>
-				</I18n>
-				<div class="info">
-					<button
-						ref="renoteTime"
-						class="_button time"
-						@click.stop="showRenoteMenu()"
-					>
-						<i
-							v-if="isMyNote"
-							:class="icon('ph-dots-three-outline dropdownIcon')"
-						></i>
-						<MkTime 
-							v-if="(renotesSliced && renotesSliced.length > 0)"
-							:time="renotesSliced[0].createdAt"
-						/>
-						<MkTime v-else :time="note.createdAt" />
-					</button>
-					<MkVisibility :note="note" />
-				</div>
-			</div>
+			<XNoteHeaderInfo v-bind="{ appearNote, note, collapsedReply, pinned }" />
+			<XRenoteBar
+				v-bind="{ appearNote, note, isRenote, renotes }"
+				@deleted="isDeleted = true"
+			/>
 		</div>
 		<article
 			class="article"
@@ -154,7 +61,7 @@
 					/>
 				</div>
 				<div class="body">
-					<MkSubNoteContent
+					<XNoteContent
 						class="text"
 						:note="appearNote"
 						:detailed="true"
@@ -164,148 +71,22 @@
 						@push="(e) => router.push(notePage(e))"
 						@focusfooter="footerEl!.focus()"
 						@expanded="(e) => setPostExpanded(e)"
-					></MkSubNoteContent>
-					<div v-if="translating || translation" class="translation">
-						<MkLoading v-if="translating" mini />
-						<div v-else-if="translation != null" class="translated">
-							<b
-								>{{
-									i18n.t("translatedFrom", {
-										x: translation.sourceLang,
-									})
-								}}:
-							</b>
-							<Mfm
-								:text="translation.text"
-								:author="appearNote.user"
-								:i="me"
-								:lang="targetLang"
-								:custom-emojis="appearNote.emojis"
-							/>
-						</div>
-					</div>
+					></XNoteContent>
+					<XNoteTranslation ref="noteTranslation" :note="note"/>
 				</div>
-				<div
-					v-if="detailedView || (appearNote.channel && !inChannel)"
-					class="info"
-				>
-					<MkA
-						v-if="detailedView"
-						class="created-at"
-						:to="notePage(appearNote)"
-					>
-						<MkTime v-if="appearNote.scheduledAt != null" :time="appearNote.scheduledAt"/>
-						<MkTime v-else :time="appearNote.createdAt" mode="absolute" />
-					</MkA>
-					<MkA
-						v-if="appearNote.channel && !inChannel"
-						class="channel"
-						:to="`/channels/${appearNote.channel.id}`"
-						@click.stop
-						><i :class="icon('ph-television', false)"></i>
-						{{ appearNote.channel.name }}</MkA
-					>
-				</div>
-				<footer
-					v-show="!hideFooter"
-					ref="footerEl"
+				<XNoteFooterInfo class="info" :note="appearNote" :detailedView />
+				<XNoteFooter
 					class="footer"
-					tabindex="-1"
-				>
-					<XReactionsViewer
-						v-if="enableEmojiReactions && !hideEmojiViewer"
-						ref="reactionsViewer"
-						:note="appearNote"
-					/>
-					<button
-						v-tooltip.noDelay.bottom="i18n.ts.reply"
-						class="button _button"
-						@click.stop="reply()"
-						:disabled="note.scheduledAt != null"
-					>
-						<i :class="icon('ph-arrow-u-up-left')"></i>
-						<template
-							v-if="appearNote.repliesCount > 0 && !detailedView"
-						>
-							<p class="count">{{ appearNote.repliesCount }}</p>
-						</template>
-					</button>
-					<XRenoteButton
-						ref="renoteButton"
-						class="button"
-						:note="appearNote"
-						:count="appearNote.renoteCount"
-						:detailed-view="detailedView"
-						:disabled="note.scheduledAt != null"
-					/>
-					<XStarButtonNoEmoji
-						v-if="!enableEmojiReactions"
-						class="button"
-						:note="appearNote"
-						:count="reactionCount"
-						:reacted="appearNote.myReaction != null"
-						:disabled="note.scheduledAt != null"
-					/>
-					<XStarButton
-						v-if="
-							enableEmojiReactions &&
-							appearNote.myReaction == null
-						"
-						ref="starButton"
-						class="button"
-						:note="appearNote"
-						:disabled="note.scheduledAt != null"
-					/>
-					<button
-						v-if="
-							enableEmojiReactions &&
-							appearNote.myReaction == null
-						"
-						ref="reactButton"
-						v-tooltip.noDelay.bottom="i18n.ts.reaction"
-						class="button _button"
-						@click.stop="react()"
-						:disabled="note.scheduledAt != null"
-					>
-						<i :class="icon('ph-smiley')"></i>
-						<p v-if="reactionCount > 0 && hideEmojiViewer" class="count">{{reactionCount}}</p>
-					</button>
-					<button
-						v-if="
-							enableEmojiReactions &&
-							appearNote.myReaction != null
-						"
-						ref="reactButton"
-						v-tooltip.noDelay.bottom="i18n.ts.removeReaction"
-						class="button _button reacted"
-						@click.stop="undoReact(appearNote)"
-						:disabled="note.scheduledAt != null"
-					>
-						<i :class="icon('ph-minus')"></i>
-						<p v-if="reactionCount > 0 && hideEmojiViewer" class="count">{{reactionCount}}</p>
-					</button>
-					<XQuoteButton class="button" :note="appearNote" :disabled="note.scheduledAt != null"/>
-					<button
-						v-if="
-							isSignedIn(me) &&
-							isForeignLanguage &&
-							translation == null
-						"
-						v-tooltip.noDelay.bottom="i18n.ts.translate"
-						class="button _button"
-						@click.stop="translate"
-					>
-						<i :class="icon('ph-translate')"></i>
-					</button>
-					<button
-						ref="menuButton"
-						v-tooltip.noDelay.bottom="i18n.ts.more"
-						class="button _button"
-						@click.stop="menu()"
-					>
-						<i :class="icon('ph-dots-three-outline')"></i>
-					</button>
-				</footer>
+					ref="footerEl"
+					:note="appearNote"
+					:enableEmojiReactions
+					:hideEmojiViewer
+					:detailedView
+					:note-translation="noteTranslation!"
+					@deleted="isDeleted = true"
+					@event:focus="focus"
+					@event:blur="blur"
+				/>
 			</div>
 		</article>
 	</div>
@@ -333,39 +114,29 @@
 </template>
 
 <script lang="ts" setup>
-import { computed, inject, onMounted, ref, watch } from "vue";
-import type { Ref } from "vue";
+import { computed, onMounted, ref, watch } from "vue";
 import type { entities } from "firefish-js";
-import MkSubNoteContent from "./MkSubNoteContent.vue";
+import XNoteContent from "@/components/note/MkNoteContent.vue";
 import MkNoteSub from "@/components/MkNoteSub.vue";
-import XNoteHeader from "@/components/MkNoteHeader.vue";
-import XRenoteButton from "@/components/MkRenoteButton.vue";
-import XReactionsViewer from "@/components/MkReactionsViewer.vue";
-import XStarButton from "@/components/MkStarButton.vue";
-import XStarButtonNoEmoji from "@/components/MkStarButtonNoEmoji.vue";
-import XQuoteButton from "@/components/MkQuoteButton.vue";
-import MkVisibility from "@/components/MkVisibility.vue";
-import copyToClipboard from "@/scripts/copy-to-clipboard";
-import { detectLanguage } from "@/scripts/language-utils";
-import { url } from "@/config";
-import { pleaseLogin } from "@/scripts/please-login";
+import XNoteHeader from "@/components/note/MkNoteHeader.vue";
 import { focusNext, focusPrev } from "@/scripts/focus";
 import { getWordSoftMute } from "@/scripts/check-word-mute";
 import { useRouter } from "@/router";
 import { userPage } from "@/filters/user";
-import * as os from "@/os";
 import { defaultStore, noteViewInterruptors } from "@/store";
-import { reactionPicker } from "@/scripts/reaction-picker";
-import { isSignedIn, me } from "@/me";
+import { me } from "@/me";
 import { i18n } from "@/i18n";
-import { getNoteMenu } from "@/scripts/get-note-menu";
 import { useNoteCapture } from "@/scripts/use-note-capture";
 import { notePage } from "@/filters/note";
 import { deepClone } from "@/scripts/clone";
-import { getNoteSummary } from "@/scripts/get-note-summary";
-import icon from "@/scripts/icon";
-import type { NoteTranslation, NoteType } from "@/types/note";
+import type { NoteType } from "@/types/note";
 import { isDeleted as _isDeleted, isRenote as _isRenote } from "@/scripts/note";
+import XNoteHeaderInfo from "@/components/note/MkNoteHeaderInfo.vue";
+import XNoteFooterInfo from "@/components/note/MkNoteFooterInfo.vue";
+import XRenoteBar from "@/components/note/MkRenoteBar.vue";
+import XNoteFooter from "./note/MkNoteFooter.vue";
+import XNoteTranslation from "./note/MkNoteTranslation.vue";
+import { showNoteContextMenu } from "@/scripts/show-note-context-menu";
 
 const props = defineProps<{
 	note: NoteType;
@@ -381,42 +152,28 @@ const props = defineProps<{
 
 // #region Constants
 const router = useRouter();
-const inChannel = inject("inChannel", null);
 const keymap = {
-	r: () => reply(true),
-	"e|a|plus": () => react(true),
-	q: () => renoteButton.value!.renote(true),
+	r: () => footerEl.value!.reply(true),
+	"e|a|plus": () => footerEl.value!.react(true),
+	q: () => footerEl.value!.renote(true),
 	"up|k": focusBefore,
 	"down|j": focusAfter,
 	esc: blur,
-	"m|o": () => menu(true),
+	"m|o": () => footerEl.value!.menu(true),
 	// FIXME: What's this?
 	// s: () => showContent.value !== showContent.value,
 };
 const el = ref<HTMLElement | null>(null);
-const footerEl = ref<HTMLElement>();
-const menuButton = ref<HTMLElement>();
-const starButton = ref<InstanceType<typeof XStarButton>>();
-const renoteButton = ref<InstanceType<typeof XRenoteButton> | null>(null);
-const renoteTime = ref<HTMLElement>();
-const reactButton = ref<HTMLElement | null>(null);
+const footerEl = ref<InstanceType<typeof XNoteFooter> | null>(null);
 const enableEmojiReactions = defaultStore.reactiveState.enableEmojiReactions;
 const expandOnNoteClick = defaultStore.reactiveState.expandOnNoteClick;
-const lang = localStorage.getItem("lang");
-const translateLang = localStorage.getItem("translateLang");
-const targetLang = (translateLang || lang || navigator.language)?.slice(0, 2);
-const currentClipPage = inject<Ref<entities.Clip> | null>(
-	"currentClipPage",
-	null,
-);
+const noteTranslation = ref<InstanceType<typeof XNoteTranslation> | null>(null);
 // #endregion
 
 // #region Variables bound to Notes
 let capture: ReturnType<typeof useNoteCapture> | undefined;
 const note = ref(deepClone(props.note));
 const postIsExpanded = ref(false);
-const translation = ref<NoteTranslation | null>(null);
-const translating = ref(false);
 const isDeleted = ref(false);
 const renotes = ref(props.renotes?.filter((rn) => !_isDeleted(rn.id)));
 const muted = ref(
@@ -430,31 +187,10 @@ const muted = ref(
 // #endregion
 
 // #region computed
-
-const renotesSliced = computed(() => renotes.value?.slice(0, 5));
-
 const isRenote = computed(() => _isRenote(note.value));
 const appearNote = computed(() =>
 	isRenote.value ? (note.value.renote as NoteType) : note.value,
 );
-const isMyNote = computed(
-	() => isSignedIn(me) && me.id === note.value.userId && props.renotes == null,
-);
-const isForeignLanguage = computed(
-	() =>
-		defaultStore.state.detectPostLanguage &&
-		appearNote.value.text != null &&
-		(() => {
-			const postLang = detectLanguage(appearNote.value.text);
-			return postLang !== "" && postLang !== targetLang;
-		})(),
-);
-const reactionCount = computed(() =>
-	Object.values(appearNote.value.reactions).reduce(
-		(partialSum, val) => partialSum + val,
-		0,
-	),
-);
 const accessibleLabel = computed(() => {
 	let label = `${appearNote.value.user.username}; `;
 	if (appearNote.value.renote) {
@@ -507,9 +243,6 @@ async function init(newNote: NoteType, first = false) {
 			note.value = deepClone(newNote);
 		}
 	}
-
-	translation.value = null;
-	translating.value = false;
 	postIsExpanded.value = false;
 	isDeleted.value = _isDeleted(note.value.id);
 	if (appearNote.value.historyId == null) {
@@ -574,33 +307,6 @@ watch(
 );
 watch(() => props.renotes?.length, recalculateRenotes);
 
-async function translate_(noteId: string, targetLang: string) {
-	return await os.api("notes/translate", {
-		noteId,
-		targetLang,
-	});
-}
-
-async function translate() {
-	if (translation.value != null) return;
-	translating.value = true;
-	translation.value = await translate_(
-		appearNote.value.id,
-		translateLang || lang || navigator.language,
-	);
-
-	// use UI language as the second translation language
-	if (
-		translateLang != null &&
-		lang != null &&
-		translateLang !== lang &&
-		(!translation.value ||
-			translation.value.sourceLang.toLowerCase() === translateLang.slice(0, 2))
-	)
-		translation.value = await translate_(appearNote.value.id, lang);
-	translating.value = false;
-}
-
 function softMuteReasonI18nSrc(what?: string) {
 	if (what === "note") return i18n.ts.userSaysSomethingReason;
 	if (what === "reply") return i18n.ts.userSaysSomethingReasonReply;
@@ -611,152 +317,12 @@ function softMuteReasonI18nSrc(what?: string) {
 	return i18n.ts.userSaysSomething;
 }
 
-function reply(_viaKeyboard = false): void {
-	pleaseLogin();
-	os.post(
-		{
-			reply: appearNote.value,
-			// animation: !viaKeyboard,
-		},
-		() => {
-			focus();
-		},
-	);
-}
-
-function react(_viaKeyboard = false): void {
-	pleaseLogin();
-	blur();
-	reactionPicker.show(
-		reactButton.value!,
-		(reaction) => {
-			os.api("notes/reactions/create", {
-				noteId: appearNote.value.id,
-				reaction,
-			});
-		},
-		() => {
-			focus();
-		},
-	);
-}
-
-function undoReact(note: NoteType): void {
-	const oldReaction = note.myReaction;
-	if (!oldReaction) return;
-	os.api("notes/reactions/delete", {
-		noteId: note.id,
-	});
-}
-
 function onContextmenu(ev: MouseEvent): void {
-	const isLink = (el: HTMLElement): boolean => {
-		if (el.tagName === "A") return true;
-		// The Audio element's context menu is the browser default, such as for selecting playback speed.
-		if (el.tagName === "AUDIO") return true;
-		if (el.parentElement) {
-			return isLink(el.parentElement);
-		}
-		return false;
-	};
-	if (isLink(ev.target as HTMLElement)) return;
-	if (window.getSelection()?.toString() !== "") return;
-
-	if (defaultStore.state.useReactionPickerForContextMenu) {
-		ev.preventDefault();
-		react();
-	} else {
-		os.contextMenu(
-			[
-				{
-					type: "label",
-					text: notePage(appearNote.value),
-				},
-				{
-					icon: `${icon("ph-browser")}`,
-					text: i18n.ts.openInWindow,
-					action: () => {
-						os.pageWindow(notePage(appearNote.value));
-					},
-				},
-				notePage(appearNote.value) !== location.pathname
-					? {
-							icon: `${icon("ph-arrows-out-simple")}`,
-							text: i18n.ts.showInPage,
-							action: () => {
-								router.push(notePage(appearNote.value), "forcePage");
-							},
-						}
-					: undefined,
-				null,
-				{
-					type: "a",
-					icon: `${icon("ph-arrow-square-out")}`,
-					text: i18n.ts.openInNewTab,
-					href: notePage(appearNote.value),
-					target: "_blank",
-				},
-				{
-					icon: `${icon("ph-link-simple")}`,
-					text: i18n.ts.copyLink,
-					action: () => {
-						copyToClipboard(`${url}${notePage(appearNote.value)}`);
-						os.success();
-					},
-				},
-				appearNote.value.user.host != null
-					? {
-							type: "a",
-							icon: `${icon("ph-arrow-square-up-right")}`,
-							text: i18n.ts.showOnRemote,
-							href: appearNote.value.url ?? appearNote.value.uri ?? "",
-							target: "_blank",
-						}
-					: undefined,
-			],
-			ev,
-		);
-	}
-}
-
-function menu(viaKeyboard = false): void {
-	os.popupMenu(
-		getNoteMenu({
-			note: note.value,
-			translating,
-			translation,
-			menuButton,
-			isDeleted,
-			currentClipPage,
-		}),
-		menuButton.value,
-		{
-			viaKeyboard,
-		},
-	).then(focus);
-}
-
-function showRenoteMenu(viaKeyboard = false): void {
-	if (!isMyNote.value) return;
-	os.popupMenu(
-		[
-			{
-				text: i18n.ts.unrenote,
-				icon: `${icon("ph-trash")}`,
-				danger: true,
-				action: () => {
-					os.api("notes/delete", {
-						noteId: note.value.id,
-					});
-					isDeleted.value = true;
-				},
-			},
-		],
-		renoteTime.value,
-		{
-			viaKeyboard,
-		},
-	);
+	showNoteContextMenu({
+		ev,
+		note: appearNote.value,
+		react: footerEl.value!.react,
+	});
 }
 
 function focus() {
@@ -791,13 +357,6 @@ function noteClick(e) {
 	}
 }
 
-function readPromo() {
-	os.api("promo/read", {
-		noteId: appearNote.value.id,
-	});
-	isDeleted.value = true;
-}
-
 function setPostExpanded(val: boolean) {
 	postIsExpanded.value = val;
 }
@@ -900,71 +459,6 @@ defineExpose({
 		> div > i {
 			margin-left: -0.5px;
 		}
-		> .info {
-			display: flex;
-			align-items: center;
-			font-size: 90%;
-			white-space: pre;
-			color: #f6c177;
-
-			> i {
-				margin-right: 4px;
-			}
-
-			> .hide {
-				margin-left: auto;
-				color: inherit;
-			}
-		}
-
-		> .renote {
-			display: flex;
-			align-items: center;
-			white-space: pre;
-			color: var(--renote);
-			cursor: pointer;
-
-			> i {
-				margin-right: 4px;
-			}
-
-			.avatar {
-				width: 1.2em;
-				height: 1.2em;
-				border-radius: 2em;
-				overflow: hidden;
-				margin-right: 0.4em;
-				background: var(--panelHighlight);
-				transform: translateY(-4px);
-			}
-
-			> span {
-				overflow: hidden;
-				flex-shrink: 1;
-				text-overflow: ellipsis;
-				white-space: nowrap;
-
-				> .name {
-					font-weight: bold;
-				}
-			}
-
-			> .info {
-				margin-left: auto;
-				font-size: 0.9em;
-				display: flex;
-
-				> .time {
-					flex-shrink: 0;
-					color: inherit;
-					display: inline-flex;
-					align-items: center;
-					> .dropdownIcon {
-						margin-right: 4px;
-					}
-				}
-			}
-		}
 
 		&.collapsedReply {
 			.line {
@@ -1054,12 +548,6 @@ defineExpose({
 
 			> .body {
 				margin-top: 0.7em;
-				> .translation {
-					border: solid 0.5px var(--divider);
-					border-radius: var(--radius);
-					padding: 12px;
-					margin-top: 8px;
-				}
 				> .renote {
 					padding-top: 8px;
 					> * {
@@ -1074,74 +562,6 @@ defineExpose({
 					}
 				}
 			}
-			> .info {
-				display: flex;
-				justify-content: space-between;
-				flex-wrap: wrap;
-				gap: 0.7em;
-				margin-top: 16px;
-				opacity: 0.7;
-				font-size: 0.9em;
-			}
-			> .footer {
-				position: relative;
-				z-index: 2;
-				display: flex;
-				flex-wrap: wrap;
-				margin-top: 0.4em;
-				> :deep(.button) {
-					position: relative;
-					margin: 0;
-					padding: 8px;
-					opacity: 0.7;
-					&:disabled {
-						opacity: 0.3 !important;
-					}
-					flex-grow: 1;
-					max-width: 3.5em;
-					width: max-content;
-					min-width: max-content;
-					height: auto;
-					transition: opacity 0.2s;
-					&::before {
-						content: "";
-						position: absolute;
-						inset: 0;
-						bottom: 2px;
-						background: var(--panel);
-						z-index: -1;
-						transition: background 0.2s;
-					}
-					&:first-of-type {
-						margin-left: -0.5em;
-						&::before {
-							border-radius: 100px 0 0 100px;
-						}
-					}
-					&:last-of-type {
-						&::before {
-							border-radius: 0 100px 100px 0;
-						}
-					}
-					&:hover {
-						color: var(--fgHighlighted);
-					}
-
-					> i {
-						display: inline !important;
-					}
-
-					> .count {
-						display: inline;
-						margin: 0 0 0 8px;
-						opacity: 0.7;
-					}
-
-					&.reacted {
-						color: var(--accent);
-					}
-				}
-			}
 		}
 	}
 
diff --git a/packages/client/src/components/MkNoteDetailed.vue b/packages/client/src/components/MkNoteDetailed.vue
index 7dcb055d47..c8092d3de4 100644
--- a/packages/client/src/components/MkNoteDetailed.vue
+++ b/packages/client/src/components/MkNoteDetailed.vue
@@ -238,7 +238,7 @@ const repliesPagingComponent = ref<MkPaginationType<"notes/replies"> | null>(
 );
 
 const el = ref<HTMLElement | null>(null);
-const noteEl = ref();
+const noteEl = ref<InstanceType<typeof MkNote> | null>(null);
 const menuButton = ref<HTMLElement>();
 const renoteButton = ref<InstanceType<typeof XRenoteButton>>();
 const reactButton = ref<HTMLElement>();
@@ -361,11 +361,11 @@ function menu(viaKeyboard = false): void {
 }
 
 function focus() {
-	noteEl.value.focus();
+	noteEl.value?.focus();
 }
 
 function blur() {
-	noteEl.value.blur();
+	noteEl.value?.blur();
 }
 
 conversation.value = null;
@@ -418,12 +418,12 @@ document.addEventListener("wheel", () => {
 
 onMounted(() => {
 	isScrolling = false;
-	noteEl.value.scrollIntoView();
+	noteEl.value?.scrollIntoView();
 });
 
 onUpdated(() => {
 	if (!isScrolling) {
-		noteEl.value.scrollIntoView();
+		noteEl.value?.scrollIntoView();
 		if (location.hash) {
 			location.replace(location.hash); // Jump to highlighted reply
 		}
diff --git a/packages/client/src/components/MkNoteSimple.vue b/packages/client/src/components/MkNoteSimple.vue
index 92d8a77696..97c3a973c4 100644
--- a/packages/client/src/components/MkNoteSimple.vue
+++ b/packages/client/src/components/MkNoteSimple.vue
@@ -9,7 +9,7 @@
 		<div class="main">
 			<XNoteHeader class="header" :note="note" :mini="true" />
 			<div class="body">
-				<MkSubNoteContent class="text" :note="note" />
+				<XNoteContent class="text" :note="note" />
 			</div>
 		</div>
 	</div>
@@ -18,8 +18,8 @@
 <script lang="ts" setup>
 import type { entities } from "firefish-js";
 import { computed, ref, watch } from "vue";
-import XNoteHeader from "@/components/MkNoteHeader.vue";
-import MkSubNoteContent from "@/components/MkSubNoteContent.vue";
+import XNoteHeader from "@/components/note/MkNoteHeader.vue";
+import XNoteContent from "@/components/note/MkNoteContent.vue";
 import { deepClone } from "@/scripts/clone";
 import { useNoteCapture } from "@/scripts/use-note-capture";
 import { isDeleted } from "@/scripts/note";
diff --git a/packages/client/src/components/MkNoteSub.vue b/packages/client/src/components/MkNoteSub.vue
index f3448d73e3..b2886fbb6c 100644
--- a/packages/client/src/components/MkNoteSub.vue
+++ b/packages/client/src/components/MkNoteSub.vue
@@ -30,7 +30,7 @@
 			<div class="body">
 				<XNoteHeader class="header" :note="note" :mini="true" />
 				<div class="body">
-					<MkSubNoteContent
+					<XNoteContent
 						class="text"
 						:note="note"
 						:parent-id="parentId"
@@ -38,112 +38,20 @@
 						:detailed-view="detailedView"
 						@focusfooter="footerEl!.focus()"
 					/>
-					<div v-if="translating || translation" class="translation">
-						<MkLoading v-if="translating || translation == null" mini />
-						<div v-else class="translated">
-							<b
-								>{{
-									i18n.t("translatedFrom", {
-										x: translation.sourceLang,
-									})
-								}}:
-							</b>
-							<Mfm
-								:text="translation.text"
-								:author="appearNote.user"
-								:i="me"
-								:lang="targetLang"
-								:custom-emojis="appearNote.emojis"
-							/>
-						</div>
-					</div>
+					<XNoteTranslation ref="noteTranslation" :note="note"/>
 				</div>
-				<footer ref="footerEl" class="footer" tabindex="-1">
-					<XReactionsViewer
-						v-if="enableEmojiReactions && !hideEmojiViewer"
-						ref="reactionsViewer"
-						:note="appearNote"
-					/>
-					<button
-						v-tooltip.noDelay.bottom="i18n.ts.reply"
-						class="button _button"
-						@click.stop="reply()"
-					>
-						<i :class="icon('ph-arrow-u-up-left')"></i>
-						<template v-if="appearNote.repliesCount > 0">
-							<p class="count">{{ appearNote.repliesCount }}</p>
-						</template>
-					</button>
-					<XRenoteButton
-						ref="renoteButton"
-						class="button"
-						:note="appearNote"
-						:count="appearNote.renoteCount"
-					/>
-					<XStarButtonNoEmoji
-						v-if="!enableEmojiReactions"
-						class="button"
-						:note="appearNote"
-						:count="reactionCount"
-						:reacted="appearNote.myReaction != null"
-					/>
-					<XStarButton
-						v-if="
-							enableEmojiReactions &&
-							appearNote.myReaction == null
-						"
-						ref="starButton"
-						class="button"
-						:note="appearNote"
-					/>
-					<button
-						v-if="
-							enableEmojiReactions &&
-							appearNote.myReaction == null
-						"
-						ref="reactButton"
-						v-tooltip.noDelay.bottom="i18n.ts.reaction"
-						class="button _button"
-						@click.stop="react()"
-					>
-						<i :class="icon('ph-smiley')"></i>
-						<p v-if="reactionCount > 0 && hideEmojiViewer" class="count">{{reactionCount}}</p>
-					</button>
-					<button
-						v-if="
-							enableEmojiReactions &&
-							appearNote.myReaction != null
-						"
-						ref="reactButton"
-						v-tooltip.noDelay.bottom="i18n.ts.removeReaction"
-						class="button _button reacted"
-						@click.stop="undoReact(appearNote)"
-					>
-						<i :class="icon('ph-minus')"></i>
-						<p v-if="reactionCount > 0 && hideEmojiViewer" class="count">{{reactionCount}}</p>
-					</button>
-					<XQuoteButton class="button" :note="appearNote" />
-					<button
-						v-if="
-							isSignedIn(me) &&
-							isForeignLanguage &&
-							translation == null
-						"
-						v-tooltip.noDelay.bottom="i18n.ts.translate"
-						class="button _button"
-						@click.stop="translate"
-					>
-						<i :class="icon('ph-translate')"></i>
-					</button>
-					<button
-						ref="menuButton"
-						v-tooltip.noDelay.bottom="i18n.ts.more"
-						class="button _button"
-						@click.stop="menu()"
-					>
-						<i :class="icon('ph-dots-three-outline')"></i>
-					</button>
-				</footer>
+				<XNoteFooter
+					class="footer"
+					ref="footerEl"
+					:note="appearNote"
+					:enableEmojiReactions
+					:hideEmojiViewer
+					:detailedView
+					:note-translation="noteTranslation!"
+					@deleted="isDeleted = true"
+					@event:focus="focus"
+					@event:blur="blur"
+				/>
 			</div>
 		</div>
 		<MkLoading v-if="conversationLoading" />
@@ -200,34 +108,24 @@
 </template>
 
 <script lang="ts" setup>
-import { computed, inject, ref, watch } from "vue";
-import type { Ref } from "vue";
+import { computed, ref, watch } from "vue";
 import type { entities } from "firefish-js";
-import XNoteHeader from "@/components/MkNoteHeader.vue";
-import MkSubNoteContent from "@/components/MkSubNoteContent.vue";
-import XReactionsViewer from "@/components/MkReactionsViewer.vue";
-import XStarButton from "@/components/MkStarButton.vue";
-import XStarButtonNoEmoji from "@/components/MkStarButtonNoEmoji.vue";
-import XRenoteButton from "@/components/MkRenoteButton.vue";
-import XQuoteButton from "@/components/MkQuoteButton.vue";
-import copyToClipboard from "@/scripts/copy-to-clipboard";
-import { detectLanguage } from "@/scripts/language-utils";
-import { url } from "@/config";
-import { pleaseLogin } from "@/scripts/please-login";
-import { getNoteMenu } from "@/scripts/get-note-menu";
+import XNoteHeader from "@/components/note/MkNoteHeader.vue";
+import XNoteContent from "@/components/note/MkNoteContent.vue";
 import { getWordSoftMute } from "@/scripts/check-word-mute";
 import { notePage } from "@/filters/note";
 import { useRouter } from "@/router";
 import { userPage } from "@/filters/user";
 import * as os from "@/os";
-import { reactionPicker } from "@/scripts/reaction-picker";
-import { isSignedIn, me } from "@/me";
+import { me } from "@/me";
 import { i18n } from "@/i18n";
 import { useNoteCapture } from "@/scripts/use-note-capture";
 import { defaultStore } from "@/store";
 import { deepClone } from "@/scripts/clone";
 import icon from "@/scripts/icon";
-import type { NoteTranslation } from "@/types/note";
+import XNoteFooter from "./note/MkNoteFooter.vue";
+import XNoteTranslation from "./note/MkNoteTranslation.vue";
+import { showNoteContextMenu } from "@/scripts/show-note-context-menu";
 
 const router = useRouter();
 
@@ -312,11 +210,8 @@ const isRenote =
 	note.value.poll == null;
 
 const el = ref<HTMLElement | null>(null);
-const footerEl = ref<HTMLElement | null>(null);
-const menuButton = ref<HTMLElement>();
-const starButton = ref<InstanceType<typeof XStarButton> | null>(null);
-const renoteButton = ref<InstanceType<typeof XRenoteButton> | null>(null);
-const reactButton = ref<HTMLElement | null>(null);
+const noteTranslation = ref<InstanceType<typeof XNoteTranslation> | null>(null);
+const footerEl = ref<InstanceType<typeof XNoteFooter> | null>(null);
 const appearNote = computed(() =>
 	isRenote ? (note.value.renote as entities.Note) : note.value,
 );
@@ -329,55 +224,8 @@ const muted = ref(
 		defaultStore.state.mutedLangs,
 	),
 );
-const translation = ref<NoteTranslation | null>(null);
-const translating = ref(false);
 const enableEmojiReactions = defaultStore.state.enableEmojiReactions;
 const expandOnNoteClick = defaultStore.state.expandOnNoteClick;
-const lang = localStorage.getItem("lang");
-const translateLang = localStorage.getItem("translateLang");
-const targetLang = (translateLang || lang || navigator.language)?.slice(0, 2);
-
-const reactionCount = computed(() =>
-	Object.values(appearNote.value.reactions).reduce(
-		(partialSum, val) => partialSum + val,
-		0,
-	),
-);
-
-const isForeignLanguage: boolean =
-	defaultStore.state.detectPostLanguage &&
-	appearNote.value.text != null &&
-	(() => {
-		const postLang = detectLanguage(appearNote.value.text);
-		return postLang !== "" && postLang !== targetLang;
-	})();
-
-async function translate_(noteId, targetLang: string) {
-	return await os.api("notes/translate", {
-		noteId,
-		targetLang,
-	});
-}
-
-async function translate() {
-	if (translation.value != null) return;
-	translating.value = true;
-	translation.value = await translate_(
-		appearNote.value.id,
-		translateLang || lang || navigator.language,
-	);
-
-	// use UI language as the second translation language
-	if (
-		translateLang != null &&
-		lang != null &&
-		translateLang !== lang &&
-		(!translation.value ||
-			translation.value.sourceLang.toLowerCase() === translateLang.slice(0, 2))
-	)
-		translation.value = await translate_(appearNote.value.id, lang);
-	translating.value = false;
-}
 
 useNoteCapture({
 	rootEl: el,
@@ -393,129 +241,12 @@ useNoteCapture({
 	},
 });
 
-function reply(_viaKeyboard = false): void {
-	pleaseLogin();
-	os.post({
-		reply: appearNote.value,
-		// animation: !viaKeyboard,
-	}).then(() => {
-		focus();
-	});
-}
-
-function react(_viaKeyboard = false): void {
-	pleaseLogin();
-	blur();
-	reactionPicker.show(
-		reactButton.value!,
-		(reaction) => {
-			os.api("notes/reactions/create", {
-				noteId: appearNote.value.id,
-				reaction,
-			});
-		},
-		() => {
-			focus();
-		},
-	);
-}
-
-function undoReact(note): void {
-	const oldReaction = note.myReaction;
-	if (!oldReaction) return;
-	os.api("notes/reactions/delete", {
-		noteId: note.id,
-	});
-}
-
-const currentClipPage = inject<Ref<entities.Clip> | null>(
-	"currentClipPage",
-	null,
-);
-
-function menu(viaKeyboard = false): void {
-	os.popupMenu(
-		getNoteMenu({
-			note: note.value,
-			translating,
-			translation,
-			menuButton,
-			isDeleted,
-			currentClipPage,
-		}),
-		menuButton.value,
-		{
-			viaKeyboard,
-		},
-	).then(focus);
-}
-
 function onContextmenu(ev: MouseEvent): void {
-	const isLink = (el: HTMLElement | null) => {
-		if (el == null) return;
-		if (el.tagName === "A") return true;
-		if (el.parentElement) {
-			return isLink(el.parentElement);
-		}
-	};
-	if (isLink(ev.target as HTMLElement | null)) return;
-	if (window.getSelection()?.toString() !== "") return;
-
-	if (defaultStore.state.useReactionPickerForContextMenu) {
-		ev.preventDefault();
-		react();
-	} else {
-		os.contextMenu(
-			[
-				{
-					type: "label",
-					text: notePage(appearNote.value),
-				},
-				{
-					icon: `${icon("ph-browser")}`,
-					text: i18n.ts.openInWindow,
-					action: () => {
-						os.pageWindow(notePage(appearNote.value));
-					},
-				},
-				notePage(appearNote.value) !== location.pathname
-					? {
-							icon: `${icon("ph-arrows-out-simple")}`,
-							text: i18n.ts.showInPage,
-							action: () => {
-								router.push(notePage(appearNote.value), "forcePage");
-							},
-						}
-					: undefined,
-				null,
-				{
-					type: "a",
-					icon: `${icon("ph-arrow-square-out")}`,
-					text: i18n.ts.openInNewTab,
-					href: notePage(appearNote.value),
-					target: "_blank",
-				},
-				{
-					icon: `${icon("ph-link-simple")}`,
-					text: i18n.ts.copyLink,
-					action: () => {
-						copyToClipboard(`${url}${notePage(appearNote.value)}`);
-						os.success();
-					},
-				},
-				note.value.user.host != null
-					? {
-							type: "a",
-							icon: `${icon("ph-arrow-square-up-right")}`,
-							text: i18n.ts.showOnRemote,
-							href: note.value.url ?? note.value.uri ?? "",
-							target: "_blank",
-						}
-					: undefined,
-			],
-			ev,
-		);
-	}
+	showNoteContextMenu({
+		ev,
+		note: appearNote.value,
+		react: footerEl.value!.react,
+	});
 }
 
 function focus() {
@@ -580,15 +311,6 @@ function noteClick(e: MouseEvent) {
 				margin-bottom: 2px;
 				cursor: auto;
 			}
-
-			> .body {
-				> .translation {
-					border: solid 0.5px var(--divider);
-					border-radius: var(--radius);
-					padding: 12px;
-					margin-top: 8px;
-				}
-			}
 			> .footer {
 				position: relative;
 				z-index: 2;
diff --git a/packages/client/src/components/global/MkError.vue b/packages/client/src/components/global/MkError.vue
index 7472542ee1..1fb76f2d06 100644
--- a/packages/client/src/components/global/MkError.vue
+++ b/packages/client/src/components/global/MkError.vue
@@ -10,9 +10,9 @@
 				<i :class="icon('ph-warning')"></i>
 				{{ i18n.ts.somethingHappened }}
 			</p>
-			<MkButton class="button" @click="() => $emit('retry')">{{
-				i18n.ts.retry
-			}}</MkButton>
+			<MkButton class="button" @click.stop="() => $emit('retry')">
+				{{ i18n.ts.retry }}
+			</MkButton>
 		</div>
 	</transition>
 </template>
diff --git a/packages/client/src/components/MkSubNoteContent.vue b/packages/client/src/components/note/MkNoteContent.vue
similarity index 98%
rename from packages/client/src/components/MkSubNoteContent.vue
rename to packages/client/src/components/note/MkNoteContent.vue
index 5860d27fff..358c365ec3 100644
--- a/packages/client/src/components/MkSubNoteContent.vue
+++ b/packages/client/src/components/note/MkNoteContent.vue
@@ -66,7 +66,7 @@
 					tabindex: !showContent ? '-1' : undefined,
 				}"
 			>
-				<span v-if="note.deletedAt" style="opacity: 0.5"
+				<span v-if="deleted" style="opacity: 0.5"
 					>({{ i18n.ts.deleted }})</span
 				>
 				<template v-if="!note.cw">
@@ -195,6 +195,7 @@ import { extractMfmWithAnimation } from "@/scripts/extract-mfm";
 import { i18n } from "@/i18n";
 import { defaultStore } from "@/store";
 import icon from "@/scripts/icon";
+import { isDeleted } from "@/scripts/note";
 
 const props = withDefaults(
 	defineProps<{
@@ -242,6 +243,8 @@ const mfms = computed(() =>
 );
 const hasMfm = computed(() => mfms.value && mfms.value.length > 0);
 
+const deleted = computed(() => isDeleted(props.note.id));
+
 const disableMfm = ref(defaultStore.state.animatedMfm);
 const showContent = ref(false);
 const collapsed = ref(props.note.cw == null && isLong.value);
diff --git a/packages/client/src/components/note/MkNoteFooter.vue b/packages/client/src/components/note/MkNoteFooter.vue
new file mode 100644
index 0000000000..aff161ff47
--- /dev/null
+++ b/packages/client/src/components/note/MkNoteFooter.vue
@@ -0,0 +1,290 @@
+<template>
+	<footer class="footer" ref="el" tabindex="-1">
+		<XReactionsViewer
+			v-if="enableEmojiReactions && !hideEmojiViewer"
+			ref="reactionsViewer"
+			:note="note"
+		/>
+		<button
+			v-tooltip.noDelay.bottom="i18n.ts.reply"
+			class="button _button"
+			@click.stop="reply()"
+			:disabled="note.scheduledAt != null"
+		>
+			<i :class="icon('ph-arrow-u-up-left')"></i>
+			<template v-if="note.repliesCount > 0 && !detailedView">
+				<p class="count">{{ note.repliesCount }}</p>
+			</template>
+		</button>
+		<XRenoteButton
+			ref="renoteButton"
+			class="button"
+			:note="note"
+			:count="note.renoteCount"
+			:detailed-view="detailedView"
+			:disabled="note.scheduledAt != null"
+		/>
+		<XStarButtonNoEmoji
+			v-if="!enableEmojiReactions"
+			class="button"
+			:note="note"
+			:count="reactionCount"
+			:reacted="note.myReaction != null"
+			:disabled="note.scheduledAt != null"
+		/>
+		<XStarButton
+			v-if="enableEmojiReactions && note.myReaction == null"
+			ref="starButton"
+			class="button"
+			:note="note"
+			:disabled="note.scheduledAt != null"
+		/>
+		<button
+			v-if="enableEmojiReactions && note.myReaction == null"
+			ref="reactButton"
+			v-tooltip.noDelay.bottom="i18n.ts.reaction"
+			class="button _button"
+			@click.stop="react()"
+			:disabled="note.scheduledAt != null"
+		>
+			<i :class="icon('ph-smiley')"></i>
+			<p v-if="reactionCount > 0 && hideEmojiViewer" class="count">
+				{{ reactionCount }}
+			</p>
+		</button>
+		<button
+			v-if="enableEmojiReactions && note.myReaction != null"
+			ref="reactButton"
+			v-tooltip.noDelay.bottom="i18n.ts.removeReaction"
+			class="button _button reacted"
+			@click.stop="undoReact(note)"
+			:disabled="note.scheduledAt != null"
+		>
+			<i :class="icon('ph-minus')"></i>
+			<p v-if="reactionCount > 0 && hideEmojiViewer" class="count">
+				{{ reactionCount }}
+			</p>
+		</button>
+		<XQuoteButton
+			class="button"
+			:note="note"
+			:disabled="note.scheduledAt != null"
+		/>
+		<button
+			v-if="
+				isSignedIn(me) &&
+				isForeignLanguage &&
+				noteTranslation.canTranslate
+			"
+			v-tooltip.noDelay.bottom="i18n.ts.translate"
+			class="button _button"
+			@click.stop="noteTranslation.translate"
+		>
+			<i :class="icon('ph-translate')"></i>
+		</button>
+		<button
+			ref="menuButton"
+			v-tooltip.noDelay.bottom="i18n.ts.more"
+			class="button _button"
+			@click.stop="menu()"
+		>
+			<i :class="icon('ph-dots-three-outline')"></i>
+		</button>
+	</footer>
+</template>
+
+<script lang="ts" setup>
+import { i18n } from "@/i18n";
+import { isSignedIn, me } from "@/me";
+import icon from "@/scripts/icon";
+import type { NoteType } from "@/types/note";
+import { type Ref, computed, inject, ref, watch } from "vue";
+import XReactionsViewer from "@/components/MkReactionsViewer.vue";
+import XStarButton from "@/components/MkStarButton.vue";
+import XStarButtonNoEmoji from "@/components/MkStarButtonNoEmoji.vue";
+import XQuoteButton from "@/components/MkQuoteButton.vue";
+import XRenoteButton from "@/components/MkRenoteButton.vue";
+import * as os from "@/os";
+import { pleaseLogin } from "@/scripts/please-login";
+import { reactionPicker } from "@/scripts/reaction-picker";
+import { getNoteMenu } from "@/scripts/get-note-menu";
+import { defaultStore } from "@/store";
+import { detectLanguage } from "@/scripts/language-utils";
+import type { entities } from "firefish-js";
+import type MkNoteTranslation from "./MkNoteTranslation.vue";
+
+const props = defineProps<{
+	note: NoteType;
+	enableEmojiReactions?: boolean;
+	hideEmojiViewer?: boolean;
+	detailedView?: boolean;
+	noteTranslation: InstanceType<typeof MkNoteTranslation>;
+}>();
+
+const emit = defineEmits<{
+	"event:focus": [];
+	"event:blur": [];
+	deleted: [];
+}>();
+
+const el = ref<HTMLElement | null>(null);
+const starButton = ref<InstanceType<typeof XStarButton>>();
+const renoteButton = ref<InstanceType<typeof XRenoteButton> | null>(null);
+const reactButton = ref<HTMLElement | null>(null);
+const menuButton = ref<HTMLElement>();
+
+const reactionCount = computed(() =>
+	Object.values(props.note.reactions).reduce(
+		(partialSum, val) => partialSum + val,
+		0,
+	),
+);
+
+const currentClipPage = inject<Ref<entities.Clip> | null>(
+	"currentClipPage",
+	null,
+);
+
+const isForeignLanguage = computed(
+	() =>
+		defaultStore.state.detectPostLanguage &&
+		props.note.text != null &&
+		(() => {
+			const postLang = detectLanguage(props.note.text);
+			return postLang !== "" && postLang !== props.noteTranslation.targetLang;
+		})(),
+);
+
+function focus() {
+	emit("event:focus");
+}
+
+function reply(_viaKeyboard = false): void {
+	pleaseLogin();
+	os.post(
+		{
+			reply: props.note,
+			// animation: !viaKeyboard,
+		},
+		() => {
+			focus();
+		},
+	);
+}
+
+function react(_viaKeyboard = false): void {
+	pleaseLogin();
+	emit("event:blur");
+	reactionPicker.show(
+		reactButton.value!,
+		(reaction) => {
+			os.api("notes/reactions/create", {
+				noteId: props.note.id,
+				reaction,
+			});
+		},
+		() => {
+			focus();
+		},
+	);
+}
+
+function undoReact(note: NoteType): void {
+	const oldReaction = note.myReaction;
+	if (!oldReaction) return;
+	os.api("notes/reactions/delete", {
+		noteId: note.id,
+	});
+}
+
+function menu(viaKeyboard = false): void {
+	const isDeleted = ref(false);
+	watch(isDeleted, (v) => {
+		if (v === true) emit("deleted");
+	});
+	os.popupMenu(
+		getNoteMenu({
+			note: props.note,
+			menuButton,
+			isDeleted,
+			currentClipPage,
+			translationEl: props.noteTranslation,
+		}),
+		menuButton.value,
+		{
+			viaKeyboard,
+		},
+	).then(focus);
+}
+
+defineExpose({
+	reply,
+	react,
+	undoReact,
+	menu,
+	renote: (viaKeyboard: boolean) => renoteButton.value!.renote(viaKeyboard),
+	focus: () => el.value?.focus(),
+});
+</script>
+
+<style lang="scss" scoped>
+.footer {
+	position: relative;
+	z-index: 2;
+	display: flex;
+	flex-wrap: wrap;
+	margin-top: 0.4em;
+	> :deep(.button) {
+		position: relative;
+		margin: 0;
+		padding: 8px;
+		opacity: 0.7;
+		&:disabled {
+			opacity: 0.3 !important;
+		}
+		flex-grow: 1;
+		max-width: 3.5em;
+		width: max-content;
+		min-width: max-content;
+		height: auto;
+		transition: opacity 0.2s;
+		&::before {
+			content: "";
+			position: absolute;
+			inset: 0;
+			bottom: 2px;
+			background: var(--panel);
+			z-index: -1;
+			transition: background 0.2s;
+		}
+		&:first-of-type {
+			margin-left: -0.5em;
+			&::before {
+				border-radius: 100px 0 0 100px;
+			}
+		}
+		&:last-of-type {
+			&::before {
+				border-radius: 0 100px 100px 0;
+			}
+		}
+		&:hover {
+			color: var(--fgHighlighted);
+		}
+
+		> i {
+			display: inline !important;
+		}
+
+		> .count {
+			display: inline;
+			margin: 0 0 0 8px;
+			opacity: 0.7;
+		}
+
+		&.reacted {
+			color: var(--accent);
+		}
+	}
+}
+</style>
diff --git a/packages/client/src/components/note/MkNoteFooterInfo.vue b/packages/client/src/components/note/MkNoteFooterInfo.vue
new file mode 100644
index 0000000000..1e62053657
--- /dev/null
+++ b/packages/client/src/components/note/MkNoteFooterInfo.vue
@@ -0,0 +1,44 @@
+<template>
+	<div v-if="detailedView || (note.channel && !inChannel)" class="footer-info">
+		<MkA v-if="detailedView" class="created-at" :to="notePage(note)">
+			<MkTime
+				v-if="note.scheduledAt != null"
+				:time="note.scheduledAt"
+			/>
+			<MkTime v-else :time="note.createdAt" mode="absolute" />
+		</MkA>
+		<MkA
+			v-if="note.channel && !inChannel"
+			class="channel"
+			:to="`/channels/${note.channel.id}`"
+			@click.stop
+			><i :class="icon('ph-television', false)"></i>
+			{{ note.channel.name }}</MkA
+		>
+	</div>
+</template>
+
+<script lang="ts" setup>
+import { notePage } from "@/filters/note";
+import icon from "@/scripts/icon";
+import type { NoteType } from "@/types/note";
+import { inject } from "vue";
+
+defineProps<{
+	note: NoteType;
+	detailedView?: boolean;
+}>();
+const inChannel = inject("inChannel", null);
+</script>
+
+<style lang="scss" scoped>
+.footer-info {
+	display: flex;
+	justify-content: space-between;
+	flex-wrap: wrap;
+	gap: 0.7em;
+	margin-top: 16px;
+	opacity: 0.7;
+	font-size: 0.9em;
+}
+</style>
diff --git a/packages/client/src/components/MkNoteHeader.vue b/packages/client/src/components/note/MkNoteHeader.vue
similarity index 100%
rename from packages/client/src/components/MkNoteHeader.vue
rename to packages/client/src/components/note/MkNoteHeader.vue
diff --git a/packages/client/src/components/note/MkNoteHeaderInfo.vue b/packages/client/src/components/note/MkNoteHeaderInfo.vue
new file mode 100644
index 0000000000..bbd7120aaf
--- /dev/null
+++ b/packages/client/src/components/note/MkNoteHeaderInfo.vue
@@ -0,0 +1,71 @@
+<template>
+	<!-- _prId_ and _featuredId_ is not used now -->
+	<!-- TODO: remove them -->
+	<!-- <div v-if="appearNote._prId_" class="info">
+		<i :class="icon('ph-megaphone-simple-bold')"></i>
+		{{ i18n.ts.promotion
+		}}<button class="_textButton hide" @click.stop="readPromo()">
+			{{ i18n.ts.hideThisNote }}
+			<i :class="icon('ph-x')"></i>
+		</button>
+	</div>
+	<div v-if="appearNote._featuredId_" class="info">
+		<i :class="icon('ph-lightning')"></i>
+		{{ i18n.ts.featured }}
+	</div> -->
+	<div v-if="pinned" class="info">
+		<i :class="icon('ph-push-pin')"></i>{{ i18n.ts.pinnedNote }}
+	</div>
+	<div v-if="collapsedReply && appearNote.reply" class="info">
+		<MkAvatar class="avatar" :user="appearNote.reply.user" />
+		<MkUserName class="username" :user="appearNote.reply.user"></MkUserName>
+		<Mfm
+			class="summary"
+			:text="getNoteSummary(appearNote.reply)"
+			:plain="true"
+			:nowrap="true"
+			:lang="appearNote.reply.lang"
+			:custom-emojis="note.emojis"
+		/>
+	</div>
+</template>
+
+<script lang="ts" setup>
+import { i18n } from "@/i18n";
+import { getNoteSummary } from "@/scripts/get-note-summary";
+import icon from "@/scripts/icon";
+import type { NoteType } from "@/types/note";
+
+defineProps<{
+	note: NoteType;
+	appearNote: NoteType;
+	collapsedReply?: boolean;
+	pinned?: boolean;
+}>();
+
+// function readPromo() {
+// 	os.api("promo/read", {
+// 		noteId: props.appearNote.id,
+// 	});
+// 	isDeleted.value = true;
+// }
+</script>
+
+<style lang="scss" scoped>
+.info {
+	display: flex;
+	align-items: center;
+	font-size: 90%;
+	white-space: pre;
+	color: #f6c177;
+
+	> i {
+		margin-right: 4px;
+	}
+
+	> .hide {
+		margin-left: auto;
+		color: inherit;
+	}
+}
+</style>
diff --git a/packages/client/src/components/MkNoteMedia.vue b/packages/client/src/components/note/MkNoteMedia.vue
similarity index 100%
rename from packages/client/src/components/MkNoteMedia.vue
rename to packages/client/src/components/note/MkNoteMedia.vue
diff --git a/packages/client/src/components/MkNoteMediaList.vue b/packages/client/src/components/note/MkNoteMediaList.vue
similarity index 88%
rename from packages/client/src/components/MkNoteMediaList.vue
rename to packages/client/src/components/note/MkNoteMediaList.vue
index 879ef8d673..cb02eebb3f 100644
--- a/packages/client/src/components/MkNoteMediaList.vue
+++ b/packages/client/src/components/note/MkNoteMediaList.vue
@@ -16,7 +16,7 @@
 
 <script lang="ts" setup>
 import type { entities } from "firefish-js";
-import XNoteMedia from "@/components/MkNoteMedia.vue";
+import XNoteMedia from "@/components/note/MkNoteMedia.vue";
 
 defineProps<{
 	note: entities.Note;
diff --git a/packages/client/src/components/note/MkNoteTranslation.vue b/packages/client/src/components/note/MkNoteTranslation.vue
new file mode 100644
index 0000000000..8b07c9f903
--- /dev/null
+++ b/packages/client/src/components/note/MkNoteTranslation.vue
@@ -0,0 +1,110 @@
+<template>
+	<div v-if="translating || translation != null || hasError" class="translation-container">
+		<MkLoading v-if="translating" mini/>
+		<MkError v-else-if="hasError" @retry="translate"/>
+		<div v-else-if="translation != null" class="translated">
+			<b
+				>{{
+					i18n.t("translatedFrom", {
+						x: translation.sourceLang,
+					})
+				}}:
+			</b>
+			<Mfm
+				:text="translation.text"
+				:author="note.user"
+				:i="me"
+				:lang="targetLang"
+				:custom-emojis="note.emojis"
+			/>
+		</div>
+	</div>
+</template>
+
+<script lang="ts" setup>
+import { i18n } from "@/i18n";
+import { me } from "@/me";
+import type { NoteTranslation, NoteType } from "@/types/note";
+import { computed, ref, watch } from "vue";
+import * as os from "@/os";
+import { instance } from "@/instance";
+
+const props = defineProps<{
+	note: NoteType;
+	detailedView?: boolean;
+}>();
+
+const translation = ref<NoteTranslation | null>(null);
+const translating = ref<boolean>();
+const hasError = ref<boolean>();
+const canTranslate = computed(
+	() =>
+		instance.translatorAvailable &&
+		translation.value == null &&
+		translating.value !== true,
+);
+
+const lang = localStorage.getItem("lang");
+const translateLang = localStorage.getItem("translateLang");
+const targetLang = (translateLang || lang || navigator.language)?.slice(0, 2);
+
+watch(
+	() => props.note.id,
+	(o, n) => {
+		if (o !== n) {
+			translating.value = false;
+			translation.value = null;
+		}
+	},
+);
+
+async function getTranslation(noteId: string, targetLang: string) {
+	return await os.api("notes/translate", {
+		noteId,
+		targetLang,
+	});
+}
+
+async function translate() {
+	try {
+		if (translation.value != null) return;
+		translating.value = true;
+		translation.value = await getTranslation(
+			props.note.id,
+			translateLang || lang || navigator.language,
+		);
+
+		// use UI language as the second translation language
+		if (
+			translateLang != null &&
+			lang != null &&
+			translateLang !== lang &&
+			(!translation.value ||
+				translation.value.sourceLang.toLowerCase() ===
+					translateLang.slice(0, 2))
+		)
+			translation.value = await getTranslation(props.note.id, lang);
+		hasError.value = false;
+	} catch (err) {
+		hasError.value = true;
+		translation.value = null;
+	} finally {
+		translating.value = false;
+	}
+}
+
+defineExpose({
+	translate,
+	canTranslate,
+	targetLang,
+});
+</script>
+
+<style lang="scss" scoped>
+.translation-container {
+	border: solid 0.5px var(--divider);
+	border-radius: var(--radius);
+	padding: 12px;
+	margin-top: 8px;
+}
+</style>
diff --git a/packages/client/src/components/note/MkRenoteBar.vue b/packages/client/src/components/note/MkRenoteBar.vue
new file mode 100644
index 0000000000..948dfbc72f
--- /dev/null
+++ b/packages/client/src/components/note/MkRenoteBar.vue
@@ -0,0 +1,165 @@
+<template>
+	<div
+		v-if="isRenote || (renotesSliced && renotesSliced.length > 0)"
+		class="renote"
+	>
+		<i :class="icon('ph-rocket-launch')"></i>
+		<I18n v-if="renotesSliced == null" :src="i18n.ts.renotedBy" tag="span">
+			<template #user>
+				<MkAvatar class="avatar" :user="note.user" />
+				<MkA
+					v-user-preview="note.userId"
+					class="name"
+					:to="userPage(note.user)"
+					@click.stop
+				>
+					<MkUserName :user="note.user" />
+				</MkA>
+			</template>
+		</I18n>
+		<I18n v-else :src="i18n.ts.renotedBy" tag="span">
+			<template #user>
+				<template v-for="(renote, index) in renotesSliced">
+					<MkAvatar class="avatar" :user="renote.user" />
+					<MkA
+						v-user-preview="renote.userId"
+						class="name"
+						:to="userPage(renote.user)"
+						@click.stop
+					>
+						<MkUserName :user="renote.user" />
+					</MkA>
+					{{
+						index !== renotesSliced.length - 1
+							? ", "
+							: renotesSliced.length < renotes!.length
+							? "..."
+							: ""
+					}}
+				</template>
+			</template>
+		</I18n>
+		<div class="info">
+			<button
+				ref="renoteTime"
+				class="_button time"
+				@click.stop="showRenoteMenu()"
+			>
+				<i
+					v-if="isMyNote"
+					:class="icon('ph-dots-three-outline dropdownIcon')"
+				></i>
+				<MkTime
+					v-if="renotesSliced && renotesSliced.length > 0"
+					:time="renotesSliced[0].createdAt"
+				/>
+				<MkTime v-else :time="note.createdAt" />
+			</button>
+			<MkVisibility :note="note" />
+		</div>
+	</div>
+</template>
+
+<script lang="ts" setup>
+import { userPage } from "@/filters/user";
+import { i18n } from "@/i18n";
+import { isSignedIn, me } from "@/me";
+import icon from "@/scripts/icon";
+import type { NoteType } from "@/types/note";
+import { computed, ref } from "vue";
+import MkVisibility from "@/components/MkVisibility.vue";
+import * as os from "@/os";
+
+const props = defineProps<{
+	note: NoteType;
+	appearNote: NoteType;
+	isRenote?: boolean;
+	renotes?: NoteType[];
+}>();
+
+const emit = defineEmits<{
+	deleted: [];
+}>();
+
+const renoteTime = ref<HTMLElement>();
+
+const renotesSliced = computed(() => props.renotes?.slice(0, 5));
+
+const isMyNote = computed(
+	() => isSignedIn(me) && me.id === props.note.userId && props.renotes == null,
+);
+
+function showRenoteMenu(viaKeyboard = false): void {
+	if (!isMyNote.value) return;
+	os.popupMenu(
+		[
+			{
+				text: i18n.ts.unrenote,
+				icon: `${icon("ph-trash")}`,
+				danger: true,
+				action: () => {
+					os.api("notes/delete", {
+						noteId: props.note.id,
+					});
+					emit("deleted");
+				},
+			},
+		],
+		renoteTime.value,
+		{
+			viaKeyboard,
+		},
+	);
+}
+</script>
+
+<style lang="scss" scoped>
+.renote {
+	display: flex;
+	align-items: center;
+	white-space: pre;
+	color: var(--renote);
+	cursor: pointer;
+
+	> i {
+		margin-right: 4px;
+	}
+
+	.avatar {
+		width: 1.2em;
+		height: 1.2em;
+		border-radius: 2em;
+		overflow: hidden;
+		margin-right: 0.4em;
+		background: var(--panelHighlight);
+		transform: translateY(-4px);
+	}
+
+	> span {
+		overflow: hidden;
+		flex-shrink: 1;
+		text-overflow: ellipsis;
+		white-space: nowrap;
+
+		> .name {
+			font-weight: bold;
+		}
+	}
+
+	> .info {
+		margin-left: auto;
+		font-size: 0.9em;
+		display: flex;
+
+		> .time {
+			flex-shrink: 0;
+			color: inherit;
+			display: inline-flex;
+			align-items: center;
+			> .dropdownIcon {
+				margin-right: 4px;
+			}
+		}
+	}
+}
+</style>
diff --git a/packages/client/src/pages/user/media-list.vue b/packages/client/src/pages/user/media-list.vue
index 533ad2a860..01c4526274 100644
--- a/packages/client/src/pages/user/media-list.vue
+++ b/packages/client/src/pages/user/media-list.vue
@@ -13,7 +13,7 @@
 <script lang="ts" setup>
 import { computed } from "vue";
 import type { entities } from "firefish-js";
-import MkNoteMediaList from "@/components/MkNoteMediaList.vue";
+import MkNoteMediaList from "@/components/note/MkNoteMediaList.vue";
 import MkPagination from "@/components/MkPagination.vue";
 
 const props = defineProps<{
diff --git a/packages/client/src/scripts/get-note-menu.ts b/packages/client/src/scripts/get-note-menu.ts
index ade43a3e22..e323a992f6 100644
--- a/packages/client/src/scripts/get-note-menu.ts
+++ b/packages/client/src/scripts/get-note-menu.ts
@@ -3,7 +3,6 @@ import { defineAsyncComponent } from "vue";
 import type { entities } from "firefish-js";
 import { isModerator, isSignedIn, me } from "@/me";
 import { i18n } from "@/i18n";
-import { instance } from "@/instance";
 import * as os from "@/os";
 import copyToClipboard from "@/scripts/copy-to-clipboard";
 import { url } from "@/config";
@@ -13,18 +12,17 @@ import { getUserMenu } from "@/scripts/get-user-menu";
 import icon from "@/scripts/icon";
 import { useRouter } from "@/router";
 import { notePage } from "@/filters/note";
-import type { NoteTranslation } from "@/types/note";
 import type { MenuItem } from "@/types/menu";
 import type { NoteDraft } from "@/types/post-form";
+import type MkNoteTranslation from "@/components/note/MkNoteTranslation.vue";
 
 const router = useRouter();
 
 export function getNoteMenu(props: {
 	note: entities.Note;
 	menuButton: Ref<HTMLElement | undefined>;
-	translation: Ref<NoteTranslation | null>;
-	translating: Ref<boolean>;
 	isDeleted: Ref<boolean>;
+	translationEl: InstanceType<typeof MkNoteTranslation>;
 	currentClipPage?: Ref<entities.Clip> | null;
 }) {
 	const isRenote =
@@ -270,42 +268,11 @@ export function getNoteMenu(props: {
 	function share(): void {
 		navigator.share({
 			title: i18n.t("noteOf", { user: appearNote.user.name }),
-			text: appearNote.text,
+			text: appearNote.text ?? undefined,
 			url: `${url}/notes/${appearNote.id}`,
 		});
 	}
 
-	async function translate_(noteId: number, targetLang: string) {
-		return await os.api("notes/translate", {
-			noteId,
-			targetLang,
-		});
-	}
-
-	async function translate(): Promise<void> {
-		const translateLang = localStorage.getItem("translateLang");
-		const lang = localStorage.getItem("lang");
-
-		if (props.translation.value != null) return;
-		props.translating.value = true;
-		props.translation.value = await translate_(
-			appearNote.id,
-			translateLang || lang || navigator.language,
-		);
-
-		// use UI language as the second translation target
-		if (
-			translateLang != null &&
-			lang != null &&
-			translateLang !== lang &&
-			(!props.translation.value ||
-				props.translation.value.sourceLang.toLowerCase() ===
-					translateLang.slice(0, 2))
-		)
-			props.translation.value = await translate_(appearNote.id, lang);
-		props.translating.value = false;
-	}
-
 	let menu: MenuItem[];
 	if (isSignedIn(me)) {
 		const statePromise = os.api("notes/state", {
@@ -394,11 +361,11 @@ export function getNoteMenu(props: {
 						action: () => showEditHistory(),
 					}
 				: undefined,
-			instance.translatorAvailable
+			props.translationEl.canTranslate
 				? {
 						icon: `${icon("ph-translate")}`,
 						text: i18n.ts.translate,
-						action: translate,
+						action: props.translationEl.translate,
 					}
 				: undefined,
 			appearNote.url || appearNote.uri
diff --git a/packages/client/src/scripts/show-note-context-menu.ts b/packages/client/src/scripts/show-note-context-menu.ts
new file mode 100644
index 0000000000..dc88627df5
--- /dev/null
+++ b/packages/client/src/scripts/show-note-context-menu.ts
@@ -0,0 +1,89 @@
+import { notePage } from "@/filters/note";
+import * as os from "@/os";
+import { defaultStore } from "@/store";
+import type { NoteType } from "@/types/note";
+import icon from "./icon";
+import { i18n } from "@/i18n";
+import copyToClipboard from "./copy-to-clipboard";
+import { useRouter } from "@/router";
+
+const router = useRouter();
+import { url } from "@/config";
+
+export function showNoteContextMenu({
+	ev,
+	note,
+	react,
+}: {
+	ev: MouseEvent;
+	note: NoteType;
+	react: () => void;
+}): void {
+	const isLink = (el: HTMLElement): boolean => {
+		if (el.tagName === "A") return true;
+		// The Audio element's context menu is the browser default, such as for selecting playback speed.
+		if (el.tagName === "AUDIO") return true;
+		if (el.parentElement) {
+			return isLink(el.parentElement);
+		}
+		return false;
+	};
+	if (isLink(ev.target as HTMLElement)) return;
+	if (window.getSelection()?.toString() !== "") return;
+
+	if (defaultStore.state.useReactionPickerForContextMenu) {
+		ev.preventDefault();
+		react();
+	} else {
+		os.contextMenu(
+			[
+				{
+					type: "label",
+					text: notePage(note),
+				},
+				{
+					icon: `${icon("ph-browser")}`,
+					text: i18n.ts.openInWindow,
+					action: () => {
+						os.pageWindow(notePage(note));
+					},
+				},
+				notePage(note) !== location.pathname
+					? {
+							icon: `${icon("ph-arrows-out-simple")}`,
+							text: i18n.ts.showInPage,
+							action: () => {
+								router.push(notePage(note), "forcePage");
+							},
+						}
+					: undefined,
+				null,
+				{
+					type: "a",
+					icon: `${icon("ph-arrow-square-out")}`,
+					text: i18n.ts.openInNewTab,
+					href: notePage(note),
+					target: "_blank",
+				},
+				{
+					icon: `${icon("ph-link-simple")}`,
+					text: i18n.ts.copyLink,
+					action: () => {
+						copyToClipboard(`${url}${notePage(note)}`);
+						os.success();
+					},
+				},
+				note.user.host != null
+					? {
+							type: "a",
+							icon: `${icon("ph-arrow-square-up-right")}`,
+							text: i18n.ts.showOnRemote,
+							href: note.url ?? note.uri ?? "",
+							target: "_blank",
+						}
+					: undefined,
+			],
+			ev,
+		);
+	}
+}