From 7e648a255f03a6ba0ebeb8547509796f3a9bd7d2 Mon Sep 17 00:00:00 2001
From: Mar0xy <marie@kaifa.ch>
Date: Sun, 15 Oct 2023 02:16:02 +0200
Subject: [PATCH] upd: Separate quote from boost

---
 .../backend/src/core/NoteCreateService.ts     |   2 +-
 .../server/api/endpoints/notes/children.ts    |  25 ++-
 .../src/server/api/endpoints/notes/renotes.ts |   9 +-
 .../server/api/endpoints/notes/unrenote.ts    |   7 +-
 packages/frontend/src/components/MkNote.vue   | 207 ++++++++++++------
 .../src/components/MkNoteDetailed.vue         | 205 ++++++++++++-----
 .../frontend/src/components/MkNoteSub.vue     | 164 +++++++++-----
 7 files changed, 413 insertions(+), 206 deletions(-)

diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts
index 6263c9ebbd..4c24f86d1b 100644
--- a/packages/backend/src/core/NoteCreateService.ts
+++ b/packages/backend/src/core/NoteCreateService.ts
@@ -535,7 +535,7 @@ export class NoteCreateService implements OnApplicationShutdown {
 			});
 		}
 
-		if (data.renote && data.renote.userId !== user.id && !user.isBot) {
+		if (data.renote && data.text == null && data.renote.userId !== user.id && !user.isBot) {
 			this.incRenoteCount(data.renote);
 		}
 
diff --git a/packages/backend/src/server/api/endpoints/notes/children.ts b/packages/backend/src/server/api/endpoints/notes/children.ts
index 1e569d9806..a16740c816 100644
--- a/packages/backend/src/server/api/endpoints/notes/children.ts
+++ b/packages/backend/src/server/api/endpoints/notes/children.ts
@@ -34,6 +34,7 @@ export const paramDef = {
 		limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
 		sinceId: { type: 'string', format: 'misskey:id' },
 		untilId: { type: 'string', format: 'misskey:id' },
+		showQuotes: { type: 'boolean', default: true },
 	},
 	required: ['noteId'],
 } as const;
@@ -51,17 +52,19 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 			const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
 				.andWhere(new Brackets(qb => {
 					qb
-						.where('note.replyId = :noteId', { noteId: ps.noteId })
-						.orWhere(new Brackets(qb => {
-							qb
-								.where('note.renoteId = :noteId', { noteId: ps.noteId })
-								.andWhere(new Brackets(qb => {
-									qb
-										.where('note.text IS NOT NULL')
-										.orWhere('note.fileIds != \'{}\'')
-										.orWhere('note.hasPoll = TRUE');
-								}));
-						}));
+						.where('note.replyId = :noteId', { noteId: ps.noteId });
+						if (ps.showQuotes) {
+							qb.orWhere(new Brackets(qb => {
+								qb
+									.where('note.renoteId = :noteId', { noteId: ps.noteId })
+									.andWhere(new Brackets(qb => {
+										qb
+											.where('note.text IS NOT NULL')
+											.orWhere('note.fileIds != \'{}\'')
+											.orWhere('note.hasPoll = TRUE');
+									}));
+							}));
+						}
 				}))
 				.innerJoinAndSelect('note.user', 'user')
 				.leftJoinAndSelect('note.reply', 'reply')
diff --git a/packages/backend/src/server/api/endpoints/notes/renotes.ts b/packages/backend/src/server/api/endpoints/notes/renotes.ts
index 2099701ab2..063650b3c7 100644
--- a/packages/backend/src/server/api/endpoints/notes/renotes.ts
+++ b/packages/backend/src/server/api/endpoints/notes/renotes.ts
@@ -44,6 +44,7 @@ export const paramDef = {
 		limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
 		sinceId: { type: 'string', format: 'misskey:id' },
 		untilId: { type: 'string', format: 'misskey:id' },
+		quote: { type: 'boolean', default: false },
 	},
 	required: ['noteId'],
 } as const;
@@ -74,7 +75,13 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 			
 			if (ps.userId) {
 				query.andWhere("user.id = :userId", { userId: ps.userId });
-			}			
+			}
+
+			if (ps.quote) {
+				query.andWhere("note.text IS NOT NULL");
+			} else {
+				query.andWhere("note.text IS NULL");
+			}
 
 			this.queryService.generateVisibilityQuery(query, me);
 			if (me) this.queryService.generateMutedUserQuery(query, me);
diff --git a/packages/backend/src/server/api/endpoints/notes/unrenote.ts b/packages/backend/src/server/api/endpoints/notes/unrenote.ts
index 1b71de4966..249344a6f3 100644
--- a/packages/backend/src/server/api/endpoints/notes/unrenote.ts
+++ b/packages/backend/src/server/api/endpoints/notes/unrenote.ts
@@ -38,6 +38,7 @@ export const paramDef = {
 	type: 'object',
 	properties: {
 		noteId: { type: 'string', format: 'misskey:id' },
+		quote: { type: 'boolean', default: false },
 	},
 	required: ['noteId'],
 } as const;
@@ -66,7 +67,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 			});
 
 			for (const note of renotes) {
-				this.noteDeleteService.delete(await this.usersRepository.findOneByOrFail({ id: me.id }), note, false);
+				if (ps.quote) {
+					if (note.text) this.noteDeleteService.delete(await this.usersRepository.findOneByOrFail({ id: me.id }), note, false);
+				} else {
+					if (!note.text) this.noteDeleteService.delete(await this.usersRepository.findOneByOrFail({ id: me.id }), note, false);
+				}
 			}
 		});
 	}
diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue
index 9e4bc38a88..2f5966be6d 100644
--- a/packages/frontend/src/components/MkNote.vue
+++ b/packages/frontend/src/components/MkNote.vue
@@ -110,6 +110,17 @@ SPDX-License-Identifier: AGPL-3.0-only
 				<button v-else :class="$style.footerButton" class="_button" disabled>
 					<i class="ph-prohibit ph-bold ph-lg"></i>
 				</button>
+				<button
+					v-if="canRenote"
+					ref="quoteButton"
+					:class="$style.footerButton"
+					class="_button"
+					:style="quoted ? 'color: var(--accent) !important;' : ''"
+					v-on:click.stop
+					@mousedown="quoted ? undoQuote(appearNote) : quote()"
+				>
+					<i class="ph-quotes ph-bold ph-lg"></i>
+				</button>
 				<button v-if="appearNote.myReaction == null && appearNote.reactionAcceptance !== 'likeOnly'" ref="likeButton" :class="$style.footerButton" class="_button" v-on:click.stop @click="like()">
 					<i class="ph-heart ph-bold ph-lg"></i>
 				</button>
@@ -216,6 +227,7 @@ const menuButton = shallowRef<HTMLElement>();
 const renoteButton = shallowRef<HTMLElement>();
 const renoteTime = shallowRef<HTMLElement>();
 const reactButton = shallowRef<HTMLElement>();
+const quoteButton = shallowRef<HTMLElement>();
 const clipButton = shallowRef<HTMLElement>();
 const likeButton = shallowRef<HTMLElement>();
 let appearNote = $computed(() => isRenote ? note.renote as Misskey.entities.Note : note);
@@ -229,6 +241,7 @@ const isLong = shouldCollapsed(appearNote);
 const collapsed = ref(appearNote.cw == null && isLong);
 const isDeleted = ref(false);
 const renoted = ref(false);
+const quoted = ref(false);
 const muted = ref($i ? checkWordMute(appearNote, $i, $i.mutedWords) : false);
 const translation = ref<any>(null);
 const translating = ref(false);
@@ -271,6 +284,25 @@ useTooltip(renoteButton, async (showing) => {
 	}, {}, 'closed');
 });
 
+useTooltip(quoteButton, async (showing) => {
+	const renotes = await os.api('notes/renotes', {
+		noteId: appearNote.id,
+		limit: 11,
+		quote: true,
+	});
+
+	const users = renotes.map(x => x.user);
+
+	if (users.length < 1) return;
+
+	os.popup(MkUsersTooltip, {
+		showing,
+		users,
+		count: appearNote.renoteCount,
+		targetElement: quoteButton.value,
+	}, {}, 'closed');
+});
+
 if ($i) {
 	os.api("notes/renotes", {
 		noteId: appearNote.id,
@@ -279,6 +311,15 @@ if ($i) {
 	}).then((res) => {
 		renoted.value = res.length > 0;
 	});
+
+	os.api("notes/renotes", {
+		noteId: appearNote.id,
+		userId: $i.id,
+		limit: 1,
+		quote: true,
+	}).then((res) => {
+		quoted.value = res.length > 0;
+	});
 }
 
 type Visibility = 'public' | 'home' | 'followers' | 'specified';
@@ -292,88 +333,103 @@ function smallerVisibility(a: Visibility | string, b: Visibility | string): Visi
 	return 'public';
 }
 
-function renote(viaKeyboard = false) {
+function renote() {
 	pleaseLogin();
 	showMovedDialog();
 
-	let items = [] as MenuItem[];
+	if (appearNote.channel) {
+		const el = renoteButton.value as HTMLElement | null | undefined;
+		if (el) {
+			const rect = el.getBoundingClientRect();
+			const x = rect.left + (el.offsetWidth / 2);
+			const y = rect.top + (el.offsetHeight / 2);
+			os.popup(MkRippleEffect, { x, y }, {}, 'end');
+		}
+
+		os.api('notes/create', {
+			renoteId: appearNote.id,
+			channelId: appearNote.channelId,
+		}).then(() => {
+			os.toast(i18n.ts.renoted);
+			renoted.value = true;
+		});
+	} else {
+		const el = renoteButton.value as HTMLElement | null | undefined;
+		if (el) {
+			const rect = el.getBoundingClientRect();
+			const x = rect.left + (el.offsetWidth / 2);
+			const y = rect.top + (el.offsetHeight / 2);
+			os.popup(MkRippleEffect, { x, y }, {}, 'end');
+		}
+
+		const configuredVisibility = defaultStore.state.rememberNoteVisibility ? defaultStore.state.visibility : defaultStore.state.defaultNoteVisibility;
+		const localOnly = defaultStore.state.rememberNoteVisibility ? defaultStore.state.localOnly : defaultStore.state.defaultNoteLocalOnly;
+
+		let visibility = appearNote.visibility;
+		visibility = smallerVisibility(visibility, configuredVisibility);
+		if (appearNote.channel?.isSensitive) {
+			visibility = smallerVisibility(visibility, 'home');
+		}
+
+		os.api('notes/create', {
+			localOnly,
+			visibility,
+			renoteId: appearNote.id,
+		}).then(() => {
+			os.toast(i18n.ts.renoted);
+			renoted.value = true;
+		});
+	}
+}
+
+function quote() {
+	pleaseLogin();
+	showMovedDialog();
 
 	if (appearNote.channel) {
-		items = items.concat([{
-			text: i18n.ts.inChannelRenote,
-			icon: 'ph-rocket-launch ph-bold ph-lg',
-			action: () => {
-				const el = renoteButton.value as HTMLElement | null | undefined;
-				if (el) {
+		os.post({
+			renote: appearNote,
+			channel: appearNote.channel,
+		}).then(() => {
+			os.api("notes/renotes", {
+				noteId: appearNote.id,
+				userId: $i.id,
+				limit: 1,
+				quote: true,
+			}).then((res) => {
+				const el = quoteButton.value as HTMLElement | null | undefined;
+				if (el && res.length > 0) {
 					const rect = el.getBoundingClientRect();
 					const x = rect.left + (el.offsetWidth / 2);
 					const y = rect.top + (el.offsetHeight / 2);
 					os.popup(MkRippleEffect, { x, y }, {}, 'end');
 				}
 
-				os.api('notes/create', {
-					renoteId: appearNote.id,
-					channelId: appearNote.channelId,
-				}).then(() => {
-					os.toast(i18n.ts.renoted);
-					renoted.value = true;
-				});
-			},
-		}, {
-			text: i18n.ts.inChannelQuote,
-			icon: 'ph-quotes ph-bold ph-lg',
-			action: () => {
-				os.post({
-					renote: appearNote,
-					channel: appearNote.channel,
-				});
-			},
-		}, null]);
+				quoted.value = res.length > 0;
+			});
+		});
+	} else {
+		os.post({
+			renote: appearNote,
+		}).then(() => {
+			os.api("notes/renotes", {
+				noteId: appearNote.id,
+				userId: $i.id,
+				limit: 1,
+				quote: true,
+			}).then((res) => {
+				const el = quoteButton.value as HTMLElement | null | undefined;
+				if (el && res.length > 0) {
+					const rect = el.getBoundingClientRect();
+					const x = rect.left + (el.offsetWidth / 2);
+					const y = rect.top + (el.offsetHeight / 2);
+					os.popup(MkRippleEffect, { x, y }, {}, 'end');
+				}
+
+				quoted.value = res.length > 0;
+			});
+		});
 	}
-
-	items = items.concat([{
-		text: i18n.ts.renote,
-		icon: 'ph-rocket-launch ph-bold ph-lg',
-		action: () => {
-			const el = renoteButton.value as HTMLElement | null | undefined;
-			if (el) {
-				const rect = el.getBoundingClientRect();
-				const x = rect.left + (el.offsetWidth / 2);
-				const y = rect.top + (el.offsetHeight / 2);
-				os.popup(MkRippleEffect, { x, y }, {}, 'end');
-			}
-
-			const configuredVisibility = defaultStore.state.rememberNoteVisibility ? defaultStore.state.visibility : defaultStore.state.defaultNoteVisibility;
-			const localOnly = defaultStore.state.rememberNoteVisibility ? defaultStore.state.localOnly : defaultStore.state.defaultNoteLocalOnly;
-
-			let visibility = appearNote.visibility;
-			visibility = smallerVisibility(visibility, configuredVisibility);
-			if (appearNote.channel?.isSensitive) {
-				visibility = smallerVisibility(visibility, 'home');
-			}
-
-			os.api('notes/create', {
-				localOnly,
-				visibility,
-				renoteId: appearNote.id,
-			}).then(() => {
-				os.toast(i18n.ts.renoted);
-				renoted.value = true;
-			});
-		},
-	}, {
-		text: i18n.ts.quote,
-		icon: 'ph-quotes ph-bold ph-lg',
-		action: () => {
-			os.post({
-				renote: appearNote,
-			});
-		},
-	}]);
-
-	os.popupMenu(items, renoteButton.value, {
-		viaKeyboard,
-	});
 }
 
 function reply(viaKeyboard = false): void {
@@ -443,13 +499,20 @@ function undoReact(note): void {
 }
 
 function undoRenote(note) : void {
-	if (!renoted.value) return;
 	os.api("notes/unrenote", {
-		noteId: note.id,
+		noteId: note.id
 	});
 	renoted.value = false;
 }
 
+function undoQuote(note) : void {
+	os.api("notes/unrenote", {
+		noteId: note.id,
+		quote: true
+	});
+	quoted.value = false;
+}
+
 function onContextmenu(ev: MouseEvent): void {
 	const isLink = (el: HTMLElement) => {
 		if (el.tagName === 'A') return true;
diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue
index 782128d810..caf0fd38f6 100644
--- a/packages/frontend/src/components/MkNoteDetailed.vue
+++ b/packages/frontend/src/components/MkNoteDetailed.vue
@@ -120,6 +120,16 @@ SPDX-License-Identifier: AGPL-3.0-only
 			<button v-else class="_button" :class="$style.noteFooterButton" disabled>
 				<i class="ph-prohibit ph-bold ph-lg"></i>
 			</button>
+			<button
+				v-if="canRenote"
+				ref="quoteButton"
+				class="_button"
+				:class="$style.noteFooterButton"
+				:style="quoted ? 'color: var(--accent) !important;' : ''"
+				@mousedown="quoted ? undoQuote() : quote()"
+			>
+				<i class="ph-quotes ph-bold ph-lg"></i>
+			</button>
 			<button v-if="appearNote.myReaction == null && appearNote.reactionAcceptance !== 'likeOnly'" ref="likeButton" :class="$style.noteFooterButton" class="_button" @mousedown="like()">
 				<i class="ph-heart ph-bold ph-lg"></i>
 			</button>
@@ -141,6 +151,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 	<div :class="$style.tabs">
 		<button class="_button" :class="[$style.tab, { [$style.tabActive]: tab === 'replies' }]" @click="tab = 'replies'"><i class="ph-arrow-u-up-left ph-bold pg-lg"></i> {{ i18n.ts.replies }}</button>
 		<button class="_button" :class="[$style.tab, { [$style.tabActive]: tab === 'renotes' }]" @click="tab = 'renotes'"><i class="ph-rocket-launch ph-bold ph-lg"></i> {{ i18n.ts.renotes }}</button>
+		<button class="_button" :class="[$style.tab, { [$style.tabActive]: tab === 'quotes' }]" @click="tab = 'quotes'"><i class="ph-quotes ph-bold ph-lg"></i> {{ i18n.ts._notification._types.quote }}</button>
 		<button class="_button" :class="[$style.tab, { [$style.tabActive]: tab === 'reactions' }]" @click="tab = 'reactions'"><i class="ph-smiley ph-bold pg-lg"></i> {{ i18n.ts.reactions }}</button>
 	</div>
 	<div>
@@ -161,6 +172,12 @@ SPDX-License-Identifier: AGPL-3.0-only
 				</template>
 			</MkPagination>
 		</div>
+		<div v-if="tab === 'quotes'" :class="$style.tab_replies">
+			<div v-if="!quotesLoaded" style="padding: 16px">
+				<MkButton style="margin: 0 auto;" primary rounded @click="loadQuotes">{{ i18n.ts.loadReplies }}</MkButton>
+			</div>
+			<MkNoteSub v-for="note in quotes" :key="note.id" :note="note" :class="$style.reply" :detail="true"/>
+		</div>
 		<div v-else-if="tab === 'reactions'" :class="$style.tab_reactions">
 			<div :class="$style.reactionTabs">
 				<button v-for="reaction in Object.keys(appearNote.reactions)" :key="reaction" :class="[$style.reactionTab, { [$style.reactionTabActive]: reactionTabType === reaction }]" class="_button" @click="reactionTabType = reaction">
@@ -258,6 +275,7 @@ const menuButton = shallowRef<HTMLElement>();
 const renoteButton = shallowRef<HTMLElement>();
 const renoteTime = shallowRef<HTMLElement>();
 const reactButton = shallowRef<HTMLElement>();
+const quoteButton = shallowRef<HTMLElement>();
 const clipButton = shallowRef<HTMLElement>();
 const likeButton = shallowRef<HTMLElement>();
 let appearNote = $computed(() => isRenote ? note.renote as Misskey.entities.Note : note);
@@ -268,6 +286,7 @@ const isMyRenote = $i && ($i.id === note.userId);
 const showContent = ref(false);
 const isDeleted = ref(false);
 const renoted = ref(false);
+const quoted = ref(false);
 const muted = ref($i ? checkWordMute(appearNote, $i, $i.mutedWords) : false);
 const translation = ref(null);
 const translating = ref(false);
@@ -275,6 +294,7 @@ const urls = appearNote.text ? extractUrlFromMfm(mfm.parse(appearNote.text)).fil
 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 quotes = ref<Misskey.entities.Note[]>([]);
 const canRenote = computed(() => ['public', 'home'].includes(appearNote.visibility) || appearNote.userId === $i.id);
 
 if ($i) {
@@ -285,6 +305,15 @@ if ($i) {
 	}).then((res) => {
 		renoted.value = res.length > 0;
 	});
+	
+	os.api("notes/renotes", {
+		noteId: appearNote.id,
+		userId: $i.id,
+		limit: 1,
+		quote: true,
+	}).then((res) => {
+		quoted.value = res.length > 0;
+	});
 }
 
 const keymap = {
@@ -335,82 +364,116 @@ useTooltip(renoteButton, async (showing) => {
 	os.popup(MkUsersTooltip, {
 		showing,
 		users,
-		count: appearNote.renoteCount,
+		count: quotes.length,
 		targetElement: renoteButton.value,
 	}, {}, 'closed');
 });
 
-function renote(viaKeyboard = false) {
+useTooltip(quoteButton, async (showing) => {
+	const renotes = await os.api('notes/renotes', {
+		noteId: appearNote.id,
+		limit: 11,
+		quote: true,
+	});
+
+	const users = renotes.map(x => x.user);
+
+	if (users.length < 1) return;
+
+	os.popup(MkUsersTooltip, {
+		showing,
+		users,
+		count: appearNote.Count,
+		targetElement: quoteButton.value,
+	}, {}, 'closed');
+});
+
+function renote() {
 	pleaseLogin();
 	showMovedDialog();
 
-	let items = [] as MenuItem[];
+	if (appearNote.channel) {
+		const el = renoteButton.value as HTMLElement | null | undefined;
+		if (el) {
+			const rect = el.getBoundingClientRect();
+			const x = rect.left + (el.offsetWidth / 2);
+			const y = rect.top + (el.offsetHeight / 2);
+			os.popup(MkRippleEffect, { x, y }, {}, 'end');
+		}
+
+		os.api('notes/create', {
+			renoteId: appearNote.id,
+			channelId: appearNote.channelId,
+		}).then(() => {
+			os.toast(i18n.ts.renoted);
+			renoted.value = true;
+		});
+	} else {
+		const el = renoteButton.value as HTMLElement | null | undefined;
+		if (el) {
+			const rect = el.getBoundingClientRect();
+			const x = rect.left + (el.offsetWidth / 2);
+			const y = rect.top + (el.offsetHeight / 2);
+			os.popup(MkRippleEffect, { x, y }, {}, 'end');
+		}
+
+		os.api('notes/create', {
+			renoteId: appearNote.id,
+		}).then(() => {
+			os.toast(i18n.ts.renoted);
+			renoted.value = true;
+		});
+	}
+}
+
+function quote() {
+	pleaseLogin();
+	showMovedDialog();
 
 	if (appearNote.channel) {
-		items = items.concat([{
-			text: i18n.ts.inChannelRenote,
-			icon: 'ph-rocket-launch ph-bold ph-lg',
-			action: () => {
-				const el = renoteButton.value as HTMLElement | null | undefined;
-				if (el) {
+		os.post({
+			renote: appearNote,
+			channel: appearNote.channel,
+		}).then(() => {
+			os.api("notes/renotes", {
+				noteId: appearNote.id,
+				userId: $i.id,
+				limit: 1,
+				quote: true,
+			}).then((res) => {
+				const el = quoteButton.value as HTMLElement | null | undefined;
+				if (el && res.length > 0) {
 					const rect = el.getBoundingClientRect();
 					const x = rect.left + (el.offsetWidth / 2);
 					const y = rect.top + (el.offsetHeight / 2);
 					os.popup(MkRippleEffect, { x, y }, {}, 'end');
 				}
 
-				os.api('notes/create', {
-					renoteId: appearNote.id,
-					channelId: appearNote.channelId,
-				}).then(() => {
-					os.toast(i18n.ts.renoted);
-					renoted.value = true;
-				});
-			},
-		}, {
-			text: i18n.ts.inChannelQuote,
-			icon: 'ph-quotes ph-bold ph-lg',
-			action: () => {
-				os.post({
-					renote: appearNote,
-					channel: appearNote.channel,
-				});
-			},
-		}, null]);
+				quoted.value = res.length > 0;
+			});
+		});
+	} else {
+		os.post({
+			renote: appearNote,
+		}).then(() => {
+			os.api("notes/renotes", {
+				noteId: appearNote.id,
+				userId: $i.id,
+				limit: 1,
+				quote: true,
+			}).then((res) => {
+				const el = quoteButton.value as HTMLElement | null | undefined;
+				if (el && res.length > 0) {
+					const rect = el.getBoundingClientRect();
+					const x = rect.left + (el.offsetWidth / 2);
+					const y = rect.top + (el.offsetHeight / 2);
+					os.popup(MkRippleEffect, { x, y }, {}, 'end');
+				}
+
+				quoted.value = res.length > 0;
+			});
+		});
 	}
-
-	items = items.concat([{
-		text: i18n.ts.renote,
-		icon: 'ph-rocket-launch ph-bold ph-lg',
-		action: () => {
-			const el = renoteButton.value as HTMLElement | null | undefined;
-			if (el) {
-				const rect = el.getBoundingClientRect();
-				const x = rect.left + (el.offsetWidth / 2);
-				const y = rect.top + (el.offsetHeight / 2);
-				os.popup(MkRippleEffect, { x, y }, {}, 'end');
-			}
-
-			os.api('notes/create', {
-				renoteId: appearNote.id,
-			}).then(() => {
-				os.toast(i18n.ts.renoted);
-				renoted.value = true;
-			});
-		},
-	}, {
-		text: i18n.ts.quote,
-		icon: 'ph-quotes ph-bold ph-lg',
-		action: () => {
-			os.post({
-				renote: appearNote,
-			});
-		},
-	}]);
-
-	os.popupMenu(items, renoteButton.value, {
-		viaKeyboard,
-	});
 }
 
 function reply(viaKeyboard = false): void {
@@ -488,6 +551,14 @@ function undoRenote() : void {
 	renoted.value = false;
 }
 
+function undoQuote() : void {
+	os.api("notes/unrenote", {
+		noteId: appearNote.id,
+		quote: true
+	});
+	quoted.value = false;
+}
+
 function onContextmenu(ev: MouseEvent): void {
 	const isLink = (el: HTMLElement) => {
 		if (el.tagName === 'A') return true;
@@ -550,12 +621,26 @@ function loadReplies() {
 	os.api('notes/children', {
 		noteId: appearNote.id,
 		limit: 30,
+		showQuotes: false,
 	}).then(res => {
 		replies.value = res;
 	});
 }
 loadReplies();
 
+const quotesLoaded = ref(false);
+function loadQuotes() {
+	quotesLoaded.value = true;
+	os.api('notes/renotes', {
+		noteId: appearNote.id,
+		limit: 30,
+		quote: true,
+	}).then(res => {
+		quotes.value = res;
+	});
+}
+loadQuotes();
+
 const conversationLoaded = ref(false);
 function loadConversation() {
 	conversationLoaded.value = true;
diff --git a/packages/frontend/src/components/MkNoteSub.vue b/packages/frontend/src/components/MkNoteSub.vue
index f0beb06acf..4a8d6c0d32 100644
--- a/packages/frontend/src/components/MkNoteSub.vue
+++ b/packages/frontend/src/components/MkNoteSub.vue
@@ -36,6 +36,16 @@ SPDX-License-Identifier: AGPL-3.0-only
 					<i class="ph-rocket-launch ph-bold ph-lg"></i>
 					<p v-if="note.renoteCount > 0" :class="$style.noteFooterButtonCount">{{ note.renoteCount }}</p>
 				</button>
+				<button
+					v-if="canRenote"
+					ref="quoteButton"
+					class="_button"
+					:class="$style.noteFooterButton"
+					:style="quoted ? 'color: var(--accent) !important;' : ''"
+					@mousedown="quoted ? undoQuote() : quote()"
+				>
+					<i class="ph-quotes ph-bold ph-lg"></i>
+				</button>
 				<button v-else class="_button" :class="$style.noteFooterButton" disabled>
 					<i class="ph-prohibit ph-bold ph-lg"></i>
 				</button>
@@ -114,8 +124,10 @@ const translation = ref(null);
 const translating = ref(false);
 const isDeleted = ref(false);
 const renoted = ref(false);
+const quoted = ref(false);
 const reactButton = shallowRef<HTMLElement>();
 const renoteButton = shallowRef<HTMLElement>();
+const quoteButton = shallowRef<HTMLElement>();
 const menuButton = shallowRef<HTMLElement>();
 const likeButton = shallowRef<HTMLElement>();
 
@@ -142,6 +154,15 @@ if ($i) {
 	}).then((res) => {
 		renoted.value = res.length > 0;
 	});
+
+	os.api("notes/renotes", {
+		noteId: appearNote.id,
+		userId: $i.id,
+		limit: 1,
+		quote: true,
+	}).then((res) => {
+		quoted.value = res.length > 0;
+	});
 }
 
 function focus() {
@@ -223,80 +244,103 @@ function undoRenote() : void {
 	renoted.value = false;
 }
 
+function undoQuote() : void {
+	os.api("notes/unrenote", {
+		noteId: appearNote.id,
+		quote: true
+	});
+	quoted.value = false;
+}
+
 let showContent = $ref(false);
 let replies: Misskey.entities.Note[] = $ref([]);
 
-function renote(viaKeyboard = false) {
+function renote() {
 	pleaseLogin();
 	showMovedDialog();
 
-	let items = [] as MenuItem[];
+	if (appearNote.channel) {
+		const el = renoteButton.value as HTMLElement | null | undefined;
+		if (el) {
+			const rect = el.getBoundingClientRect();
+			const x = rect.left + (el.offsetWidth / 2);
+			const y = rect.top + (el.offsetHeight / 2);
+			os.popup(MkRippleEffect, { x, y }, {}, 'end');
+		}
 
-	if (props.note.channel) {
-		items = items.concat([{
-			text: i18n.ts.inChannelRenote,
-			icon: 'ph-rocket-launch ph-bold ph-lg',
-			action: () => {
-				const el = renoteButton.value as HTMLElement | null | undefined;
-				if (el) {
+		os.api('notes/create', {
+			renoteId: props.note.id,
+			channelId: props.note.channelId,
+		}).then(() => {
+			os.toast(i18n.ts.renoted);
+			renoted.value = true;
+		});
+	} else {
+		const el = renoteButton.value as HTMLElement | null | undefined;
+		if (el) {
+			const rect = el.getBoundingClientRect();
+			const x = rect.left + (el.offsetWidth / 2);
+			const y = rect.top + (el.offsetHeight / 2);
+			os.popup(MkRippleEffect, { x, y }, {}, 'end');
+		}
+
+		os.api('notes/create', {
+			renoteId: props.note.id,
+		}).then(() => {
+			os.toast(i18n.ts.renoted);
+			renoted.value = true;
+		});
+	}
+}
+
+function quote() {
+	pleaseLogin();
+	showMovedDialog();
+
+	if (appearNote.channel) {
+		os.post({
+			renote: appearNote,
+			channel: appearNote.channel,
+		}).then(() => {
+			os.api("notes/renotes", {
+				noteId: props.note.id,
+				userId: $i.id,
+				limit: 1,
+				quote: true,
+			}).then((res) => {
+				const el = quoteButton.value as HTMLElement | null | undefined;
+				if (el && res.length > 0) {
 					const rect = el.getBoundingClientRect();
 					const x = rect.left + (el.offsetWidth / 2);
 					const y = rect.top + (el.offsetHeight / 2);
 					os.popup(MkRippleEffect, { x, y }, {}, 'end');
 				}
 
-				os.api('notes/create', {
-					renoteId: props.note.id,
-					channelId: props.note.channelId,
-				}).then(() => {
-					os.toast(i18n.ts.renoted);
-					renoted.value = true;
-				});
-			},
-		}, {
-			text: i18n.ts.inChannelQuote,
-			icon: 'ph-quotes ph-bold ph-lg',
-			action: () => {
-				os.post({
-					renote: props.note,
-					channel: props.note.channel,
-				});
-			},
-		}, null]);
+				quoted.value = res.length > 0;
+			});
+		});
+	} else {
+		os.post({
+			renote: appearNote,
+		}).then(() => {
+			os.api("notes/renotes", {
+				noteId: props.note.id,
+				userId: $i.id,
+				limit: 1,
+				quote: true,
+			}).then((res) => {
+				const el = quoteButton.value as HTMLElement | null | undefined;
+				if (el && res.length > 0) {
+					const rect = el.getBoundingClientRect();
+					const x = rect.left + (el.offsetWidth / 2);
+					const y = rect.top + (el.offsetHeight / 2);
+					os.popup(MkRippleEffect, { x, y }, {}, 'end');
+				}
+
+				quoted.value = res.length > 0;
+			});
+		});
 	}
-
-	items = items.concat([{
-		text: i18n.ts.renote,
-		icon: 'ph-rocket-launch ph-bold ph-lg',
-		action: () => {
-			const el = renoteButton.value as HTMLElement | null | undefined;
-			if (el) {
-				const rect = el.getBoundingClientRect();
-				const x = rect.left + (el.offsetWidth / 2);
-				const y = rect.top + (el.offsetHeight / 2);
-				os.popup(MkRippleEffect, { x, y }, {}, 'end');
-			}
-
-			os.api('notes/create', {
-				renoteId: props.note.id,
-			}).then(() => {
-				os.toast(i18n.ts.renoted);
-				renoted.value = true;
-			});
-		},
-	}, {
-		text: i18n.ts.quote,
-		icon: 'ph-quotes ph-bold ph-lg',
-		action: () => {
-			os.post({
-				renote: props.note,
-			});
-		},
-	}]);
-
-	os.popupMenu(items, renoteButton.value, {
-		viaKeyboard,
-	});
 }
 
 function menu(viaKeyboard = false): void {