From 7825f6e95eab864796b31e589e14cdc2a0353096 Mon Sep 17 00:00:00 2001
From: HellhoundSoftware <keith@keithhacks.cyou>
Date: Tue, 14 Jan 2025 19:42:35 -0500
Subject: Open boost visibility menu on shift-click

---
 packages/frontend/src/components/MkNoteDetailed.vue | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

(limited to 'packages/frontend/src/components/MkNoteDetailed.vue')

diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue
index 243b95215a..a5e82782b0 100644
--- a/packages/frontend/src/components/MkNoteDetailed.vue
+++ b/packages/frontend/src/components/MkNoteDetailed.vue
@@ -143,7 +143,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 				class="_button"
 				:class="$style.noteFooterButton"
 				:style="renoted ? 'color: var(--MI_THEME-accent) !important;' : ''"
-				@mousedown.prevent="renoted ? undoRenote() : boostVisibility()"
+				@mousedown.prevent="renoted ? undoRenote() : boostVisibility($event.shiftKey)"
 			>
 				<i class="ti ti-repeat"></i>
 				<p v-if="appearNote.renoteCount > 0" :class="$style.noteFooterButtonCount">{{ number(appearNote.renoteCount) }}</p>
@@ -478,10 +478,10 @@ useTooltip(quoteButton, async (showing) => {
 	});
 });
 
-function boostVisibility() {
+function boostVisibility(forceMenu: boolean = false) {
 	if (renoting) return;
 
-	if (!defaultStore.state.showVisibilitySelectorOnBoost) {
+	if (!defaultStore.state.showVisibilitySelectorOnBoost && !forceMenu) {
 		renote(defaultStore.state.visibilityOnBoost);
 	} else {
 		os.popupMenu(boostMenuItems(appearNote, renote), renoteButton.value);
-- 
cgit v1.2.3-freya


From 2c90eebe869d56f6c6e948d96e4565c8c6761a45 Mon Sep 17 00:00:00 2001
From: HellhoundSoftware <keith@keithhacks.cyou>
Date: Wed, 15 Jan 2025 14:38:34 -0500
Subject: Add tooltip for shift-boost

---
 locales/index.d.ts                                  | 4 ++++
 packages/frontend/src/components/MkNote.vue         | 1 +
 packages/frontend/src/components/MkNoteDetailed.vue | 1 +
 packages/frontend/src/components/MkNoteSub.vue      | 1 +
 packages/frontend/src/components/SkNote.vue         | 1 +
 packages/frontend/src/components/SkNoteDetailed.vue | 1 +
 packages/frontend/src/components/SkNoteSub.vue      | 1 +
 sharkey-locales/en-US.yml                           | 1 +
 8 files changed, 11 insertions(+)

(limited to 'packages/frontend/src/components/MkNoteDetailed.vue')

diff --git a/locales/index.d.ts b/locales/index.d.ts
index bcb78e4ee1..8276ead05e 100644
--- a/locales/index.d.ts
+++ b/locales/index.d.ts
@@ -10805,6 +10805,10 @@ export interface Locale extends ILocale {
      * Date
      */
     "date": string;
+    /**
+     * Boost (hold Shift for visibility menu)
+     */
+    "renoteShift": string;
     /**
      * Quoted.
      */
diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue
index 98ec61cefd..b4193369ef 100644
--- a/packages/frontend/src/components/MkNote.vue
+++ b/packages/frontend/src/components/MkNote.vue
@@ -128,6 +128,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 				<button
 					v-if="canRenote"
 					ref="renoteButton"
+					v-tooltip="renoted ? i18n.ts.unrenote : (!defaultStore.state.showVisibilitySelectorOnBoost ? i18n.ts.renoteShift : i18n.ts.renote)"
 					:class="$style.footerButton"
 					class="_button"
 					:style="renoted ? 'color: var(--MI_THEME-accent) !important;' : ''"
diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue
index a5e82782b0..21d8568300 100644
--- a/packages/frontend/src/components/MkNoteDetailed.vue
+++ b/packages/frontend/src/components/MkNoteDetailed.vue
@@ -140,6 +140,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 			<button
 				v-if="canRenote"
 				ref="renoteButton"
+				v-tooltip="renoted ? i18n.ts.unrenote : (!defaultStore.state.showVisibilitySelectorOnBoost ? i18n.ts.renoteShift : i18n.ts.renote)"
 				class="_button"
 				:class="$style.noteFooterButton"
 				:style="renoted ? 'color: var(--MI_THEME-accent) !important;' : ''"
diff --git a/packages/frontend/src/components/MkNoteSub.vue b/packages/frontend/src/components/MkNoteSub.vue
index f9e54f70aa..c84d80475f 100644
--- a/packages/frontend/src/components/MkNoteSub.vue
+++ b/packages/frontend/src/components/MkNoteSub.vue
@@ -28,6 +28,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 				<button
 					v-if="canRenote"
 					ref="renoteButton"
+					v-tooltip="renoted ? i18n.ts.unrenote : (!defaultStore.state.showVisibilitySelectorOnBoost ? i18n.ts.renoteShift : i18n.ts.renote)"
 					class="_button"
 					:class="$style.noteFooterButton"
 					:style="renoted ? 'color: var(--MI_THEME-accent) !important;' : ''"
diff --git a/packages/frontend/src/components/SkNote.vue b/packages/frontend/src/components/SkNote.vue
index c6754764b2..a4265896af 100644
--- a/packages/frontend/src/components/SkNote.vue
+++ b/packages/frontend/src/components/SkNote.vue
@@ -129,6 +129,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 				<button
 					v-if="canRenote"
 					ref="renoteButton"
+					v-tooltip="renoted ? i18n.ts.unrenote : (!defaultStore.state.showVisibilitySelectorOnBoost ? i18n.ts.renoteShift : i18n.ts.renote)"
 					:class="$style.footerButton"
 					class="_button"
 					:style="renoted ? 'color: var(--MI_THEME-accent) !important;' : ''"
diff --git a/packages/frontend/src/components/SkNoteDetailed.vue b/packages/frontend/src/components/SkNoteDetailed.vue
index 5017e3d902..a66e17a134 100644
--- a/packages/frontend/src/components/SkNoteDetailed.vue
+++ b/packages/frontend/src/components/SkNoteDetailed.vue
@@ -145,6 +145,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 			<button
 				v-if="canRenote"
 				ref="renoteButton"
+				v-tooltip="renoted ? i18n.ts.unrenote : (!defaultStore.state.showVisibilitySelectorOnBoost ? i18n.ts.renoteShift : i18n.ts.renote)"
 				class="_button"
 				:class="$style.noteFooterButton"
 				:style="renoted ? 'color: var(--MI_THEME-accent) !important;' : ''"
diff --git a/packages/frontend/src/components/SkNoteSub.vue b/packages/frontend/src/components/SkNoteSub.vue
index 9205983850..39cbaa3195 100644
--- a/packages/frontend/src/components/SkNoteSub.vue
+++ b/packages/frontend/src/components/SkNoteSub.vue
@@ -36,6 +36,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 				<button
 					v-if="canRenote"
 					ref="renoteButton"
+					v-tooltip="renoted ? i18n.ts.unrenote : (!defaultStore.state.showVisibilitySelectorOnBoost ? i18n.ts.renoteShift : i18n.ts.renote)"
 					class="_button"
 					:class="$style.noteFooterButton"
 					:style="renoted ? 'color: var(--MI_THEME-accent) !important;' : ''"
diff --git a/sharkey-locales/en-US.yml b/sharkey-locales/en-US.yml
index db3ef9c3eb..e2a57f3613 100644
--- a/sharkey-locales/en-US.yml
+++ b/sharkey-locales/en-US.yml
@@ -17,6 +17,7 @@ emailDestination: "Destination address"
 date: "Date"
 renote: "Boost"
 unrenote: "Remove boost"
+renoteShift: "Boost (hold Shift for visibility menu)"
 renoted: "Boosted."
 quoted: "Quoted."
 rmboost: "Unboosted."
-- 
cgit v1.2.3-freya


From 3717654309be5bd3dfb87ad0d71f04c52125c00b Mon Sep 17 00:00:00 2001
From: HellhoundSoftware <keith@keithhacks.cyou>
Date: Wed, 15 Jan 2025 16:14:35 -0500
Subject: move tooltip message to computed

---
 packages/frontend/src/components/MkNote.vue         | 7 ++++++-
 packages/frontend/src/components/MkNoteDetailed.vue | 7 ++++++-
 packages/frontend/src/components/MkNoteSub.vue      | 7 ++++++-
 packages/frontend/src/components/SkNote.vue         | 7 ++++++-
 packages/frontend/src/components/SkNoteDetailed.vue | 7 ++++++-
 packages/frontend/src/components/SkNoteSub.vue      | 7 ++++++-
 6 files changed, 36 insertions(+), 6 deletions(-)

(limited to 'packages/frontend/src/components/MkNoteDetailed.vue')

diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue
index b4193369ef..2971252dbf 100644
--- a/packages/frontend/src/components/MkNote.vue
+++ b/packages/frontend/src/components/MkNote.vue
@@ -128,7 +128,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 				<button
 					v-if="canRenote"
 					ref="renoteButton"
-					v-tooltip="renoted ? i18n.ts.unrenote : (!defaultStore.state.showVisibilitySelectorOnBoost ? i18n.ts.renoteShift : i18n.ts.renote)"
+					v-tooltip="renoteTooltip"
 					:class="$style.footerButton"
 					class="_button"
 					:style="renoted ? 'color: var(--MI_THEME-accent) !important;' : ''"
@@ -339,6 +339,11 @@ const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({
 	url: `https://${host}/notes/${appearNote.value.id}`,
 }));
 
+const renoteTooltip = computed(() => {
+	if (renoted.value) return i18n.ts.unrenote;
+	return defaultStore.state.showVisibilitySelectorOnBoost ? i18n.ts.renote : i18n.ts.renoteShift;
+});
+
 /* Overload FunctionにLintが対応していないのでコメントアウト
 function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly: true): boolean;
 function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly: false): boolean | 'sensitiveMute';
diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue
index 21d8568300..a855b3269c 100644
--- a/packages/frontend/src/components/MkNoteDetailed.vue
+++ b/packages/frontend/src/components/MkNoteDetailed.vue
@@ -140,7 +140,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 			<button
 				v-if="canRenote"
 				ref="renoteButton"
-				v-tooltip="renoted ? i18n.ts.unrenote : (!defaultStore.state.showVisibilitySelectorOnBoost ? i18n.ts.renoteShift : i18n.ts.renote)"
+				v-tooltip="renoteTooltip"
 				class="_button"
 				:class="$style.noteFooterButton"
 				:style="renoted ? 'color: var(--MI_THEME-accent) !important;' : ''"
@@ -348,6 +348,11 @@ const quotes = ref<Misskey.entities.Note[]>([]);
 const canRenote = computed(() => ['public', 'home'].includes(appearNote.value.visibility) || (appearNote.value.visibility === 'followers' && appearNote.value.userId === $i?.id));
 const defaultLike = computed(() => defaultStore.state.like ? defaultStore.state.like : null);
 
+const renoteTooltip = computed(() => {
+	if (renoted.value) return i18n.ts.unrenote;
+	return defaultStore.state.showVisibilitySelectorOnBoost ? i18n.ts.renote : i18n.ts.renoteShift;
+});
+
 watch(() => props.expandAllCws, (expandAllCws) => {
 	if (expandAllCws !== showContent.value) showContent.value = expandAllCws;
 });
diff --git a/packages/frontend/src/components/MkNoteSub.vue b/packages/frontend/src/components/MkNoteSub.vue
index c84d80475f..4bdeeb7ab4 100644
--- a/packages/frontend/src/components/MkNoteSub.vue
+++ b/packages/frontend/src/components/MkNoteSub.vue
@@ -28,7 +28,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 				<button
 					v-if="canRenote"
 					ref="renoteButton"
-					v-tooltip="renoted ? i18n.ts.unrenote : (!defaultStore.state.showVisibilitySelectorOnBoost ? i18n.ts.renoteShift : i18n.ts.renote)"
+					v-tooltip="renoteTooltip"
 					class="_button"
 					:class="$style.noteFooterButton"
 					:style="renoted ? 'color: var(--MI_THEME-accent) !important;' : ''"
@@ -136,6 +136,11 @@ const quoteButton = shallowRef<HTMLElement>();
 const menuButton = shallowRef<HTMLElement>();
 const likeButton = shallowRef<HTMLElement>();
 
+const renoteTooltip = computed(() => {
+	if (renoted.value) return i18n.ts.unrenote;
+	return defaultStore.state.showVisibilitySelectorOnBoost ? i18n.ts.renote : i18n.ts.renoteShift;
+});
+
 let appearNote = computed(() => isRenote ? props.note.renote as Misskey.entities.Note : props.note);
 const defaultLike = computed(() => defaultStore.state.like ? defaultStore.state.like : null);
 const replies = ref<Misskey.entities.Note[]>([]);
diff --git a/packages/frontend/src/components/SkNote.vue b/packages/frontend/src/components/SkNote.vue
index a4265896af..eef6129792 100644
--- a/packages/frontend/src/components/SkNote.vue
+++ b/packages/frontend/src/components/SkNote.vue
@@ -129,7 +129,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 				<button
 					v-if="canRenote"
 					ref="renoteButton"
-					v-tooltip="renoted ? i18n.ts.unrenote : (!defaultStore.state.showVisibilitySelectorOnBoost ? i18n.ts.renoteShift : i18n.ts.renote)"
+					v-tooltip="renoteTooltip"
 					:class="$style.footerButton"
 					class="_button"
 					:style="renoted ? 'color: var(--MI_THEME-accent) !important;' : ''"
@@ -334,6 +334,11 @@ const defaultLike = computed(() => defaultStore.state.like ? defaultStore.state.
 const animated = computed(() => parsed.value ? checkAnimationFromMfm(parsed.value) : null);
 const allowAnim = ref(defaultStore.state.advancedMfm && defaultStore.state.animatedMfm ? true : false);
 
+const renoteTooltip = computed(() => {
+	if (renoted.value) return i18n.ts.unrenote;
+	return defaultStore.state.showVisibilitySelectorOnBoost ? i18n.ts.renote : i18n.ts.renoteShift;
+});
+
 const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({
 	type: 'lookup',
 	url: `https://${host}/notes/${appearNote.value.id}`,
diff --git a/packages/frontend/src/components/SkNoteDetailed.vue b/packages/frontend/src/components/SkNoteDetailed.vue
index a66e17a134..e7e225304e 100644
--- a/packages/frontend/src/components/SkNoteDetailed.vue
+++ b/packages/frontend/src/components/SkNoteDetailed.vue
@@ -145,7 +145,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 			<button
 				v-if="canRenote"
 				ref="renoteButton"
-				v-tooltip="renoted ? i18n.ts.unrenote : (!defaultStore.state.showVisibilitySelectorOnBoost ? i18n.ts.renoteShift : i18n.ts.renote)"
+				v-tooltip="renoteTooltip"
 				class="_button"
 				:class="$style.noteFooterButton"
 				:style="renoted ? 'color: var(--MI_THEME-accent) !important;' : ''"
@@ -354,6 +354,11 @@ const quotes = ref<Misskey.entities.Note[]>([]);
 const canRenote = computed(() => ['public', 'home'].includes(appearNote.value.visibility) || (appearNote.value.visibility === 'followers' && appearNote.value.userId === $i?.id));
 const defaultLike = computed(() => defaultStore.state.like ? defaultStore.state.like : null);
 
+const renoteTooltip = computed(() => {
+	if (renoted.value) return i18n.ts.unrenote;
+	return defaultStore.state.showVisibilitySelectorOnBoost ? i18n.ts.renote : i18n.ts.renoteShift;
+});
+
 watch(() => props.expandAllCws, (expandAllCws) => {
 	if (expandAllCws !== showContent.value) showContent.value = expandAllCws;
 });
diff --git a/packages/frontend/src/components/SkNoteSub.vue b/packages/frontend/src/components/SkNoteSub.vue
index 39cbaa3195..ff8af5b72b 100644
--- a/packages/frontend/src/components/SkNoteSub.vue
+++ b/packages/frontend/src/components/SkNoteSub.vue
@@ -36,7 +36,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 				<button
 					v-if="canRenote"
 					ref="renoteButton"
-					v-tooltip="renoted ? i18n.ts.unrenote : (!defaultStore.state.showVisibilitySelectorOnBoost ? i18n.ts.renoteShift : i18n.ts.renote)"
+					v-tooltip="renoteTooltip"
 					class="_button"
 					:class="$style.noteFooterButton"
 					:style="renoted ? 'color: var(--MI_THEME-accent) !important;' : ''"
@@ -150,6 +150,11 @@ const quoteButton = shallowRef<HTMLElement>();
 const menuButton = shallowRef<HTMLElement>();
 const likeButton = shallowRef<HTMLElement>();
 
+const renoteTooltip = computed(() => {
+	if (renoted.value) return i18n.ts.unrenote;
+	return defaultStore.state.showVisibilitySelectorOnBoost ? i18n.ts.renote : i18n.ts.renoteShift;
+});
+
 let appearNote = computed(() => isRenote ? props.note.renote as Misskey.entities.Note : props.note);
 const defaultLike = computed(() => defaultStore.state.like ? defaultStore.state.like : null);
 const replies = ref<Misskey.entities.Note[]>([]);
-- 
cgit v1.2.3-freya


From 5a76b7069f5120f4b1698b4c254111ad8cce78d9 Mon Sep 17 00:00:00 2001
From: HellhoundSoftware <keith@keithhacks.cyou>
Date: Sat, 18 Jan 2025 08:28:57 -0500
Subject: Refactor renote tooltip text

---
 packages/frontend/src/components/MkNote.vue         |  7 ++-----
 packages/frontend/src/components/MkNoteDetailed.vue |  7 ++-----
 packages/frontend/src/components/MkNoteSub.vue      |  7 ++-----
 packages/frontend/src/components/SkNote.vue         |  7 ++-----
 packages/frontend/src/components/SkNoteDetailed.vue |  7 ++-----
 packages/frontend/src/components/SkNoteSub.vue      |  7 ++-----
 packages/frontend/src/scripts/boost-quote.ts        | 10 +++++++++-
 7 files changed, 21 insertions(+), 31 deletions(-)

(limited to 'packages/frontend/src/components/MkNoteDetailed.vue')

diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue
index 2971252dbf..5c6c6f45bb 100644
--- a/packages/frontend/src/components/MkNote.vue
+++ b/packages/frontend/src/components/MkNote.vue
@@ -239,7 +239,7 @@ import { getNoteSummary } from '@/scripts/get-note-summary.js';
 import MkRippleEffect from '@/components/MkRippleEffect.vue';
 import { showMovedDialog } from '@/scripts/show-moved-dialog.js';
 import { useRouter } from '@/router/supplier.js';
-import { boostMenuItems, type Visibility } from '@/scripts/boost-quote.js';
+import { boostMenuItems, type Visibility, computeRenoteTooltip } from '@/scripts/boost-quote.js';
 import { isEnabledUrlPreview } from '@/instance.js';
 import { type Keymap } from '@/scripts/hotkey.js';
 import { focusPrev, focusNext } from '@/scripts/focus.js';
@@ -339,10 +339,7 @@ const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({
 	url: `https://${host}/notes/${appearNote.value.id}`,
 }));
 
-const renoteTooltip = computed(() => {
-	if (renoted.value) return i18n.ts.unrenote;
-	return defaultStore.state.showVisibilitySelectorOnBoost ? i18n.ts.renote : i18n.ts.renoteShift;
-});
+const renoteTooltip = computeRenoteTooltip(renoted);
 
 /* Overload FunctionにLintが対応していないのでコメントアウト
 function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly: true): boolean;
diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue
index a855b3269c..93a543dff1 100644
--- a/packages/frontend/src/components/MkNoteDetailed.vue
+++ b/packages/frontend/src/components/MkNoteDetailed.vue
@@ -281,7 +281,7 @@ import MkUserCardMini from '@/components/MkUserCardMini.vue';
 import MkPagination, { type Paging } from '@/components/MkPagination.vue';
 import MkReactionIcon from '@/components/MkReactionIcon.vue';
 import MkButton from '@/components/MkButton.vue';
-import { boostMenuItems, type Visibility } from '@/scripts/boost-quote.js';
+import { boostMenuItems, type Visibility, computeRenoteTooltip } from '@/scripts/boost-quote.js';
 import { isEnabledUrlPreview } from '@/instance.js';
 import { getAppearNote } from '@/scripts/get-appear-note.js';
 import { type Keymap } from '@/scripts/hotkey.js';
@@ -348,10 +348,7 @@ const quotes = ref<Misskey.entities.Note[]>([]);
 const canRenote = computed(() => ['public', 'home'].includes(appearNote.value.visibility) || (appearNote.value.visibility === 'followers' && appearNote.value.userId === $i?.id));
 const defaultLike = computed(() => defaultStore.state.like ? defaultStore.state.like : null);
 
-const renoteTooltip = computed(() => {
-	if (renoted.value) return i18n.ts.unrenote;
-	return defaultStore.state.showVisibilitySelectorOnBoost ? i18n.ts.renote : i18n.ts.renoteShift;
-});
+const renoteTooltip = computeRenoteTooltip(renoted);
 
 watch(() => props.expandAllCws, (expandAllCws) => {
 	if (expandAllCws !== showContent.value) showContent.value = expandAllCws;
diff --git a/packages/frontend/src/components/MkNoteSub.vue b/packages/frontend/src/components/MkNoteSub.vue
index 4bdeeb7ab4..9febc1219c 100644
--- a/packages/frontend/src/components/MkNoteSub.vue
+++ b/packages/frontend/src/components/MkNoteSub.vue
@@ -107,7 +107,7 @@ import { reactionPicker } from '@/scripts/reaction-picker.js';
 import { claimAchievement } from '@/scripts/achievements.js';
 import { getNoteMenu } from '@/scripts/get-note-menu.js';
 import { useNoteCapture } from '@/scripts/use-note-capture.js';
-import { boostMenuItems, type Visibility } from '@/scripts/boost-quote.js';
+import { boostMenuItems, type Visibility, computeRenoteTooltip } from '@/scripts/boost-quote.js';
 
 const canRenote = computed(() => ['public', 'home'].includes(props.note.visibility) || props.note.userId === $i.id);
 
@@ -136,10 +136,7 @@ const quoteButton = shallowRef<HTMLElement>();
 const menuButton = shallowRef<HTMLElement>();
 const likeButton = shallowRef<HTMLElement>();
 
-const renoteTooltip = computed(() => {
-	if (renoted.value) return i18n.ts.unrenote;
-	return defaultStore.state.showVisibilitySelectorOnBoost ? i18n.ts.renote : i18n.ts.renoteShift;
-});
+const renoteTooltip = computeRenoteTooltip(computed);
 
 let appearNote = computed(() => isRenote ? props.note.renote as Misskey.entities.Note : props.note);
 const defaultLike = computed(() => defaultStore.state.like ? defaultStore.state.like : null);
diff --git a/packages/frontend/src/components/SkNote.vue b/packages/frontend/src/components/SkNote.vue
index eef6129792..7dc513ff0b 100644
--- a/packages/frontend/src/components/SkNote.vue
+++ b/packages/frontend/src/components/SkNote.vue
@@ -239,7 +239,7 @@ import { getNoteSummary } from '@/scripts/get-note-summary.js';
 import MkRippleEffect from '@/components/MkRippleEffect.vue';
 import { showMovedDialog } from '@/scripts/show-moved-dialog.js';
 import { useRouter } from '@/router/supplier.js';
-import { boostMenuItems, type Visibility } from '@/scripts/boost-quote.js';
+import { boostMenuItems, type Visibility, computeRenoteTooltip } from '@/scripts/boost-quote.js';
 import { isEnabledUrlPreview } from '@/instance.js';
 import { type Keymap } from '@/scripts/hotkey.js';
 import { focusPrev, focusNext } from '@/scripts/focus.js';
@@ -334,10 +334,7 @@ const defaultLike = computed(() => defaultStore.state.like ? defaultStore.state.
 const animated = computed(() => parsed.value ? checkAnimationFromMfm(parsed.value) : null);
 const allowAnim = ref(defaultStore.state.advancedMfm && defaultStore.state.animatedMfm ? true : false);
 
-const renoteTooltip = computed(() => {
-	if (renoted.value) return i18n.ts.unrenote;
-	return defaultStore.state.showVisibilitySelectorOnBoost ? i18n.ts.renote : i18n.ts.renoteShift;
-});
+const renoteTooltip = computeRenoteTooltip(renoted);
 
 const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({
 	type: 'lookup',
diff --git a/packages/frontend/src/components/SkNoteDetailed.vue b/packages/frontend/src/components/SkNoteDetailed.vue
index e7e225304e..69e2898264 100644
--- a/packages/frontend/src/components/SkNoteDetailed.vue
+++ b/packages/frontend/src/components/SkNoteDetailed.vue
@@ -286,7 +286,7 @@ import MkUserCardMini from '@/components/MkUserCardMini.vue';
 import MkPagination, { type Paging } from '@/components/MkPagination.vue';
 import MkReactionIcon from '@/components/MkReactionIcon.vue';
 import MkButton from '@/components/MkButton.vue';
-import { boostMenuItems, type Visibility } from '@/scripts/boost-quote.js';
+import { boostMenuItems, type Visibility, computeRenoteTooltip } from '@/scripts/boost-quote.js';
 import { isEnabledUrlPreview } from '@/instance.js';
 import { getAppearNote } from '@/scripts/get-appear-note.js';
 import { type Keymap } from '@/scripts/hotkey.js';
@@ -354,10 +354,7 @@ const quotes = ref<Misskey.entities.Note[]>([]);
 const canRenote = computed(() => ['public', 'home'].includes(appearNote.value.visibility) || (appearNote.value.visibility === 'followers' && appearNote.value.userId === $i?.id));
 const defaultLike = computed(() => defaultStore.state.like ? defaultStore.state.like : null);
 
-const renoteTooltip = computed(() => {
-	if (renoted.value) return i18n.ts.unrenote;
-	return defaultStore.state.showVisibilitySelectorOnBoost ? i18n.ts.renote : i18n.ts.renoteShift;
-});
+const renoteTooltip = computeRenoteTooltip(renoted);
 
 watch(() => props.expandAllCws, (expandAllCws) => {
 	if (expandAllCws !== showContent.value) showContent.value = expandAllCws;
diff --git a/packages/frontend/src/components/SkNoteSub.vue b/packages/frontend/src/components/SkNoteSub.vue
index ff8af5b72b..a3d27c9e16 100644
--- a/packages/frontend/src/components/SkNoteSub.vue
+++ b/packages/frontend/src/components/SkNoteSub.vue
@@ -115,7 +115,7 @@ import { reactionPicker } from '@/scripts/reaction-picker.js';
 import { claimAchievement } from '@/scripts/achievements.js';
 import { getNoteMenu } from '@/scripts/get-note-menu.js';
 import { useNoteCapture } from '@/scripts/use-note-capture.js';
-import { boostMenuItems, type Visibility } from '@/scripts/boost-quote.js';
+import { boostMenuItems, type Visibility, computeRenoteTooltip } from '@/scripts/boost-quote.js';
 
 const canRenote = computed(() => ['public', 'home'].includes(props.note.visibility) || props.note.userId === $i.id);
 const hideLine = computed(() => { return props.detail ? true : false; });
@@ -150,10 +150,7 @@ const quoteButton = shallowRef<HTMLElement>();
 const menuButton = shallowRef<HTMLElement>();
 const likeButton = shallowRef<HTMLElement>();
 
-const renoteTooltip = computed(() => {
-	if (renoted.value) return i18n.ts.unrenote;
-	return defaultStore.state.showVisibilitySelectorOnBoost ? i18n.ts.renote : i18n.ts.renoteShift;
-});
+const renoteTooltip = computeRenoteTooltip(renoted);
 
 let appearNote = computed(() => isRenote ? props.note.renote as Misskey.entities.Note : props.note);
 const defaultLike = computed(() => defaultStore.state.like ? defaultStore.state.like : null);
diff --git a/packages/frontend/src/scripts/boost-quote.ts b/packages/frontend/src/scripts/boost-quote.ts
index 4e025f5d4f..feb949772b 100644
--- a/packages/frontend/src/scripts/boost-quote.ts
+++ b/packages/frontend/src/scripts/boost-quote.ts
@@ -3,7 +3,7 @@
  * SPDX-License-Identifier: AGPL-3.0-only
 */
 
-import { ref, Ref } from 'vue';
+import { ref, Ref, computed, ComputedRef } from 'vue';
 import * as Misskey from 'misskey-js';
 import { i18n } from '@/i18n.js';
 import { defaultStore } from '@/store.js';
@@ -79,3 +79,11 @@ export function boostMenuItems(appearNote: Ref<Misskey.entities.Note>, renote: (
 		} as MenuItem,
 	];
 }
+
+export function computeRenoteTooltip(renoted: Ref<boolean>): ComputedRef<string> {
+	return computed(() => {
+		if (renoted.value) return i18n.ts.unrenote;
+		if (defaultStore.state.showVisibilitySelectorOnBoost) return i18n.ts.renote;
+		return i18n.ts.renoteShift;
+	});
+}
-- 
cgit v1.2.3-freya


From 078b7d6cc5bc8876dd36c89ee437cf3c0dc3cd56 Mon Sep 17 00:00:00 2001
From: taichan <40626578+tai-cha@users.noreply.github.com>
Date: Sun, 19 Jan 2025 20:12:35 +0900
Subject: fix(frontend):
 アンケート(投票)でリモートの絵文字が正しく描画できない問題の修正 (#15306)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* アンケート選択肢にリモート絵文字を表示

* MkSubNoteContentでのリモート絵文字の描画

* Update Changelog

---------

Co-authored-by: kozakura913 <98575220+kozakura913@users.noreply.github.com>
---
 CHANGELOG.md                                          | 2 ++
 packages/frontend/src/components/MkNote.vue           | 2 +-
 packages/frontend/src/components/MkNoteDetailed.vue   | 2 +-
 packages/frontend/src/components/MkPoll.vue           | 4 +++-
 packages/frontend/src/components/MkSubNoteContent.vue | 2 +-
 5 files changed, 8 insertions(+), 4 deletions(-)

(limited to 'packages/frontend/src/components/MkNoteDetailed.vue')

diff --git a/CHANGELOG.md b/CHANGELOG.md
index cc84130d5b..76268a43c0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -39,6 +39,8 @@
 - Fix: RSSウィジェットが正しく表示されない問題を修正  
   (Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/857)
 - Fix: ワードミュートの保存失敗時にAPIエラーが握りつぶされる事があるのを修正
+- Fix: アンケートでリモートの絵文字が正しく描画できない問題の修正
+  (Cherry-picked from https://github.com/yojo-art/cherrypick/pull/153)
 
 ### Server
 - Enhance: pg_bigmが利用できるよう、ノートの検索をILIKE演算子でなくLIKE演算子でLOWER()をかけたテキストに対して行うように
diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue
index 4c26444b35..06183a5747 100644
--- a/packages/frontend/src/components/MkNote.vue
+++ b/packages/frontend/src/components/MkNote.vue
@@ -88,7 +88,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 					<div v-if="appearNote.files && appearNote.files.length > 0">
 						<MkMediaList ref="galleryEl" :mediaList="appearNote.files"/>
 					</div>
-					<MkPoll v-if="appearNote.poll" :noteId="appearNote.id" :poll="appearNote.poll" :class="$style.poll"/>
+					<MkPoll v-if="appearNote.poll" :noteId="appearNote.id" :poll="appearNote.poll" :author="appearNote.user" :emojiUrls="appearNote.emojis" :class="$style.poll"/>
 					<div v-if="isEnabledUrlPreview">
 						<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" :class="$style.urlPreview"/>
 					</div>
diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue
index 4a350388c2..b6ae478f28 100644
--- a/packages/frontend/src/components/MkNoteDetailed.vue
+++ b/packages/frontend/src/components/MkNoteDetailed.vue
@@ -109,7 +109,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 				<div v-if="appearNote.files && appearNote.files.length > 0">
 					<MkMediaList ref="galleryEl" :mediaList="appearNote.files"/>
 				</div>
-				<MkPoll v-if="appearNote.poll" ref="pollViewer" :noteId="appearNote.id" :poll="appearNote.poll" :class="$style.poll"/>
+				<MkPoll v-if="appearNote.poll" ref="pollViewer" :noteId="appearNote.id" :poll="appearNote.poll" :class="$style.poll" :author="appearNote.user" :emojiUrls="appearNote.emojis"/>
 				<div v-if="isEnabledUrlPreview">
 					<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="true" style="margin-top: 6px;"/>
 				</div>
diff --git a/packages/frontend/src/components/MkPoll.vue b/packages/frontend/src/components/MkPoll.vue
index e70ac7ff1a..1b2b3e48ba 100644
--- a/packages/frontend/src/components/MkPoll.vue
+++ b/packages/frontend/src/components/MkPoll.vue
@@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 			<div :class="$style.bg" :style="{ 'width': `${showResult ? (choice.votes / total * 100) : 0}%` }"></div>
 			<span :class="$style.fg">
 				<template v-if="choice.isVoted"><i class="ti ti-check" style="margin-right: 4px; color: var(--MI_THEME-accent);"></i></template>
-				<Mfm :text="choice.text" :plain="true"/>
+				<Mfm :text="choice.text" :plain="true" :author="author" :emojiUrls="emojiUrls"/>
 				<span v-if="showResult" style="margin-left: 4px; opacity: 0.7;">({{ i18n.tsx._poll.votesCount({ n: choice.votes }) }})</span>
 			</span>
 		</li>
@@ -42,6 +42,8 @@ const props = defineProps<{
 	noteId: string;
 	poll: NonNullable<Misskey.entities.Note['poll']>;
 	readOnly?: boolean;
+	emojiUrls?: Record<string, string>;
+	author?: Misskey.entities.UserLite;
 }>();
 
 const remaining = ref(-1);
diff --git a/packages/frontend/src/components/MkSubNoteContent.vue b/packages/frontend/src/components/MkSubNoteContent.vue
index 9e02884b8c..5f64e3bd59 100644
--- a/packages/frontend/src/components/MkSubNoteContent.vue
+++ b/packages/frontend/src/components/MkSubNoteContent.vue
@@ -18,7 +18,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 	</details>
 	<details v-if="note.poll">
 		<summary>{{ i18n.ts.poll }}</summary>
-		<MkPoll :noteId="note.id" :poll="note.poll"/>
+		<MkPoll :noteId="note.id" :poll="note.poll" :author="appearNote.user" :emojiUrls="appearNote.emojis"/>
 	</details>
 	<button v-if="isLong && collapsed" :class="$style.fade" class="_button" @click="collapsed = false">
 		<span :class="$style.fadeLabel">{{ i18n.ts.showMore }}</span>
-- 
cgit v1.2.3-freya


From e8b633efec5a26b6534cd59a3ca1dd14899defd1 Mon Sep 17 00:00:00 2001
From: かっこかり <67428053+kakkokari-gtyih@users.noreply.github.com>
Date: Tue, 21 Jan 2025 10:26:47 +0900
Subject: fix(frontend):
 Instanceの値が部分的に欠損していると、ローカルサーバーの情報にフォールバックする問題を修正
 (#15319)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 packages/frontend/src/components/MkInstanceTicker.vue | 19 ++++++++++++++++---
 packages/frontend/src/components/MkNote.vue           |  2 +-
 packages/frontend/src/components/MkNoteDetailed.vue   |  2 +-
 3 files changed, 18 insertions(+), 5 deletions(-)

(limited to 'packages/frontend/src/components/MkNoteDetailed.vue')

diff --git a/packages/frontend/src/components/MkInstanceTicker.vue b/packages/frontend/src/components/MkInstanceTicker.vue
index 570c50b627..70c33a692d 100644
--- a/packages/frontend/src/components/MkInstanceTicker.vue
+++ b/packages/frontend/src/components/MkInstanceTicker.vue
@@ -17,6 +17,7 @@ import { instance as localInstance } from '@/instance.js';
 import { getProxiedImageUrlNullable } from '@/scripts/media-proxy.js';
 
 const props = defineProps<{
+	host: string | null;
 	instance?: {
 		faviconUrl?: string | null
 		name?: string | null
@@ -25,12 +26,24 @@ const props = defineProps<{
 }>();
 
 // if no instance data is given, this is for the local instance
-const instanceName = computed(() => props.instance?.name ?? localInstanceName);
+const instanceName = computed(() => props.host == null ? localInstanceName : props.instance?.name ?? props.host);
 
-const faviconUrl = computed(() => getProxiedImageUrlNullable(props.instance?.faviconUrl ?? localInstance.iconUrl, 'preview') ?? '/favicon.ico');
+const faviconUrl = computed(() => {
+	let imageSrc: string | null = null;
+	if (props.host == null) {
+		if (localInstance.iconUrl == null) {
+			return '/favicon.ico';
+		} else {
+			imageSrc = localInstance.iconUrl;
+		}
+	} else {
+		imageSrc = props.instance?.faviconUrl ?? null;
+	}
+	return getProxiedImageUrlNullable(imageSrc);
+});
 
 const themeColorStyle = computed<CSSProperties>(() => {
-	const themeColor = props.instance?.themeColor ?? localInstance.themeColor ?? '#777777';
+	const themeColor = (props.host == null ? localInstance.themeColor : props.instance?.themeColor) ?? '#777777';
 	return {
 		background: `linear-gradient(90deg, ${themeColor}, ${themeColor}00)`,
 	};
diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue
index 06183a5747..52d0485743 100644
--- a/packages/frontend/src/components/MkNote.vue
+++ b/packages/frontend/src/components/MkNote.vue
@@ -50,7 +50,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 		<MkAvatar :class="$style.avatar" :user="appearNote.user" :link="!mock" :preview="!mock"/>
 		<div :class="$style.main">
 			<MkNoteHeader :note="appearNote" :mini="true"/>
-			<MkInstanceTicker v-if="showTicker" :instance="appearNote.user.instance"/>
+			<MkInstanceTicker v-if="showTicker" :host="appearNote.user.host" :instance="appearNote.user.instance"/>
 			<div style="container-type: inline-size;">
 				<p v-if="appearNote.cw != null" :class="$style.cw">
 					<Mfm
diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue
index b6ae478f28..9d3374d433 100644
--- a/packages/frontend/src/components/MkNoteDetailed.vue
+++ b/packages/frontend/src/components/MkNoteDetailed.vue
@@ -70,7 +70,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 						<img v-for="(role, i) in appearNote.user.badgeRoles" :key="i" v-tooltip="role.name" :class="$style.noteHeaderBadgeRole" :src="role.iconUrl!"/>
 					</div>
 				</div>
-				<MkInstanceTicker v-if="showTicker" :instance="appearNote.user.instance"/>
+				<MkInstanceTicker v-if="showTicker" :host="appearNote.user.host" :instance="appearNote.user.instance"/>
 			</div>
 		</header>
 		<div :class="$style.noteContent">
-- 
cgit v1.2.3-freya


From c6eb1ffde348d52173f34e2dbfe1587865776dc4 Mon Sep 17 00:00:00 2001
From: dakkar <dakkar@thenautilus.net>
Date: Mon, 10 Feb 2025 11:37:03 +0000
Subject: hide note preview if it's already quoted - fixes #888

I do not particularly _love_ this solution, but it is simple and
effective

I can't find another reliable way to determine if a link goes to a
local note of the given id, apart from fetching the link
---
 packages/frontend/src/components/MkNote.vue         |  2 +-
 packages/frontend/src/components/MkNoteDetailed.vue |  2 +-
 packages/frontend/src/components/MkUrlPreview.vue   | 10 +++++++++-
 packages/frontend/src/components/SkNote.vue         |  2 +-
 packages/frontend/src/components/SkNoteDetailed.vue |  2 +-
 5 files changed, 13 insertions(+), 5 deletions(-)

(limited to 'packages/frontend/src/components/MkNoteDetailed.vue')

diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue
index 5c6c6f45bb..69c6b4f357 100644
--- a/packages/frontend/src/components/MkNote.vue
+++ b/packages/frontend/src/components/MkNote.vue
@@ -102,7 +102,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 					</div>
 					<MkPoll v-if="appearNote.poll" :noteId="appearNote.id" :poll="appearNote.poll" :local="!appearNote.user.host" :class="$style.poll" @click.stop/>
 					<div v-if="isEnabledUrlPreview">
-						<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" :showAsQuote="true" :class="$style.urlPreview" @click.stop/>
+						<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" :showAsQuote="true" :skipNoteIds="[appearNote.renote?.id]" :class="$style.urlPreview" @click.stop/>
 					</div>
 					<div v-if="appearNote.renote" :class="$style.quote"><MkNoteSimple :note="appearNote.renote" :class="$style.quoteNote"/></div>
 					<button v-if="isLong && collapsed" :class="$style.collapsed" class="_button" @click.stop @click="collapsed = false">
diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue
index 93a543dff1..2eb4431de2 100644
--- a/packages/frontend/src/components/MkNoteDetailed.vue
+++ b/packages/frontend/src/components/MkNoteDetailed.vue
@@ -117,7 +117,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 				</div>
 				<MkPoll v-if="appearNote.poll" ref="pollViewer" :noteId="appearNote.id" :poll="appearNote.poll" :local="!appearNote.user.host" :class="$style.poll"/>
 				<div v-if="isEnabledUrlPreview">
-					<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="true" :showAsQuote="true" style="margin-top: 6px;"/>
+					<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="true" :showAsQuote="true" :skipNoteIds="[appearNote.renote?.id]" style="margin-top: 6px;"/>
 				</div>
 				<div v-if="appearNote.renote" :class="$style.quote"><MkNoteSimple :note="appearNote.renote" :class="$style.quoteNote" :expandAllCws="props.expandAllCws"/></div>
 			</div>
diff --git a/packages/frontend/src/components/MkUrlPreview.vue b/packages/frontend/src/components/MkUrlPreview.vue
index 20982bc5d3..7cfd831044 100644
--- a/packages/frontend/src/components/MkUrlPreview.vue
+++ b/packages/frontend/src/components/MkUrlPreview.vue
@@ -44,7 +44,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 	</div>
 </template>
 <div v-else-if="theNote" :class="[$style.link, { [$style.compact]: compact }]"><XNoteSimple :note="theNote" :class="$style.body"/></div>
-<div v-else>
+<div v-else-if="!hidePreview">
 	<component :is="self ? 'MkA' : 'a'" :class="[$style.link, { [$style.compact]: compact }]" :[attr]="self ? url.substring(local.length) : url" rel="nofollow noopener" :target="target" :title="url">
 		<div v-if="thumbnail && !sensitive" :class="$style.thumbnail" :style="defaultStore.state.dataSaver.urlPreview ? '' : `background-image: url('${thumbnail}')`">
 		</div>
@@ -111,16 +111,19 @@ const props = withDefaults(defineProps<{
 	compact?: boolean;
 	showAsQuote?: boolean;
 	showActions?: boolean;
+	skipNoteIds?: string[];
 }>(), {
 	detail: false,
 	compact: false,
 	showAsQuote: false,
 	showActions: true,
+	skipNoteIds: [],
 });
 
 const MOBILE_THRESHOLD = 500;
 const isMobile = ref(deviceKind === 'smartphone' || window.innerWidth <= MOBILE_THRESHOLD);
 
+const hidePreview = ref<boolean>(false);
 const self = props.url.startsWith(local);
 const attr = self ? 'to' : 'href';
 const target = self ? null : '_blank';
@@ -155,6 +158,11 @@ watch(activityPub, async (uri) => {
 		try {
 			const response = await misskeyApi('ap/show', { uri });
 			if (response.type !== 'Note') return;
+			const theNoteId = response['object'].id;
+			if (theNoteId && props.skipNoteIds.includes(theNoteId)) {
+				hidePreview.value = true;
+				return;
+			}
 			theNote.value = response['object'];
 		} catch (err) {
 			if (_DEV_) {
diff --git a/packages/frontend/src/components/SkNote.vue b/packages/frontend/src/components/SkNote.vue
index 7dc513ff0b..7cd5c2e0cf 100644
--- a/packages/frontend/src/components/SkNote.vue
+++ b/packages/frontend/src/components/SkNote.vue
@@ -104,7 +104,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 					</div>
 					<MkPoll v-if="appearNote.poll" :noteId="appearNote.id" :poll="appearNote.poll" :local="!appearNote.user.host" :class="$style.poll" @click.stop/>
 					<div v-if="isEnabledUrlPreview">
-						<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" :showAsQuote="true" :class="$style.urlPreview" @click.stop/>
+						<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" :showAsQuote="true" :skipNoteIds="[appearNote.renote?.id]" :class="$style.urlPreview" @click.stop/>
 					</div>
 					<div v-if="appearNote.renote" :class="$style.quote"><SkNoteSimple :note="appearNote.renote" :class="$style.quoteNote"/></div>
 					<button v-if="isLong && collapsed" :class="$style.collapsed" class="_button" @click.stop @click="collapsed = false">
diff --git a/packages/frontend/src/components/SkNoteDetailed.vue b/packages/frontend/src/components/SkNoteDetailed.vue
index 69e2898264..7b45885c3e 100644
--- a/packages/frontend/src/components/SkNoteDetailed.vue
+++ b/packages/frontend/src/components/SkNoteDetailed.vue
@@ -122,7 +122,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 				</div>
 				<MkPoll v-if="appearNote.poll" ref="pollViewer" :noteId="appearNote.id" :poll="appearNote.poll" :local="!appearNote.user.host" :class="$style.poll"/>
 				<div v-if="isEnabledUrlPreview">
-					<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="true" :showAsQuote="true" style="margin-top: 6px;"/>
+					<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="true" :showAsQuote="true" :skipNoteIds="[appearNote.renote?.id]" style="margin-top: 6px;"/>
 				</div>
 				<div v-if="appearNote.renote" :class="$style.quote"><SkNoteSimple :note="appearNote.renote" :class="$style.quoteNote" :expandAllCws="props.expandAllCws"/></div>
 			</div>
-- 
cgit v1.2.3-freya


From 905b63764885b3961d1b3955032e6202682d9b8b Mon Sep 17 00:00:00 2001
From: Hazelnoot <acomputerdog@gmail.com>
Date: Wed, 12 Feb 2025 14:47:17 -0500
Subject: append mandatory CW in all note views (Mk/Sk/Em +
 basic/Detailed/Simple/Sub)

---
 packages/frontend-embed/src/components/EmNote.vue       | 15 ++++++++++++---
 .../frontend-embed/src/components/EmNoteDetailed.vue    | 15 ++++++++++++---
 packages/frontend-embed/src/components/EmNoteSimple.vue | 17 +++++++++++++----
 packages/frontend-embed/src/components/EmNoteSub.vue    | 17 +++++++++++++----
 packages/frontend/src/components/MkNote.vue             | 17 +++++++++++++----
 packages/frontend/src/components/MkNoteDetailed.vue     | 17 +++++++++++++----
 packages/frontend/src/components/MkNoteSimple.vue       | 17 +++++++++++++----
 packages/frontend/src/components/MkNoteSub.vue          | 15 ++++++++++++---
 packages/frontend/src/components/SkNote.vue             | 17 +++++++++++++----
 packages/frontend/src/components/SkNoteDetailed.vue     | 17 +++++++++++++----
 packages/frontend/src/components/SkNoteSimple.vue       | 17 +++++++++++++----
 packages/frontend/src/components/SkNoteSub.vue          | 15 ++++++++++++---
 12 files changed, 152 insertions(+), 44 deletions(-)

(limited to 'packages/frontend/src/components/MkNoteDetailed.vue')

diff --git a/packages/frontend-embed/src/components/EmNote.vue b/packages/frontend-embed/src/components/EmNote.vue
index 025c4c0734..a918ae8db4 100644
--- a/packages/frontend-embed/src/components/EmNote.vue
+++ b/packages/frontend-embed/src/components/EmNote.vue
@@ -46,11 +46,11 @@ SPDX-License-Identifier: AGPL-3.0-only
 			<EmNoteHeader :note="appearNote" :mini="true"/>
 			<EmInstanceTicker v-if="appearNote.user.instance != null" :instance="appearNote.user.instance"/>
 			<div style="container-type: inline-size;">
-				<p v-if="appearNote.cw != null" :class="$style.cw">
-					<EmMfm v-if="appearNote.cw != ''" style="margin-right: 8px;" :text="appearNote.cw" :author="appearNote.user" :nyaize="'respect'" :isBlock="true"/>
+				<p v-if="mergedCW != null" :class="$style.cw">
+					<EmMfm v-if="mergedCW != ''" style="margin-right: 8px;" :text="mergedCW" :author="appearNote.user" :nyaize="'respect'" :isBlock="true"/>
 					<button style="display: block; width: 100%; margin: 4px 0;" class="_buttonGray _buttonRounded" @click="showContent = !showContent">{{ showContent ? i18n.ts._cw.hide : i18n.ts._cw.show }}</button>
 				</p>
-				<div v-show="appearNote.cw == null || showContent" :class="[{ [$style.contentCollapsed]: collapsed }]">
+				<div v-show="mergedCW == null || showContent" :class="[{ [$style.contentCollapsed]: collapsed }]">
 					<div :class="$style.text">
 						<span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span>
 						<EmA v-if="appearNote.replyId" :class="$style.replyIcon" :to="`/notes/${appearNote.replyId}`"><i class="ti ti-arrow-back-up"></i></EmA>
@@ -109,6 +109,7 @@ import * as mfm from '@transfem-org/sfm-js';
 import * as Misskey from 'misskey-js';
 import { shouldCollapsed } from '@@/js/collapsed.js';
 import { url } from '@@/js/config.js';
+import { appendContentWarning } from '@@/js/append-content-warning.js';
 import I18n from '@/components/I18n.vue';
 import EmNoteSub from '@/components/EmNoteSub.vue';
 import EmNoteHeader from '@/components/EmNoteHeader.vue';
@@ -154,6 +155,14 @@ const parsed = computed(() => appearNote.value.text ? mfm.parse(appearNote.value
 const isLong = shouldCollapsed(appearNote.value, []);
 const collapsed = ref(appearNote.value.cw == null && isLong);
 const isDeleted = ref(false);
+
+const mergedCW = computed(() => {
+	let cw = appearNote.value.cw;
+	if (appearNote.value.user.mandatoryCW) {
+		cw = appendContentWarning(cw, appearNote.value.user.mandatoryCW);
+	}
+	return cw;
+});
 </script>
 
 <style lang="scss" module>
diff --git a/packages/frontend-embed/src/components/EmNoteDetailed.vue b/packages/frontend-embed/src/components/EmNoteDetailed.vue
index c4ea9b4f2e..10a58feaef 100644
--- a/packages/frontend-embed/src/components/EmNoteDetailed.vue
+++ b/packages/frontend-embed/src/components/EmNoteDetailed.vue
@@ -58,11 +58,11 @@ SPDX-License-Identifier: AGPL-3.0-only
 			</div>
 		</header>
 		<div :class="[$style.noteContent, { [$style.contentCollapsed]: collapsed }]">
-			<p v-if="appearNote.cw != null" :class="$style.cw">
-				<EmMfm v-if="appearNote.cw != ''" style="margin-right: 8px;" :text="appearNote.cw" :author="appearNote.user" :nyaize="'respect'" :isBlock="true"/>
+			<p v-if="mergedCW != null" :class="$style.cw">
+				<EmMfm v-if="mergedCW != ''" style="margin-right: 8px;" :text="mergedCW" :author="appearNote.user" :nyaize="'respect'" :isBlock="true"/>
 				<button style="display: block; width: 100%; margin: 4px 0;" class="_buttonGray _buttonRounded" @click="showContent = !showContent">{{ showContent ? i18n.ts._cw.hide : i18n.ts._cw.show }}</button>
 			</p>
-			<div v-show="appearNote.cw == null || showContent">
+			<div v-show="mergedCW == null || showContent">
 				<span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span>
 				<EmA v-if="appearNote.replyId" :class="$style.noteReplyTarget" :to="`/notes/${appearNote.replyId}`"><i class="ti ti-arrow-back-up"></i></EmA>
 				<EmMfm
@@ -130,6 +130,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { computed, inject, ref } from 'vue';
 import * as mfm from '@transfem-org/sfm-js';
 import * as Misskey from 'misskey-js';
+import { appendContentWarning } from '@@/js/append-content-warning.js';
 import I18n from '@/components/I18n.vue';
 import EmMediaList from '@/components/EmMediaList.vue';
 import EmNoteSub from '@/components/EmNoteSub.vue';
@@ -175,6 +176,14 @@ const isDeleted = ref(false);
 const parsed = appearNote.value.text ? mfm.parse(appearNote.value.text) : null;
 const isLong = shouldCollapsed(appearNote.value, []);
 const collapsed = ref(appearNote.value.cw == null && isLong);
+
+const mergedCW = computed(() => {
+	let cw = appearNote.value.cw;
+	if (appearNote.value.user.mandatoryCW) {
+		cw = appendContentWarning(cw, appearNote.value.user.mandatoryCW);
+	}
+	return cw;
+});
 </script>
 
 <style lang="scss" module>
diff --git a/packages/frontend-embed/src/components/EmNoteSimple.vue b/packages/frontend-embed/src/components/EmNoteSimple.vue
index 83e73f9870..74aed4e28b 100644
--- a/packages/frontend-embed/src/components/EmNoteSimple.vue
+++ b/packages/frontend-embed/src/components/EmNoteSimple.vue
@@ -9,11 +9,11 @@ SPDX-License-Identifier: AGPL-3.0-only
 	<div :class="$style.main">
 		<EmNoteHeader :class="$style.header" :note="note" :mini="true"/>
 		<div>
-			<p v-if="note.cw != null" :class="$style.cw">
-				<EmMfm v-if="note.cw != ''" style="margin-right: 8px;" :text="note.cw" :author="note.user" :nyaize="'respect'" :emojiUrls="note.emojis" :isBlock="true"/>
+			<p v-if="mergedCW != null" :class="$style.cw">
+				<EmMfm v-if="mergedCW != ''" style="margin-right: 8px;" :text="mergedCW" :author="note.user" :nyaize="'respect'" :emojiUrls="note.emojis" :isBlock="true"/>
 				<button style="display: block; width: 100%;" class="_buttonGray _buttonRounded" @click="showContent = !showContent">{{ showContent ? i18n.ts._cw.hide : i18n.ts._cw.show }}</button>
 			</p>
-			<div v-show="note.cw == null || showContent">
+			<div v-show="mergedCW == null || showContent">
 				<EmSubNoteContent :class="$style.text" :note="note"/>
 			</div>
 		</div>
@@ -22,8 +22,9 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { ref } from 'vue';
+import { computed, ref } from 'vue';
 import * as Misskey from 'misskey-js';
+import { appendContentWarning } from '@@/js/append-content-warning.js';
 import { i18n } from '@/i18n.js';
 import EmAvatar from '@/components/EmAvatar.vue';
 import EmNoteHeader from '@/components/EmNoteHeader.vue';
@@ -35,6 +36,14 @@ const props = defineProps<{
 }>();
 
 const showContent = ref(false);
+
+const mergedCW = computed(() => {
+	let cw = props.note.cw;
+	if (props.note.user.mandatoryCW) {
+		cw = appendContentWarning(cw, props.note.user.mandatoryCW);
+	}
+	return cw;
+});
 </script>
 
 <style lang="scss" module>
diff --git a/packages/frontend-embed/src/components/EmNoteSub.vue b/packages/frontend-embed/src/components/EmNoteSub.vue
index cc379e8281..e1302bbe25 100644
--- a/packages/frontend-embed/src/components/EmNoteSub.vue
+++ b/packages/frontend-embed/src/components/EmNoteSub.vue
@@ -11,11 +11,11 @@ SPDX-License-Identifier: AGPL-3.0-only
 		<div :class="$style.body">
 			<EmNoteHeader :class="$style.header" :note="note" :mini="true"/>
 			<div>
-				<p v-if="note.cw != null" :class="$style.cw">
-					<EmMfm v-if="note.cw != ''" style="margin-right: 8px;" :text="note.cw" :author="note.user" :nyaize="'respect'" :isBlock="true"/>
+				<p v-if="mergedCW != null" :class="$style.cw">
+					<EmMfm v-if="mergedCW != ''" style="margin-right: 8px;" :text="mergedCW" :author="note.user" :nyaize="'respect'" :isBlock="true"/>
 					<button style="display: block; width: 100%;" class="_buttonGray _buttonRounded" @click="showContent = !showContent">{{ showContent ? i18n.ts._cw.hide : i18n.ts._cw.show }}</button>
 				</p>
-				<div v-show="note.cw == null || showContent">
+				<div v-show="mergedCW == null || showContent">
 					<EmSubNoteContent :class="$style.text" :note="note"/>
 				</div>
 			</div>
@@ -31,8 +31,9 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { ref } from 'vue';
+import { computed, ref } from 'vue';
 import * as Misskey from 'misskey-js';
+import { appendContentWarning } from '@@/js/append-content-warning.js';
 import EmA from '@/components/EmA.vue';
 import EmAvatar from '@/components/EmAvatar.vue';
 import EmNoteHeader from '@/components/EmNoteHeader.vue';
@@ -55,6 +56,14 @@ const props = withDefaults(defineProps<{
 const showContent = ref(false);
 const replies = ref<Misskey.entities.Note[]>([]);
 
+const mergedCW = computed(() => {
+	let cw = props.note.cw;
+	if (props.note.user.mandatoryCW) {
+		cw = appendContentWarning(cw, props.note.user.mandatoryCW);
+	}
+	return cw;
+});
+
 if (props.detail) {
 	misskeyApi('notes/children', {
 		noteId: props.note.id,
diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue
index 9271e9e4b7..aa971b0ab3 100644
--- a/packages/frontend/src/components/MkNote.vue
+++ b/packages/frontend/src/components/MkNote.vue
@@ -59,10 +59,10 @@ SPDX-License-Identifier: AGPL-3.0-only
 			<MkInstanceTicker v-if="showTicker" :host="appearNote.user.host" :instance="appearNote.user.instance"/>
 			<div style="container-type: inline-size;">
 				<bdi>
-				<p v-if="appearNote.cw != null" :class="$style.cw">
+				<p v-if="mergedCW != null" :class="$style.cw">
 					<Mfm
-						v-if="appearNote.cw != ''"
-						:text="appearNote.cw"
+						v-if="mergedCW != ''"
+						:text="mergedCW"
 						:author="appearNote.user"
 						:nyaize="'respect'"
 						:enableEmojiMenu="true"
@@ -71,7 +71,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 					/>
 					<MkCwButton v-model="showContent" :text="appearNote.text" :renote="appearNote.renote" :files="appearNote.files" :poll="appearNote.poll" style="margin: 4px 0;" @click.stop/>
 				</p>
-				<div v-show="appearNote.cw == null || showContent" :class="[{ [$style.contentCollapsed]: collapsed }]">
+				<div v-show="mergedCW == null || showContent" :class="[{ [$style.contentCollapsed]: collapsed }]">
 					<div :class="$style.text">
 						<span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span>
 						<MkA v-if="appearNote.replyId" :class="$style.replyIcon" :to="`/notes/${appearNote.replyId}`"><i class="ph-arrow-bend-left-up ph-bold ph-lg"></i></MkA>
@@ -212,6 +212,7 @@ import * as Misskey from 'misskey-js';
 import { isLink } from '@@/js/is-link.js';
 import { shouldCollapsed } from '@@/js/collapsed.js';
 import { host } from '@@/js/config.js';
+import { appendContentWarning } from '@@/js/append-content-warning.js';
 import type { MenuItem } from '@/types/menu.js';
 import MkNoteSub from '@/components/MkNoteSub.vue';
 import MkNoteHeader from '@/components/MkNoteHeader.vue';
@@ -350,6 +351,14 @@ const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({
 	url: `https://${host}/notes/${appearNote.value.id}`,
 }));
 
+const mergedCW = computed(() => {
+	let cw = appearNote.value.cw;
+	if (appearNote.value.user.mandatoryCW) {
+		cw = appendContentWarning(cw, appearNote.value.user.mandatoryCW);
+	}
+	return cw;
+});
+
 const renoteTooltip = computeRenoteTooltip(renoted);
 
 /* Overload FunctionにLintが対応していないのでコメントアウト
diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue
index 6c52714f46..5f7c4fa12f 100644
--- a/packages/frontend/src/components/MkNoteDetailed.vue
+++ b/packages/frontend/src/components/MkNoteDetailed.vue
@@ -75,10 +75,10 @@ SPDX-License-Identifier: AGPL-3.0-only
 			</div>
 		</header>
 		<div :class="$style.noteContent">
-			<p v-if="appearNote.cw != null" :class="$style.cw">
+			<p v-if="mergedCW != null" :class="$style.cw">
 				<Mfm
-					v-if="appearNote.cw != ''"
-					:text="appearNote.cw"
+					v-if="mergedCW != ''"
+					:text="mergedCW"
 					:author="appearNote.user"
 					:nyaize="'respect'"
 					:enableEmojiMenu="true"
@@ -87,7 +87,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 				/>
 				<MkCwButton v-model="showContent" :text="appearNote.text" :renote="appearNote.renote" :files="appearNote.files" :poll="appearNote.poll"/>
 			</p>
-			<div v-show="appearNote.cw == null || showContent">
+			<div v-show="mergedCW == null || showContent">
 				<span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span>
 				<MkA v-if="appearNote.replyId" :class="$style.noteReplyTarget" :to="`/notes/${appearNote.replyId}`"><i class="ph-arrow-bend-left-up ph-bold ph-lg"></i></MkA>
 				<Mfm
@@ -245,6 +245,7 @@ import * as mfm from '@transfem-org/sfm-js';
 import * as Misskey from 'misskey-js';
 import { isLink } from '@@/js/is-link.js';
 import { host } from '@@/js/config.js';
+import { appendContentWarning } from '@@/js/append-content-warning.js';
 import MkNoteSub from '@/components/MkNoteSub.vue';
 import MkNoteSimple from '@/components/MkNoteSimple.vue';
 import MkReactionsViewer from '@/components/MkReactionsViewer.vue';
@@ -348,6 +349,14 @@ const quotes = ref<Misskey.entities.Note[]>([]);
 const canRenote = computed(() => ['public', 'home'].includes(appearNote.value.visibility) || (appearNote.value.visibility === 'followers' && appearNote.value.userId === $i?.id));
 const defaultLike = computed(() => defaultStore.state.like ? defaultStore.state.like : null);
 
+const mergedCW = computed(() => {
+	let cw = appearNote.value.cw;
+	if (appearNote.value.user.mandatoryCW) {
+		cw = appendContentWarning(cw, appearNote.value.user.mandatoryCW);
+	}
+	return cw;
+});
+
 const renoteTooltip = computeRenoteTooltip(renoted);
 
 watch(() => props.expandAllCws, (expandAllCws) => {
diff --git a/packages/frontend/src/components/MkNoteSimple.vue b/packages/frontend/src/components/MkNoteSimple.vue
index 9ef45a0ec3..e487fb978e 100644
--- a/packages/frontend/src/components/MkNoteSimple.vue
+++ b/packages/frontend/src/components/MkNoteSimple.vue
@@ -9,11 +9,11 @@ SPDX-License-Identifier: AGPL-3.0-only
 	<div :class="$style.main">
 		<MkNoteHeader :class="$style.header" :note="note" :mini="true"/>
 		<div>
-			<p v-if="note.cw != null" :class="$style.cw">
-				<Mfm v-if="note.cw != ''" style="margin-right: 8px;" :text="note.cw" :isBlock="true" :author="note.user" :nyaize="'respect'" :emojiUrls="note.emojis"/>
+			<p v-if="mergedCW != null" :class="$style.cw">
+				<Mfm v-if="mergedCW != ''" style="margin-right: 8px;" :text="mergedCW" :isBlock="true" :author="note.user" :nyaize="'respect'" :emojiUrls="note.emojis"/>
 				<MkCwButton v-model="showContent" :text="note.text" :files="note.files" :poll="note.poll" @click.stop/>
 			</p>
-			<div v-show="note.cw == null || showContent">
+			<div v-show="mergedCW == null || showContent">
 				<MkSubNoteContent :hideFiles="hideFiles" :class="$style.text" :note="note" :expandAllCws="props.expandAllCws"/>
 				<div v-if="note.isSchedule" style="margin-top: 10px;">
 					<MkButton :class="$style.button" inline @click.stop.prevent="editScheduleNote()"><i class="ti ti-eraser"></i> {{ i18n.ts.edit }}</MkButton>
@@ -26,8 +26,9 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { ref, watch } from 'vue';
+import { computed, ref, watch } from 'vue';
 import * as Misskey from 'misskey-js';
+import { appendContentWarning } from '@@/js/append-content-warning.js';
 import * as os from '@/os.js';
 import MkNoteHeader from '@/components/MkNoteHeader.vue';
 import MkSubNoteContent from '@/components/MkSubNoteContent.vue';
@@ -48,6 +49,14 @@ const props = defineProps<{
 let showContent = ref(defaultStore.state.uncollapseCW);
 const isDeleted = ref(false);
 
+const mergedCW = computed(() => {
+	let cw = props.note.cw;
+	if (props.note.user.mandatoryCW) {
+		cw = appendContentWarning(cw, props.note.user.mandatoryCW);
+	}
+	return cw;
+});
+
 const emit = defineEmits<{
 	(ev: 'editScheduleNote'): void;
 }>();
diff --git a/packages/frontend/src/components/MkNoteSub.vue b/packages/frontend/src/components/MkNoteSub.vue
index 9febc1219c..43cc35d3cc 100644
--- a/packages/frontend/src/components/MkNoteSub.vue
+++ b/packages/frontend/src/components/MkNoteSub.vue
@@ -11,11 +11,11 @@ SPDX-License-Identifier: AGPL-3.0-only
 		<div :class="$style.body">
 			<MkNoteHeader :class="$style.header" :note="note" :mini="true"/>
 			<div :class="$style.content">
-				<p v-if="note.cw != null" :class="$style.cw">
-					<Mfm v-if="note.cw != ''" style="margin-right: 8px;" :text="note.cw" :isBlock="true" :author="note.user" :nyaize="'respect'"/>
+				<p v-if="mergedCW != null" :class="$style.cw">
+					<Mfm v-if="mergedCW != ''" style="margin-right: 8px;" :text="mergedCW" :isBlock="true" :author="note.user" :nyaize="'respect'"/>
 					<MkCwButton v-model="showContent" :text="note.text" :files="note.files" :poll="note.poll"/>
 				</p>
-				<div v-show="note.cw == null || showContent">
+				<div v-show="mergedCW == null || showContent">
 					<MkSubNoteContent :class="$style.text" :note="note" :translating="translating" :translation="translation" :expandAllCws="props.expandAllCws"/>
 				</div>
 			</div>
@@ -86,6 +86,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 <script lang="ts" setup>
 import { computed, ref, shallowRef, watch } from 'vue';
 import * as Misskey from 'misskey-js';
+import { appendContentWarning } from '@@/js/append-content-warning.js';
 import MkNoteHeader from '@/components/MkNoteHeader.vue';
 import MkReactionsViewer from '@/components/MkReactionsViewer.vue';
 import MkSubNoteContent from '@/components/MkSubNoteContent.vue';
@@ -142,6 +143,14 @@ let appearNote = computed(() => isRenote ? props.note.renote as Misskey.entities
 const defaultLike = computed(() => defaultStore.state.like ? defaultStore.state.like : null);
 const replies = ref<Misskey.entities.Note[]>([]);
 
+const mergedCW = computed(() => {
+	let cw = appearNote.value.cw;
+	if (appearNote.value.user.mandatoryCW) {
+		cw = appendContentWarning(cw, appearNote.value.user.mandatoryCW);
+	}
+	return cw;
+});
+
 const isRenote = (
 	props.note.renote != null &&
 	props.note.text == null &&
diff --git a/packages/frontend/src/components/SkNote.vue b/packages/frontend/src/components/SkNote.vue
index 323ca283bf..2cfc5ac01e 100644
--- a/packages/frontend/src/components/SkNote.vue
+++ b/packages/frontend/src/components/SkNote.vue
@@ -62,10 +62,10 @@ SPDX-License-Identifier: AGPL-3.0-only
 		</div>
 		<div :class="[{ [$style.clickToOpen]: defaultStore.state.clickToOpen }]" @click.stop="defaultStore.state.clickToOpen ? noteclick(appearNote.id) : undefined">
 			<div style="container-type: inline-size;">
-				<p v-if="appearNote.cw != null" :class="$style.cw">
+				<p v-if="mergedCW != null" :class="$style.cw">
 					<Mfm
-						v-if="appearNote.cw != ''"
-						:text="appearNote.cw"
+						v-if="mergedCW != ''"
+						:text="mergedCW"
 						:author="appearNote.user"
 						:nyaize="'respect'"
 						:enableEmojiMenu="true"
@@ -74,7 +74,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 					/>
 					<MkCwButton v-model="showContent" :text="appearNote.text" :renote="appearNote.renote" :files="appearNote.files" :poll="appearNote.poll" style="margin: 4px 0;" @click.stop/>
 				</p>
-				<div v-show="appearNote.cw == null || showContent" :class="[{ [$style.contentCollapsed]: collapsed }]">
+				<div v-show="mergedCW == null || showContent" :class="[{ [$style.contentCollapsed]: collapsed }]">
 					<div :class="$style.text">
 						<span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span>
 						<Mfm
@@ -213,6 +213,7 @@ import * as Misskey from 'misskey-js';
 import { isLink } from '@@/js/is-link.js';
 import { shouldCollapsed } from '@@/js/collapsed.js';
 import { host } from '@@/js/config.js';
+import { appendContentWarning } from '@@/js/append-content-warning.js';
 import type { MenuItem } from '@/types/menu.js';
 import SkNoteSub from '@/components/SkNoteSub.vue';
 import SkNoteHeader from '@/components/SkNoteHeader.vue';
@@ -345,6 +346,14 @@ const defaultLike = computed(() => defaultStore.state.like ? defaultStore.state.
 const animated = computed(() => parsed.value ? checkAnimationFromMfm(parsed.value) : null);
 const allowAnim = ref(defaultStore.state.advancedMfm && defaultStore.state.animatedMfm ? true : false);
 
+const mergedCW = computed(() => {
+	let cw = appearNote.value.cw;
+	if (appearNote.value.user.mandatoryCW) {
+		cw = appendContentWarning(cw, appearNote.value.user.mandatoryCW);
+	}
+	return cw;
+});
+
 const renoteTooltip = computeRenoteTooltip(renoted);
 
 const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({
diff --git a/packages/frontend/src/components/SkNoteDetailed.vue b/packages/frontend/src/components/SkNoteDetailed.vue
index dc8a5f59b2..1931946bcc 100644
--- a/packages/frontend/src/components/SkNoteDetailed.vue
+++ b/packages/frontend/src/components/SkNoteDetailed.vue
@@ -81,10 +81,10 @@ SPDX-License-Identifier: AGPL-3.0-only
 			</div>
 		</header>
 		<div :class="$style.noteContent">
-			<p v-if="appearNote.cw != null" :class="$style.cw">
+			<p v-if="mergedCW != null" :class="$style.cw">
 				<Mfm
-					v-if="appearNote.cw != ''"
-					:text="appearNote.cw"
+					v-if="mergedCW != ''"
+					:text="mergedCW"
 					:author="appearNote.user"
 					:nyaize="'respect'"
 					:enableEmojiMenu="true"
@@ -93,7 +93,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 				/>
 				<MkCwButton v-model="showContent" :text="appearNote.text" :renote="appearNote.renote" :files="appearNote.files" :poll="appearNote.poll"/>
 			</p>
-			<div v-show="appearNote.cw == null || showContent">
+			<div v-show="mergedCW == null || showContent">
 				<span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span>
 				<Mfm
 					v-if="appearNote.text"
@@ -250,6 +250,7 @@ import * as mfm from '@transfem-org/sfm-js';
 import * as Misskey from 'misskey-js';
 import { isLink } from '@@/js/is-link.js';
 import { host } from '@@/js/config.js';
+import { appendContentWarning } from '@@/js/append-content-warning.js';
 import SkNoteSub from '@/components/SkNoteSub.vue';
 import SkNoteSimple from '@/components/SkNoteSimple.vue';
 import MkReactionsViewer from '@/components/MkReactionsViewer.vue';
@@ -354,6 +355,14 @@ const quotes = ref<Misskey.entities.Note[]>([]);
 const canRenote = computed(() => ['public', 'home'].includes(appearNote.value.visibility) || (appearNote.value.visibility === 'followers' && appearNote.value.userId === $i?.id));
 const defaultLike = computed(() => defaultStore.state.like ? defaultStore.state.like : null);
 
+const mergedCW = computed(() => {
+	let cw = appearNote.value.cw;
+	if (appearNote.value.user.mandatoryCW) {
+		cw = appendContentWarning(cw, appearNote.value.user.mandatoryCW);
+	}
+	return cw;
+});
+
 const renoteTooltip = computeRenoteTooltip(renoted);
 
 watch(() => props.expandAllCws, (expandAllCws) => {
diff --git a/packages/frontend/src/components/SkNoteSimple.vue b/packages/frontend/src/components/SkNoteSimple.vue
index b9895305f2..f30fb82926 100644
--- a/packages/frontend/src/components/SkNoteSimple.vue
+++ b/packages/frontend/src/components/SkNoteSimple.vue
@@ -9,11 +9,11 @@ SPDX-License-Identifier: AGPL-3.0-only
 	<div :class="$style.main">
 		<MkNoteHeader :class="$style.header" :classic="true" :note="note" :mini="true"/>
 		<div>
-			<p v-if="note.cw != null" :class="$style.cw">
-				<Mfm v-if="note.cw != ''" style="margin-right: 8px;" :text="note.cw" :isBlock="true" :author="note.user" :nyaize="'respect'" :emojiUrls="note.emojis"/>
+			<p v-if="mergedCW != null" :class="$style.cw">
+				<Mfm v-if="mergedCW != ''" style="margin-right: 8px;" :text="mergedCW" :isBlock="true" :author="note.user" :nyaize="'respect'" :emojiUrls="note.emojis"/>
 				<MkCwButton v-model="showContent" :text="note.text" :files="note.files" :poll="note.poll" @click.stop/>
 			</p>
-			<div v-show="note.cw == null || showContent">
+			<div v-show="mergedCW == null || showContent">
 				<MkSubNoteContent :hideFiles="hideFiles" :class="$style.text" :note="note" :expandAllCws="props.expandAllCws"/>
 			</div>
 		</div>
@@ -22,8 +22,9 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { watch, ref } from 'vue';
+import { watch, ref, computed } from 'vue';
 import * as Misskey from 'misskey-js';
+import { appendContentWarning } from '@@/js/append-content-warning.js';
 import MkNoteHeader from '@/components/MkNoteHeader.vue';
 import MkSubNoteContent from '@/components/MkSubNoteContent.vue';
 import MkCwButton from '@/components/MkCwButton.vue';
@@ -37,6 +38,14 @@ const props = defineProps<{
 
 let showContent = ref(defaultStore.state.uncollapseCW);
 
+const mergedCW = computed(() => {
+	let cw = props.note.cw;
+	if (props.note.user.mandatoryCW) {
+		cw = appendContentWarning(cw, props.note.user.mandatoryCW);
+	}
+	return cw;
+});
+
 watch(() => props.expandAllCws, (expandAllCws) => {
 	if (expandAllCws !== showContent.value) showContent.value = expandAllCws;
 });
diff --git a/packages/frontend/src/components/SkNoteSub.vue b/packages/frontend/src/components/SkNoteSub.vue
index a3d27c9e16..a4ab7b7405 100644
--- a/packages/frontend/src/components/SkNoteSub.vue
+++ b/packages/frontend/src/components/SkNoteSub.vue
@@ -19,11 +19,11 @@ SPDX-License-Identifier: AGPL-3.0-only
 		<div :class="$style.body">
 			<SkNoteHeader :class="$style.header" :note="note" :classic="true" :mini="true"/>
 			<div :class="$style.content">
-				<p v-if="note.cw != null" :class="$style.cw">
-					<Mfm v-if="note.cw != ''" style="margin-right: 8px;" :text="note.cw" :isBlock="true" :author="note.user" :nyaize="'respect'"/>
+				<p v-if="mergedCW != null" :class="$style.cw">
+					<Mfm v-if="mergedCW != ''" style="margin-right: 8px;" :text="mergedCW" :isBlock="true" :author="note.user" :nyaize="'respect'"/>
 					<MkCwButton v-model="showContent" :text="note.text" :files="note.files" :poll="note.poll"/>
 				</p>
-				<div v-show="note.cw == null || showContent">
+				<div v-show="mergedCW == null || showContent">
 					<MkSubNoteContent :class="$style.text" :note="note" :translating="translating" :translation="translation" :expandAllCws="props.expandAllCws"/>
 				</div>
 			</div>
@@ -94,6 +94,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 <script lang="ts" setup>
 import { computed, ref, shallowRef, watch } from 'vue';
 import * as Misskey from 'misskey-js';
+import { appendContentWarning } from '@@/js/append-content-warning.js';
 import SkNoteHeader from '@/components/SkNoteHeader.vue';
 import MkReactionsViewer from '@/components/MkReactionsViewer.vue';
 import MkSubNoteContent from '@/components/MkSubNoteContent.vue';
@@ -156,6 +157,14 @@ let appearNote = computed(() => isRenote ? props.note.renote as Misskey.entities
 const defaultLike = computed(() => defaultStore.state.like ? defaultStore.state.like : null);
 const replies = ref<Misskey.entities.Note[]>([]);
 
+const mergedCW = computed(() => {
+	let cw = appearNote.value.cw;
+	if (appearNote.value.user.mandatoryCW) {
+		cw = appendContentWarning(cw, appearNote.value.user.mandatoryCW);
+	}
+	return cw;
+});
+
 const isRenote = (
 	props.note.renote != null &&
 	props.note.text == null &&
-- 
cgit v1.2.3-freya


From b2d43040b1f7111cb180f6798eea266c1c85166f Mon Sep 17 00:00:00 2001
From: Hazelnoot <acomputerdog@gmail.com>
Date: Sat, 15 Feb 2025 11:49:33 -0500
Subject: factor out shared "compute-merged-cw" routine

---
 packages/frontend-embed/src/components/EmNote.vue       | 10 ++--------
 .../frontend-embed/src/components/EmNoteDetailed.vue    | 10 ++--------
 packages/frontend-embed/src/components/EmNoteSimple.vue | 10 ++--------
 packages/frontend-embed/src/components/EmNoteSub.vue    | 10 ++--------
 packages/frontend-shared/js/compute-merged-cw.ts        | 17 +++++++++++++++++
 packages/frontend/src/components/MkNote.vue             | 10 ++--------
 packages/frontend/src/components/MkNoteDetailed.vue     | 10 ++--------
 packages/frontend/src/components/MkNoteSimple.vue       | 10 ++--------
 packages/frontend/src/components/MkNoteSub.vue          | 10 ++--------
 packages/frontend/src/components/SkNote.vue             | 10 ++--------
 packages/frontend/src/components/SkNoteDetailed.vue     | 10 ++--------
 packages/frontend/src/components/SkNoteSimple.vue       | 10 ++--------
 packages/frontend/src/components/SkNoteSub.vue          | 10 ++--------
 13 files changed, 41 insertions(+), 96 deletions(-)
 create mode 100644 packages/frontend-shared/js/compute-merged-cw.ts

(limited to 'packages/frontend/src/components/MkNoteDetailed.vue')

diff --git a/packages/frontend-embed/src/components/EmNote.vue b/packages/frontend-embed/src/components/EmNote.vue
index a918ae8db4..bf96c557ea 100644
--- a/packages/frontend-embed/src/components/EmNote.vue
+++ b/packages/frontend-embed/src/components/EmNote.vue
@@ -109,7 +109,7 @@ import * as mfm from '@transfem-org/sfm-js';
 import * as Misskey from 'misskey-js';
 import { shouldCollapsed } from '@@/js/collapsed.js';
 import { url } from '@@/js/config.js';
-import { appendContentWarning } from '@@/js/append-content-warning.js';
+import { computeMergedCw } from '@@/js/compute-merged-cw.js';
 import I18n from '@/components/I18n.vue';
 import EmNoteSub from '@/components/EmNoteSub.vue';
 import EmNoteHeader from '@/components/EmNoteHeader.vue';
@@ -156,13 +156,7 @@ const isLong = shouldCollapsed(appearNote.value, []);
 const collapsed = ref(appearNote.value.cw == null && isLong);
 const isDeleted = ref(false);
 
-const mergedCW = computed(() => {
-	let cw = appearNote.value.cw;
-	if (appearNote.value.user.mandatoryCW) {
-		cw = appendContentWarning(cw, appearNote.value.user.mandatoryCW);
-	}
-	return cw;
-});
+const mergedCW = computed(() => computeMergedCw(appearNote.value));
 </script>
 
 <style lang="scss" module>
diff --git a/packages/frontend-embed/src/components/EmNoteDetailed.vue b/packages/frontend-embed/src/components/EmNoteDetailed.vue
index 10a58feaef..0961b36e35 100644
--- a/packages/frontend-embed/src/components/EmNoteDetailed.vue
+++ b/packages/frontend-embed/src/components/EmNoteDetailed.vue
@@ -130,7 +130,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { computed, inject, ref } from 'vue';
 import * as mfm from '@transfem-org/sfm-js';
 import * as Misskey from 'misskey-js';
-import { appendContentWarning } from '@@/js/append-content-warning.js';
+import { computeMergedCw } from '@@/js/compute-merged-cw.js';
 import I18n from '@/components/I18n.vue';
 import EmMediaList from '@/components/EmMediaList.vue';
 import EmNoteSub from '@/components/EmNoteSub.vue';
@@ -177,13 +177,7 @@ const parsed = appearNote.value.text ? mfm.parse(appearNote.value.text) : null;
 const isLong = shouldCollapsed(appearNote.value, []);
 const collapsed = ref(appearNote.value.cw == null && isLong);
 
-const mergedCW = computed(() => {
-	let cw = appearNote.value.cw;
-	if (appearNote.value.user.mandatoryCW) {
-		cw = appendContentWarning(cw, appearNote.value.user.mandatoryCW);
-	}
-	return cw;
-});
+const mergedCW = computed(() => computeMergedCw(appearNote.value));
 </script>
 
 <style lang="scss" module>
diff --git a/packages/frontend-embed/src/components/EmNoteSimple.vue b/packages/frontend-embed/src/components/EmNoteSimple.vue
index 74aed4e28b..688758edb6 100644
--- a/packages/frontend-embed/src/components/EmNoteSimple.vue
+++ b/packages/frontend-embed/src/components/EmNoteSimple.vue
@@ -24,7 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 <script lang="ts" setup>
 import { computed, ref } from 'vue';
 import * as Misskey from 'misskey-js';
-import { appendContentWarning } from '@@/js/append-content-warning.js';
+import { computeMergedCw } from '@@/js/compute-merged-cw.js';
 import { i18n } from '@/i18n.js';
 import EmAvatar from '@/components/EmAvatar.vue';
 import EmNoteHeader from '@/components/EmNoteHeader.vue';
@@ -37,13 +37,7 @@ const props = defineProps<{
 
 const showContent = ref(false);
 
-const mergedCW = computed(() => {
-	let cw = props.note.cw;
-	if (props.note.user.mandatoryCW) {
-		cw = appendContentWarning(cw, props.note.user.mandatoryCW);
-	}
-	return cw;
-});
+const mergedCW = computed(() => computeMergedCw(props.note));
 </script>
 
 <style lang="scss" module>
diff --git a/packages/frontend-embed/src/components/EmNoteSub.vue b/packages/frontend-embed/src/components/EmNoteSub.vue
index e1302bbe25..629f0bffcd 100644
--- a/packages/frontend-embed/src/components/EmNoteSub.vue
+++ b/packages/frontend-embed/src/components/EmNoteSub.vue
@@ -33,7 +33,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 <script lang="ts" setup>
 import { computed, ref } from 'vue';
 import * as Misskey from 'misskey-js';
-import { appendContentWarning } from '@@/js/append-content-warning.js';
+import { computeMergedCw } from '@@/js/compute-merged-cw.js';
 import EmA from '@/components/EmA.vue';
 import EmAvatar from '@/components/EmAvatar.vue';
 import EmNoteHeader from '@/components/EmNoteHeader.vue';
@@ -56,13 +56,7 @@ const props = withDefaults(defineProps<{
 const showContent = ref(false);
 const replies = ref<Misskey.entities.Note[]>([]);
 
-const mergedCW = computed(() => {
-	let cw = props.note.cw;
-	if (props.note.user.mandatoryCW) {
-		cw = appendContentWarning(cw, props.note.user.mandatoryCW);
-	}
-	return cw;
-});
+const mergedCW = computed(() => computeMergedCw(props.note));
 
 if (props.detail) {
 	misskeyApi('notes/children', {
diff --git a/packages/frontend-shared/js/compute-merged-cw.ts b/packages/frontend-shared/js/compute-merged-cw.ts
new file mode 100644
index 0000000000..aafe141b69
--- /dev/null
+++ b/packages/frontend-shared/js/compute-merged-cw.ts
@@ -0,0 +1,17 @@
+/*
+ * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import * as Misskey from 'misskey-js';
+import { appendContentWarning } from '@/js/append-content-warning.js';
+
+export function computeMergedCw(note: Misskey.entities.Note): string | null {
+	let cw = note.cw;
+
+	if (note.user.mandatoryCW) {
+		cw = appendContentWarning(cw, note.user.mandatoryCW);
+	}
+
+	return cw ?? null;
+}
diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue
index aa971b0ab3..0bac6a67b9 100644
--- a/packages/frontend/src/components/MkNote.vue
+++ b/packages/frontend/src/components/MkNote.vue
@@ -212,7 +212,7 @@ import * as Misskey from 'misskey-js';
 import { isLink } from '@@/js/is-link.js';
 import { shouldCollapsed } from '@@/js/collapsed.js';
 import { host } from '@@/js/config.js';
-import { appendContentWarning } from '@@/js/append-content-warning.js';
+import { computeMergedCw } from '@@/js/compute-merged-cw.js';
 import type { MenuItem } from '@/types/menu.js';
 import MkNoteSub from '@/components/MkNoteSub.vue';
 import MkNoteHeader from '@/components/MkNoteHeader.vue';
@@ -351,13 +351,7 @@ const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({
 	url: `https://${host}/notes/${appearNote.value.id}`,
 }));
 
-const mergedCW = computed(() => {
-	let cw = appearNote.value.cw;
-	if (appearNote.value.user.mandatoryCW) {
-		cw = appendContentWarning(cw, appearNote.value.user.mandatoryCW);
-	}
-	return cw;
-});
+const mergedCW = computed(() => computeMergedCw(appearNote.value));
 
 const renoteTooltip = computeRenoteTooltip(renoted);
 
diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue
index 5f7c4fa12f..582838ebca 100644
--- a/packages/frontend/src/components/MkNoteDetailed.vue
+++ b/packages/frontend/src/components/MkNoteDetailed.vue
@@ -245,7 +245,7 @@ import * as mfm from '@transfem-org/sfm-js';
 import * as Misskey from 'misskey-js';
 import { isLink } from '@@/js/is-link.js';
 import { host } from '@@/js/config.js';
-import { appendContentWarning } from '@@/js/append-content-warning.js';
+import { computeMergedCw } from '@@/js/compute-merged-cw.js';
 import MkNoteSub from '@/components/MkNoteSub.vue';
 import MkNoteSimple from '@/components/MkNoteSimple.vue';
 import MkReactionsViewer from '@/components/MkReactionsViewer.vue';
@@ -349,13 +349,7 @@ const quotes = ref<Misskey.entities.Note[]>([]);
 const canRenote = computed(() => ['public', 'home'].includes(appearNote.value.visibility) || (appearNote.value.visibility === 'followers' && appearNote.value.userId === $i?.id));
 const defaultLike = computed(() => defaultStore.state.like ? defaultStore.state.like : null);
 
-const mergedCW = computed(() => {
-	let cw = appearNote.value.cw;
-	if (appearNote.value.user.mandatoryCW) {
-		cw = appendContentWarning(cw, appearNote.value.user.mandatoryCW);
-	}
-	return cw;
-});
+const mergedCW = computed(() => computeMergedCw(appearNote.value));
 
 const renoteTooltip = computeRenoteTooltip(renoted);
 
diff --git a/packages/frontend/src/components/MkNoteSimple.vue b/packages/frontend/src/components/MkNoteSimple.vue
index e487fb978e..3720aa7493 100644
--- a/packages/frontend/src/components/MkNoteSimple.vue
+++ b/packages/frontend/src/components/MkNoteSimple.vue
@@ -28,7 +28,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 <script lang="ts" setup>
 import { computed, ref, watch } from 'vue';
 import * as Misskey from 'misskey-js';
-import { appendContentWarning } from '@@/js/append-content-warning.js';
+import { computeMergedCw } from '@@/js/compute-merged-cw.js';
 import * as os from '@/os.js';
 import MkNoteHeader from '@/components/MkNoteHeader.vue';
 import MkSubNoteContent from '@/components/MkSubNoteContent.vue';
@@ -49,13 +49,7 @@ const props = defineProps<{
 let showContent = ref(defaultStore.state.uncollapseCW);
 const isDeleted = ref(false);
 
-const mergedCW = computed(() => {
-	let cw = props.note.cw;
-	if (props.note.user.mandatoryCW) {
-		cw = appendContentWarning(cw, props.note.user.mandatoryCW);
-	}
-	return cw;
-});
+const mergedCW = computed(() => computeMergedCw(props.note));
 
 const emit = defineEmits<{
 	(ev: 'editScheduleNote'): void;
diff --git a/packages/frontend/src/components/MkNoteSub.vue b/packages/frontend/src/components/MkNoteSub.vue
index 43cc35d3cc..61006cfdcc 100644
--- a/packages/frontend/src/components/MkNoteSub.vue
+++ b/packages/frontend/src/components/MkNoteSub.vue
@@ -86,7 +86,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 <script lang="ts" setup>
 import { computed, ref, shallowRef, watch } from 'vue';
 import * as Misskey from 'misskey-js';
-import { appendContentWarning } from '@@/js/append-content-warning.js';
+import { computeMergedCw } from '@@/js/compute-merged-cw.js';
 import MkNoteHeader from '@/components/MkNoteHeader.vue';
 import MkReactionsViewer from '@/components/MkReactionsViewer.vue';
 import MkSubNoteContent from '@/components/MkSubNoteContent.vue';
@@ -143,13 +143,7 @@ let appearNote = computed(() => isRenote ? props.note.renote as Misskey.entities
 const defaultLike = computed(() => defaultStore.state.like ? defaultStore.state.like : null);
 const replies = ref<Misskey.entities.Note[]>([]);
 
-const mergedCW = computed(() => {
-	let cw = appearNote.value.cw;
-	if (appearNote.value.user.mandatoryCW) {
-		cw = appendContentWarning(cw, appearNote.value.user.mandatoryCW);
-	}
-	return cw;
-});
+const mergedCW = computed(() => computeMergedCw(appearNote.value));
 
 const isRenote = (
 	props.note.renote != null &&
diff --git a/packages/frontend/src/components/SkNote.vue b/packages/frontend/src/components/SkNote.vue
index 2cfc5ac01e..777e95593d 100644
--- a/packages/frontend/src/components/SkNote.vue
+++ b/packages/frontend/src/components/SkNote.vue
@@ -213,7 +213,7 @@ import * as Misskey from 'misskey-js';
 import { isLink } from '@@/js/is-link.js';
 import { shouldCollapsed } from '@@/js/collapsed.js';
 import { host } from '@@/js/config.js';
-import { appendContentWarning } from '@@/js/append-content-warning.js';
+import { computeMergedCw } from '@@/js/compute-merged-cw.js';
 import type { MenuItem } from '@/types/menu.js';
 import SkNoteSub from '@/components/SkNoteSub.vue';
 import SkNoteHeader from '@/components/SkNoteHeader.vue';
@@ -346,13 +346,7 @@ const defaultLike = computed(() => defaultStore.state.like ? defaultStore.state.
 const animated = computed(() => parsed.value ? checkAnimationFromMfm(parsed.value) : null);
 const allowAnim = ref(defaultStore.state.advancedMfm && defaultStore.state.animatedMfm ? true : false);
 
-const mergedCW = computed(() => {
-	let cw = appearNote.value.cw;
-	if (appearNote.value.user.mandatoryCW) {
-		cw = appendContentWarning(cw, appearNote.value.user.mandatoryCW);
-	}
-	return cw;
-});
+const mergedCW = computed(() => computeMergedCw(appearNote.value));
 
 const renoteTooltip = computeRenoteTooltip(renoted);
 
diff --git a/packages/frontend/src/components/SkNoteDetailed.vue b/packages/frontend/src/components/SkNoteDetailed.vue
index 1931946bcc..471a4ce7d7 100644
--- a/packages/frontend/src/components/SkNoteDetailed.vue
+++ b/packages/frontend/src/components/SkNoteDetailed.vue
@@ -250,7 +250,7 @@ import * as mfm from '@transfem-org/sfm-js';
 import * as Misskey from 'misskey-js';
 import { isLink } from '@@/js/is-link.js';
 import { host } from '@@/js/config.js';
-import { appendContentWarning } from '@@/js/append-content-warning.js';
+import { computeMergedCw } from '@@/js/compute-merged-cw.js';
 import SkNoteSub from '@/components/SkNoteSub.vue';
 import SkNoteSimple from '@/components/SkNoteSimple.vue';
 import MkReactionsViewer from '@/components/MkReactionsViewer.vue';
@@ -355,13 +355,7 @@ const quotes = ref<Misskey.entities.Note[]>([]);
 const canRenote = computed(() => ['public', 'home'].includes(appearNote.value.visibility) || (appearNote.value.visibility === 'followers' && appearNote.value.userId === $i?.id));
 const defaultLike = computed(() => defaultStore.state.like ? defaultStore.state.like : null);
 
-const mergedCW = computed(() => {
-	let cw = appearNote.value.cw;
-	if (appearNote.value.user.mandatoryCW) {
-		cw = appendContentWarning(cw, appearNote.value.user.mandatoryCW);
-	}
-	return cw;
-});
+const mergedCW = computed(() => computeMergedCw(appearNote.value));
 
 const renoteTooltip = computeRenoteTooltip(renoted);
 
diff --git a/packages/frontend/src/components/SkNoteSimple.vue b/packages/frontend/src/components/SkNoteSimple.vue
index f30fb82926..71a5bd4df8 100644
--- a/packages/frontend/src/components/SkNoteSimple.vue
+++ b/packages/frontend/src/components/SkNoteSimple.vue
@@ -24,7 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 <script lang="ts" setup>
 import { watch, ref, computed } from 'vue';
 import * as Misskey from 'misskey-js';
-import { appendContentWarning } from '@@/js/append-content-warning.js';
+import { computeMergedCw } from '@@/js/compute-merged-cw.js';
 import MkNoteHeader from '@/components/MkNoteHeader.vue';
 import MkSubNoteContent from '@/components/MkSubNoteContent.vue';
 import MkCwButton from '@/components/MkCwButton.vue';
@@ -38,13 +38,7 @@ const props = defineProps<{
 
 let showContent = ref(defaultStore.state.uncollapseCW);
 
-const mergedCW = computed(() => {
-	let cw = props.note.cw;
-	if (props.note.user.mandatoryCW) {
-		cw = appendContentWarning(cw, props.note.user.mandatoryCW);
-	}
-	return cw;
-});
+const mergedCW = computed(() => computeMergedCw(props.note));
 
 watch(() => props.expandAllCws, (expandAllCws) => {
 	if (expandAllCws !== showContent.value) showContent.value = expandAllCws;
diff --git a/packages/frontend/src/components/SkNoteSub.vue b/packages/frontend/src/components/SkNoteSub.vue
index a4ab7b7405..060e66be16 100644
--- a/packages/frontend/src/components/SkNoteSub.vue
+++ b/packages/frontend/src/components/SkNoteSub.vue
@@ -94,7 +94,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 <script lang="ts" setup>
 import { computed, ref, shallowRef, watch } from 'vue';
 import * as Misskey from 'misskey-js';
-import { appendContentWarning } from '@@/js/append-content-warning.js';
+import { computeMergedCw } from '@@/js/compute-merged-cw.js';
 import SkNoteHeader from '@/components/SkNoteHeader.vue';
 import MkReactionsViewer from '@/components/MkReactionsViewer.vue';
 import MkSubNoteContent from '@/components/MkSubNoteContent.vue';
@@ -157,13 +157,7 @@ let appearNote = computed(() => isRenote ? props.note.renote as Misskey.entities
 const defaultLike = computed(() => defaultStore.state.like ? defaultStore.state.like : null);
 const replies = ref<Misskey.entities.Note[]>([]);
 
-const mergedCW = computed(() => {
-	let cw = appearNote.value.cw;
-	if (appearNote.value.user.mandatoryCW) {
-		cw = appendContentWarning(cw, appearNote.value.user.mandatoryCW);
-	}
-	return cw;
-});
+const mergedCW = computed(() => computeMergedCw(appearNote.value));
 
 const isRenote = (
 	props.note.renote != null &&
-- 
cgit v1.2.3-freya


From 292d3b92295d194856cb73c66ac097180f70deb8 Mon Sep 17 00:00:00 2001
From: Hazelnoot <acomputerdog@gmail.com>
Date: Sat, 15 Feb 2025 23:08:02 -0500
Subject: add "reject quotes" toggle at user and instance level

+ improve, cleanup, and de-duplicate quote resolution
+ add warning message when quote cannot be loaded
+ add "process error" framework to display warnings when a note cannot be correctly loaded from another instance
---
 locales/index.d.ts                                 |  34 +++++
 .../1739671352784-add_note_processErrors.js        |  11 ++
 .../1739671777344-add_user_rejectQuotes.js         |  11 ++
 .../1739671847942-add_instance_rejectQuotes.js     |  11 ++
 packages/backend/src/core/NoteCreateService.ts     |  30 +++++
 packages/backend/src/core/NoteEditService.ts       |   5 +
 .../src/core/activitypub/models/ApNoteService.ts   | 143 ++++++++++-----------
 .../src/core/entities/InstanceEntityService.ts     |   1 +
 .../backend/src/core/entities/NoteEntityService.ts |   1 +
 .../backend/src/core/entities/UserEntityService.ts |   1 +
 packages/backend/src/models/Instance.ts            |   9 ++
 packages/backend/src/models/Note.ts                |  11 ++
 packages/backend/src/models/User.ts                |   9 ++
 .../src/models/json-schema/federation-instance.ts  |   5 +
 packages/backend/src/models/json-schema/note.ts    |   8 ++
 packages/backend/src/models/json-schema/user.ts    |   4 +
 .../endpoints/admin/federation/update-instance.ts  |  10 ++
 .../server/api/endpoints/admin/reject-quotes.ts    |  63 +++++++++
 .../src/server/api/endpoints/notes/create.ts       |   8 ++
 .../backend/src/server/api/endpoints/notes/edit.ts |   8 ++
 packages/backend/src/types.ts                      |  22 ++++
 packages/frontend/src/components/MkNote.vue        |   2 +-
 .../frontend/src/components/MkNoteDetailed.vue     |   2 +-
 packages/frontend/src/components/MkNoteSub.vue     |   2 +-
 packages/frontend/src/components/MkPostForm.vue    |   2 +-
 packages/frontend/src/components/SkErrorList.vue   |  43 +++++++
 packages/frontend/src/components/SkNote.vue        |   2 +-
 .../frontend/src/components/SkNoteDetailed.vue     |   2 +-
 packages/frontend/src/components/SkNoteSub.vue     |   2 +-
 packages/frontend/src/pages/admin-user.vue         |  19 +++
 .../frontend/src/pages/admin/modlog.ModLog.vue     |  12 ++
 packages/frontend/src/pages/instance-info.vue      |  12 ++
 packages/frontend/src/pages/note.vue               |   8 +-
 packages/misskey-js/src/consts.ts                  |  19 +++
 packages/misskey-js/src/entities.ts                |  12 ++
 sharkey-locales/en-US.yml                          |  10 ++
 36 files changed, 466 insertions(+), 88 deletions(-)
 create mode 100644 packages/backend/migration/1739671352784-add_note_processErrors.js
 create mode 100644 packages/backend/migration/1739671777344-add_user_rejectQuotes.js
 create mode 100644 packages/backend/migration/1739671847942-add_instance_rejectQuotes.js
 create mode 100644 packages/backend/src/server/api/endpoints/admin/reject-quotes.ts
 create mode 100644 packages/frontend/src/components/SkErrorList.vue

(limited to 'packages/frontend/src/components/MkNoteDetailed.vue')

diff --git a/locales/index.d.ts b/locales/index.d.ts
index bf49869bf8..cc7884b8c1 100644
--- a/locales/index.d.ts
+++ b/locales/index.d.ts
@@ -8566,6 +8566,10 @@ export interface Locale extends ILocale {
          * Un-silence users
          */
         "write:admin:unsilence-user": string;
+        /**
+         * Allow/Reject quote posts from a user
+         */
+        "write:admin:reject-quotes": string;
         /**
          * View your list of scheduled notes
          */
@@ -10242,6 +10246,14 @@ export interface Locale extends ILocale {
          * Accepted reports from remote instance
          */
         "acceptRemoteInstanceReports": string;
+        /**
+         * Rejected quotes from user
+         */
+        "rejectQuotesUser": string;
+        /**
+         * Allowed quotes from user
+         */
+        "allowQuotesUser": string;
     };
     "_fileViewer": {
         /**
@@ -11240,6 +11252,22 @@ export interface Locale extends ILocale {
      * Reject reports from this instance
      */
     "rejectReports": string;
+    /**
+     * Reject quote posts from this instance
+     */
+    "rejectQuotesInstance": string;
+    /**
+     * Reject quote posts from this user
+     */
+    "rejectQuotesUser": string;
+    /**
+     * Are you sure you wish to reject quote posts?
+     */
+    "rejectQuotesConfirm": string;
+    /**
+     * Are you sure you wish to allow quote posts?
+     */
+    "allowQuotesConfirm": string;
     /**
      * This host is blocked implicitly because a base domain is blocked. To unblock this host, first unblock the base domain(s).
      */
@@ -12109,6 +12137,12 @@ export interface Locale extends ILocale {
      * Applies a content warning to all posts created by this user. If the post already has a CW, then this is appended to the end.
      */
     "mandatoryCWDescription": string;
+    "_processErrors": {
+        /**
+         * Unable to process quote. This post may be missing context.
+         */
+        "quoteUnavailable": string;
+    };
 }
 declare const locales: {
     [lang: string]: Locale;
diff --git a/packages/backend/migration/1739671352784-add_note_processErrors.js b/packages/backend/migration/1739671352784-add_note_processErrors.js
new file mode 100644
index 0000000000..0be10125e1
--- /dev/null
+++ b/packages/backend/migration/1739671352784-add_note_processErrors.js
@@ -0,0 +1,11 @@
+export class AddNoteProcessErrors1739671352784 {
+	name = 'AddNoteProcessErrors1739671352784'
+
+	async up(queryRunner) {
+		await queryRunner.query(`ALTER TABLE "note" ADD "processErrors" text array`);
+	}
+
+	async down(queryRunner) {
+		await queryRunner.query(`ALTER TABLE "note" DROP COLUMN "processErrors"`);
+	}
+}
diff --git a/packages/backend/migration/1739671777344-add_user_rejectQuotes.js b/packages/backend/migration/1739671777344-add_user_rejectQuotes.js
new file mode 100644
index 0000000000..29ed90c8ff
--- /dev/null
+++ b/packages/backend/migration/1739671777344-add_user_rejectQuotes.js
@@ -0,0 +1,11 @@
+export class AddUserRejectQuotes1739671777344 {
+	name = 'AddUserRejectQuotes1739671777344'
+
+	async up(queryRunner) {
+		await queryRunner.query(`ALTER TABLE "user" ADD "rejectQuotes" boolean NOT NULL DEFAULT false`);
+	}
+
+	async down(queryRunner) {
+		await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "rejectQuotes"`);
+	}
+}
diff --git a/packages/backend/migration/1739671847942-add_instance_rejectQuotes.js b/packages/backend/migration/1739671847942-add_instance_rejectQuotes.js
new file mode 100644
index 0000000000..89774eb991
--- /dev/null
+++ b/packages/backend/migration/1739671847942-add_instance_rejectQuotes.js
@@ -0,0 +1,11 @@
+export class AddInstanceRejectQuotes1739671847942 {
+	name = 'AddInstanceRejectQuotes1739671847942'
+
+	async up(queryRunner) {
+		await queryRunner.query(`ALTER TABLE "instance" ADD "rejectQuotes" boolean NOT NULL DEFAULT false`);
+	}
+
+	async down(queryRunner) {
+		await queryRunner.query(`ALTER TABLE "instance" DROP COLUMN "rejectQuotes"`);
+	}
+}
diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts
index 8291db9b42..df31cb4247 100644
--- a/packages/backend/src/core/NoteCreateService.ts
+++ b/packages/backend/src/core/NoteCreateService.ts
@@ -144,6 +144,7 @@ type Option = {
 	uri?: string | null;
 	url?: string | null;
 	app?: MiApp | null;
+	processErrors?: string[] | null;
 };
 
 export type PureRenoteOption = Option & { renote: MiNote } & ({ text?: null } | { cw?: null } | { reply?: null } | { poll?: null } | { files?: null | [] });
@@ -309,6 +310,9 @@ export class NoteCreateService implements OnApplicationShutdown {
 			}
 		}
 
+		// Check quote permissions
+		await this.checkQuotePermissions(data, user);
+
 		// Check blocking
 		if (this.isRenote(data) && !this.isQuote(data)) {
 			if (data.renote.userHost === null) {
@@ -482,6 +486,7 @@ export class NoteCreateService implements OnApplicationShutdown {
 			renoteUserId: data.renote ? data.renote.userId : null,
 			renoteUserHost: data.renote ? data.renote.userHost : null,
 			userHost: user.host,
+			processErrors: data.processErrors,
 		});
 
 		// should really not happen, but better safe than sorry
@@ -1147,4 +1152,29 @@ export class NoteCreateService implements OnApplicationShutdown {
 	public async onApplicationShutdown(signal?: string | undefined): Promise<void> {
 		await this.dispose();
 	}
+
+	@bindThis
+	public async checkQuotePermissions(data: Option, user: MiUser): Promise<void> {
+		// Not a quote
+		if (!this.isRenote(data) || !this.isQuote(data)) return;
+
+		// User cannot quote
+		if (user.rejectQuotes) {
+			if (user.host == null) {
+				throw new IdentifiableError('1c0ea108-d1e3-4e8e-aa3f-4d2487626153', 'QUOTE_DISABLED_FOR_USER');
+			} else {
+				(data as Option).renote = null;
+				(data.processErrors ??= []).push('quoteUnavailable');
+			}
+		}
+
+		// Instance cannot quote
+		if (user.host) {
+			const instance = await this.federatedInstanceService.fetch(user.host);
+			if (instance?.rejectQuotes) {
+				(data as Option).renote = null;
+				(data.processErrors ??= []).push('quoteUnavailable');
+			}
+		}
+	}
 }
diff --git a/packages/backend/src/core/NoteEditService.ts b/packages/backend/src/core/NoteEditService.ts
index 24a99156d2..7851af86b7 100644
--- a/packages/backend/src/core/NoteEditService.ts
+++ b/packages/backend/src/core/NoteEditService.ts
@@ -140,6 +140,7 @@ type Option = {
 	app?: MiApp | null;
 	updatedAt?: Date | null;
 	editcount?: boolean | null;
+	processErrors?: string[] | null;
 };
 
 @Injectable()
@@ -337,6 +338,9 @@ export class NoteEditService implements OnApplicationShutdown {
 			}
 		}
 
+		// Check quote permissions
+		await this.noteCreateService.checkQuotePermissions(data, user);
+
 		// Check blocking
 		if (this.isRenote(data) && !this.isQuote(data)) {
 			if (data.renote.userHost === null) {
@@ -529,6 +533,7 @@ export class NoteEditService implements OnApplicationShutdown {
 
 			if (data.uri != null) note.uri = data.uri;
 			if (data.url != null) note.url = data.url;
+			if (data.processErrors !== undefined) note.processErrors = data.processErrors;
 
 			if (mentionedUsers.length > 0) {
 				note.mentions = mentionedUsers.map(u => u.id);
diff --git a/packages/backend/src/core/activitypub/models/ApNoteService.ts b/packages/backend/src/core/activitypub/models/ApNoteService.ts
index 2995b1e764..8470285e93 100644
--- a/packages/backend/src/core/activitypub/models/ApNoteService.ts
+++ b/packages/backend/src/core/activitypub/models/ApNoteService.ts
@@ -296,44 +296,8 @@ export class ApNoteService {
 			: null;
 
 		// 引用
-		let quote: MiNote | undefined | null = null;
-
-		if (note._misskey_quote ?? note.quoteUrl ?? note.quoteUri) {
-			const tryResolveNote = async (uri: unknown): Promise<
-				| { status: 'ok'; res: MiNote }
-				| { status: 'permerror' | 'temperror' }
-			> => {
-				if (typeof uri !== 'string' || !/^https?:/.test(uri)) {
-					this.logger.warn(`Failed to resolve quote ${uri} for note ${entryUri}: URI is invalid`);
-					return { status: 'permerror' };
-				}
-				try {
-					const res = await this.resolveNote(uri, { resolver });
-					if (res == null) {
-						this.logger.warn(`Failed to resolve quote ${uri} for note ${entryUri}: resolution error`);
-						return { status: 'permerror' };
-					}
-					return { status: 'ok', res };
-				} catch (e) {
-					const error = e instanceof Error ? `${e.name}: ${e.message}` : String(e);
-					this.logger.warn(`Failed to resolve quote ${uri} for note ${entryUri}: ${error}`);
-
-					return {
-						status: (e instanceof StatusError && !e.isRetryable) ? 'permerror' : 'temperror',
-					};
-				}
-			};
-
-			const uris = unique([note._misskey_quote, note.quoteUrl, note.quoteUri].filter(x => x != null));
-			const results = await Promise.all(uris.map(tryResolveNote));
-
-			quote = results.filter((x): x is { status: 'ok', res: MiNote } => x.status === 'ok').map(x => x.res).at(0);
-			if (!quote) {
-				if (results.some(x => x.status === 'temperror')) {
-					throw new Error(`temporary error resolving quote for ${entryUri}`);
-				}
-			}
-		}
+		const quote = await this.getQuote(note, entryUri, resolver);
+		const processErrors = quote === null ? ['quoteUnavailable'] : null;
 
 		// vote
 		if (reply && reply.hasPoll) {
@@ -369,7 +333,8 @@ export class ApNoteService {
 				createdAt: note.published ? new Date(note.published) : null,
 				files,
 				reply,
-				renote: quote,
+				renote: quote ?? null,
+				processErrors,
 				name: note.name,
 				cw,
 				text,
@@ -538,44 +503,8 @@ export class ApNoteService {
 			: null;
 
 		// 引用
-		let quote: MiNote | undefined | null = null;
-
-		if (note._misskey_quote ?? note.quoteUrl ?? note.quoteUri) {
-			const tryResolveNote = async (uri: unknown): Promise<
-				| { status: 'ok'; res: MiNote }
-				| { status: 'permerror' | 'temperror' }
-			> => {
-				if (typeof uri !== 'string' || !/^https?:/.test(uri)) {
-					this.logger.warn(`Failed to resolve quote ${uri} for note ${entryUri}: URI is invalid`);
-					return { status: 'permerror' };
-				}
-				try {
-					const res = await this.resolveNote(uri, { resolver });
-					if (res == null) {
-						this.logger.warn(`Failed to resolve quote ${uri} for note ${entryUri}: resolution error`);
-						return { status: 'permerror' };
-					}
-					return { status: 'ok', res };
-				} catch (e) {
-					const error = e instanceof Error ? `${e.name}: ${e.message}` : String(e);
-					this.logger.warn(`Failed to resolve quote ${uri} for note ${entryUri}: ${error}`);
-
-					return {
-						status: (e instanceof StatusError && !e.isRetryable) ? 'permerror' : 'temperror',
-					};
-				}
-			};
-
-			const uris = unique([note._misskey_quote, note.quoteUrl, note.quoteUri].filter(x => x != null));
-			const results = await Promise.all(uris.map(tryResolveNote));
-
-			quote = results.filter((x): x is { status: 'ok', res: MiNote } => x.status === 'ok').map(x => x.res).at(0);
-			if (!quote) {
-				if (results.some(x => x.status === 'temperror')) {
-					throw new Error(`temporary error resolving quote for ${entryUri}`);
-				}
-			}
-		}
+		const quote = await this.getQuote(note, entryUri, resolver);
+		const processErrors = quote === null ? ['quoteUnavailable'] : null;
 
 		// vote
 		if (reply && reply.hasPoll) {
@@ -611,7 +540,8 @@ export class ApNoteService {
 				createdAt: note.published ? new Date(note.published) : null,
 				files,
 				reply,
-				renote: quote,
+				renote: quote ?? null,
+				processErrors,
 				name: note.name,
 				cw,
 				text,
@@ -734,6 +664,63 @@ export class ApNoteService {
 			});
 		}));
 	}
+
+	/**
+	 * Fetches the note's quoted post.
+	 * On success - returns the note.
+	 * On skip (no quote) - returns undefined.
+	 * On permanent error - returns null.
+	 * On temporary error - throws an exception.
+	 */
+	private async getQuote(note: IPost, entryUri: string, resolver: Resolver): Promise<MiNote | null | undefined> {
+		const quoteUris = new Set<string>();
+		if (note._misskey_quote) quoteUris.add(note._misskey_quote);
+		if (note.quoteUrl) quoteUris.add(note.quoteUrl);
+		if (note.quoteUri) quoteUris.add(note.quoteUri);
+
+		// No quote, return undefined
+		if (quoteUris.size < 1) return undefined;
+
+		/**
+		 * Attempts to resolve a quote by URI.
+		 * Returns the note if successful, true if there's a retryable error, and false if there's a permanent error.
+		 */
+		const resolveQuote = async (uri: unknown): Promise<MiNote | boolean> => {
+			if (typeof(uri) !== 'string' || !/^https?:/.test(uri)) {
+				this.logger.warn(`Failed to resolve quote "${uri}" for note "${entryUri}": URI is invalid`);
+				return false;
+			}
+
+			try {
+				const quote = await this.resolveNote(uri, { resolver });
+
+				if (quote == null) {
+					this.logger.warn(`Failed to resolve quote "${uri}" for note "${entryUri}": request error`);
+					return false;
+				}
+
+				return quote;
+			} catch (e) {
+				const error = e instanceof Error ? `${e.name}: ${e.message}` : String(e);
+				this.logger.warn(`Failed to resolve quote "${uri}" for note "${entryUri}": ${error}`);
+
+				return (e instanceof StatusError && e.isRetryable);
+			}
+		};
+
+		const results = await Promise.all(Array.from(quoteUris).map(u => resolveQuote(u)));
+
+		// Success - return the quote
+		const quote = results.find(r => typeof(r) === 'object');
+		if (quote) return quote;
+
+		// Temporary / retryable error - throw error
+		const tempError = results.find(r => r === true);
+		if (tempError) throw new Error(`temporary error resolving quote for "${entryUri}"`);
+
+		// Permanent error - return null
+		return null;
+	}
 }
 
 function getBestIcon(note: IObject): IObject | null {
diff --git a/packages/backend/src/core/entities/InstanceEntityService.ts b/packages/backend/src/core/entities/InstanceEntityService.ts
index 63e5923255..fcc9bed3bd 100644
--- a/packages/backend/src/core/entities/InstanceEntityService.ts
+++ b/packages/backend/src/core/entities/InstanceEntityService.ts
@@ -60,6 +60,7 @@ export class InstanceEntityService {
 			latestRequestReceivedAt: instance.latestRequestReceivedAt ? instance.latestRequestReceivedAt.toISOString() : null,
 			isNSFW: instance.isNSFW,
 			rejectReports: instance.rejectReports,
+			rejectQuotes: instance.rejectQuotes,
 			moderationNote: iAmModerator ? instance.moderationNote : null,
 		};
 	}
diff --git a/packages/backend/src/core/entities/NoteEntityService.ts b/packages/backend/src/core/entities/NoteEntityService.ts
index dca73567cc..1c51aba09b 100644
--- a/packages/backend/src/core/entities/NoteEntityService.ts
+++ b/packages/backend/src/core/entities/NoteEntityService.ts
@@ -490,6 +490,7 @@ export class NoteEntityService implements OnModuleInit {
 
 			...(opts.detail ? {
 				clippedCount: note.clippedCount,
+				processErrors: note.processErrors,
 
 				reply: note.replyId ? this.pack(note.reply ?? note.replyId, me, {
 					detail: false,
diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts
index 4fbbbdd379..5d539ea264 100644
--- a/packages/backend/src/core/entities/UserEntityService.ts
+++ b/packages/backend/src/core/entities/UserEntityService.ts
@@ -674,6 +674,7 @@ export class UserEntityService implements OnModuleInit {
 				securityKeys: profile!.twoFactorEnabled
 					? this.userSecurityKeysRepository.countBy({ userId: user.id }).then(result => result >= 1)
 					: false,
+				rejectQuotes: user.rejectQuotes,
 			} : {}),
 
 			...(isDetailed && isMe ? {
diff --git a/packages/backend/src/models/Instance.ts b/packages/backend/src/models/Instance.ts
index ba93190c57..c64ebb1b3b 100644
--- a/packages/backend/src/models/Instance.ts
+++ b/packages/backend/src/models/Instance.ts
@@ -164,6 +164,15 @@ export class MiInstance {
 	})
 	public rejectReports: boolean;
 
+	/**
+	 * If true, quote posts from this instance will be downgraded to normal posts.
+	 * The quote will be stripped and a process error will be generated.
+	 */
+	@Column('boolean', {
+		default: false,
+	})
+	public rejectQuotes: boolean;
+
 	@Column('varchar', {
 		length: 16384, default: '',
 	})
diff --git a/packages/backend/src/models/Note.ts b/packages/backend/src/models/Note.ts
index 8b5265e8fe..2dabb75d83 100644
--- a/packages/backend/src/models/Note.ts
+++ b/packages/backend/src/models/Note.ts
@@ -203,6 +203,17 @@ export class MiNote {
 	@JoinColumn()
 	public channel: MiChannel | null;
 
+	/**
+	 * List of non-fatal errors encountered while processing (creating or updating) this note.
+	 * Entries can be a translation key (which will be queried from the "_processErrors" section) or a raw string.
+	 * Errors will be displayed to the user when viewing the note.
+	 */
+	@Column('text', {
+		array: true,
+		nullable: true,
+	})
+	public processErrors: string[] | null;
+
 	//#region Denormalized fields
 	@Index()
 	@Column('varchar', {
diff --git a/packages/backend/src/models/User.ts b/packages/backend/src/models/User.ts
index 8a3ad1003d..5d87c7fa12 100644
--- a/packages/backend/src/models/User.ts
+++ b/packages/backend/src/models/User.ts
@@ -348,6 +348,15 @@ export class MiUser {
 	})
 	public mandatoryCW: string | null;
 
+	/**
+	 * If true, quote posts from this user will be downgraded to normal posts.
+	 * The quote will be stripped and a process error will be generated.
+	 */
+	@Column('boolean', {
+		default: false,
+	})
+	public rejectQuotes: boolean;
+
 	constructor(data: Partial<MiUser>) {
 		if (data == null) return;
 
diff --git a/packages/backend/src/models/json-schema/federation-instance.ts b/packages/backend/src/models/json-schema/federation-instance.ts
index 7960e748e9..57d4466ffa 100644
--- a/packages/backend/src/models/json-schema/federation-instance.ts
+++ b/packages/backend/src/models/json-schema/federation-instance.ts
@@ -126,6 +126,11 @@ export const packedFederationInstanceSchema = {
 			optional: false,
 			nullable: false,
 		},
+		rejectQuotes: {
+			type: 'boolean',
+			optional: false,
+			nullable: false,
+		},
 		moderationNote: {
 			type: 'string',
 			optional: true, nullable: true,
diff --git a/packages/backend/src/models/json-schema/note.ts b/packages/backend/src/models/json-schema/note.ts
index 432c096e48..51d23fe5e7 100644
--- a/packages/backend/src/models/json-schema/note.ts
+++ b/packages/backend/src/models/json-schema/note.ts
@@ -256,6 +256,14 @@ export const packedNoteSchema = {
 			type: 'number',
 			optional: true, nullable: false,
 		},
+		processErrors: {
+			type: 'array',
+			optional: true, nullable: true,
+			items: {
+				type: 'string',
+				optional: false, nullable: false,
+			},
+		},
 
 		myReaction: {
 			type: 'string',
diff --git a/packages/backend/src/models/json-schema/user.ts b/packages/backend/src/models/json-schema/user.ts
index 1c2ba538c1..3d0bf44c2e 100644
--- a/packages/backend/src/models/json-schema/user.ts
+++ b/packages/backend/src/models/json-schema/user.ts
@@ -445,6 +445,10 @@ export const packedUserDetailedNotMeOnlySchema = {
 			type: 'boolean',
 			nullable: false, optional: true,
 		},
+		rejectQuotes: {
+			type: 'boolean',
+			nullable: false, optional: true,
+		},
 		//#region relations
 		isFollowing: {
 			type: 'boolean',
diff --git a/packages/backend/src/server/api/endpoints/admin/federation/update-instance.ts b/packages/backend/src/server/api/endpoints/admin/federation/update-instance.ts
index daf19c4435..24d0b8527c 100644
--- a/packages/backend/src/server/api/endpoints/admin/federation/update-instance.ts
+++ b/packages/backend/src/server/api/endpoints/admin/federation/update-instance.ts
@@ -27,6 +27,7 @@ export const paramDef = {
 		isNSFW: { type: 'boolean' },
 		rejectReports: { type: 'boolean' },
 		moderationNote: { type: 'string' },
+		rejectQuotes: { type: 'boolean' },
 	},
 	required: ['host'],
 } as const;
@@ -59,6 +60,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 				suspensionState,
 				isNSFW: ps.isNSFW,
 				rejectReports: ps.rejectReports,
+				rejectQuotes: ps.rejectQuotes,
 				moderationNote: ps.moderationNote,
 			});
 
@@ -92,6 +94,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 				});
 			}
 
+			if (ps.rejectQuotes != null && instance.rejectQuotes !== ps.rejectQuotes) {
+				const message = ps.rejectReports ? 'rejectQuotesInstance' : 'acceptQuotesInstance';
+				this.moderationLogService.log(me, message, {
+					id: instance.id,
+					host: instance.host,
+				});
+			}
+
 			if (ps.moderationNote != null && instance.moderationNote !== ps.moderationNote) {
 				this.moderationLogService.log(me, 'updateRemoteInstanceNote', {
 					id: instance.id,
diff --git a/packages/backend/src/server/api/endpoints/admin/reject-quotes.ts b/packages/backend/src/server/api/endpoints/admin/reject-quotes.ts
new file mode 100644
index 0000000000..78f94ceeff
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/admin/reject-quotes.ts
@@ -0,0 +1,63 @@
+/*
+ * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import type { UsersRepository } from '@/models/_.js';
+import { DI } from '@/di-symbols.js';
+import { GlobalEventService } from '@/core/GlobalEventService.js';
+import { CacheService } from '@/core/CacheService.js';
+import { ModerationLogService } from '@/core/ModerationLogService.js';
+
+export const meta = {
+	tags: ['admin'],
+
+	requireCredential: true,
+	requireModerator: true,
+	kind: 'write:admin:reject-quotes',
+} as const;
+
+export const paramDef = {
+	type: 'object',
+	properties: {
+		userId: { type: 'string', format: 'misskey:id' },
+		rejectQuotes: { type: 'boolean', nullable: false },
+	},
+	required: ['userId', 'rejectQuotes'],
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+	constructor(
+		@Inject(DI.usersRepository)
+		private readonly usersRepository: UsersRepository,
+
+		private readonly globalEventService: GlobalEventService,
+		private readonly cacheService: CacheService,
+		private readonly moderationLogService: ModerationLogService,
+	) {
+		super(meta, paramDef, async (ps, me) => {
+			const user = await this.cacheService.findUserById(ps.userId);
+
+			// Skip if there's nothing to do
+			if (user.rejectQuotes === ps.rejectQuotes) return;
+
+			// Log event first.
+			// This ensures that we don't "lose" the log if an error occurs
+			await this.moderationLogService.log(me, ps.rejectQuotes ? 'rejectQuotesUser' : 'acceptQuotesUser', {
+				userId: user.id,
+				userUsername: user.username,
+				userHost: user.host,
+			});
+
+			await this.usersRepository.update(ps.userId, {
+				rejectQuotes: ps.rejectQuotes,
+			});
+
+			// Synchronize caches and other processes
+			this.globalEventService.publishInternalEvent('localUserUpdated', { id: ps.userId });
+		});
+	}
+}
diff --git a/packages/backend/src/server/api/endpoints/notes/create.ts b/packages/backend/src/server/api/endpoints/notes/create.ts
index d1cf0123dc..b0f32bfda8 100644
--- a/packages/backend/src/server/api/endpoints/notes/create.ts
+++ b/packages/backend/src/server/api/endpoints/notes/create.ts
@@ -143,6 +143,12 @@ export const meta = {
 			code: 'CONTAINS_TOO_MANY_MENTIONS',
 			id: '4de0363a-3046-481b-9b0f-feff3e211025',
 		},
+
+		quoteDisabledForUser: {
+			message: 'You do not have permission to create quote posts.',
+			code: 'QUOTE_DISABLED_FOR_USER',
+			id: '1c0ea108-d1e3-4e8e-aa3f-4d2487626153',
+		},
 	},
 } as const;
 
@@ -415,6 +421,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 						throw new ApiError(meta.errors.containsProhibitedWords);
 					} else if (e.id === '9f466dab-c856-48cd-9e65-ff90ff750580') {
 						throw new ApiError(meta.errors.containsTooManyMentions);
+					} else if (e.id === '1c0ea108-d1e3-4e8e-aa3f-4d2487626153') {
+						throw new ApiError(meta.errors.quoteDisabledForUser);
 					}
 				}
 				throw e;
diff --git a/packages/backend/src/server/api/endpoints/notes/edit.ts b/packages/backend/src/server/api/endpoints/notes/edit.ts
index dc94c78e75..cc2293c5d6 100644
--- a/packages/backend/src/server/api/endpoints/notes/edit.ts
+++ b/packages/backend/src/server/api/endpoints/notes/edit.ts
@@ -176,6 +176,12 @@ export const meta = {
 			id: '33510210-8452-094c-6227-4a6c05d99f02',
 		},
 
+		quoteDisabledForUser: {
+			message: 'You do not have permission to create quote posts.',
+			code: 'QUOTE_DISABLED_FOR_USER',
+			id: '1c0ea108-d1e3-4e8e-aa3f-4d2487626153',
+		},
+
 		containsProhibitedWords: {
 			message: 'Cannot post because it contains prohibited words.',
 			code: 'CONTAINS_PROHIBITED_WORDS',
@@ -469,6 +475,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 						throw new ApiError(meta.errors.containsProhibitedWords);
 					} else if (e.id === '9f466dab-c856-48cd-9e65-ff90ff750580') {
 						throw new ApiError(meta.errors.containsTooManyMentions);
+					} else if (e.id === '1c0ea108-d1e3-4e8e-aa3f-4d2487626153') {
+						throw new ApiError(meta.errors.quoteDisabledForUser);
 					}
 				}
 				throw e;
diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts
index b359fa5a39..b5d982e3a5 100644
--- a/packages/backend/src/types.ts
+++ b/packages/backend/src/types.ts
@@ -132,6 +132,10 @@ export const moderationLogTypes = [
 	'deletePage',
 	'deleteFlash',
 	'deleteGalleryPost',
+	'acceptQuotesUser',
+	'rejectQuotesUser',
+	'acceptQuotesInstance',
+	'rejectQuotesInstance',
 ] as const;
 
 export type ModerationLogPayloads = {
@@ -417,6 +421,24 @@ export type ModerationLogPayloads = {
 		postUserUsername: string;
 		post: any;
 	};
+	acceptQuotesUser: {
+		userId: string,
+		userUsername: string,
+		userHost: string | null,
+	};
+	rejectQuotesUser: {
+		userId: string,
+		userUsername: string,
+		userHost: string | null,
+	};
+	acceptQuotesInstance: {
+		id: string;
+		host: string;
+	};
+	rejectQuotesInstance: {
+		id: string;
+		host: string;
+	};
 };
 
 export type Serialized<T> = {
diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue
index 0bac6a67b9..4b174d7336 100644
--- a/packages/frontend/src/components/MkNote.vue
+++ b/packages/frontend/src/components/MkNote.vue
@@ -142,7 +142,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 					<i class="ti ti-ban"></i>
 				</button>
 				<button
-					v-if="canRenote && !props.mock"
+					v-if="canRenote && !props.mock && !$i?.rejectQuotes"
 					ref="quoteButton"
 					:class="$style.footerButton"
 					class="_button"
diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue
index 582838ebca..7c5494e2fa 100644
--- a/packages/frontend/src/components/MkNoteDetailed.vue
+++ b/packages/frontend/src/components/MkNoteDetailed.vue
@@ -153,7 +153,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 				<i class="ti ti-ban"></i>
 			</button>
 			<button
-				v-if="canRenote"
+				v-if="canRenote && !$i?.rejectQuotes"
 				ref="quoteButton"
 				class="_button"
 				:class="$style.noteFooterButton"
diff --git a/packages/frontend/src/components/MkNoteSub.vue b/packages/frontend/src/components/MkNoteSub.vue
index 61006cfdcc..b2967a7cf3 100644
--- a/packages/frontend/src/components/MkNoteSub.vue
+++ b/packages/frontend/src/components/MkNoteSub.vue
@@ -38,7 +38,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 					<p v-if="note.renoteCount > 0" :class="$style.noteFooterButtonCount">{{ note.renoteCount }}</p>
 				</button>
 				<button
-					v-if="canRenote"
+					v-if="canRenote && !$i?.rejectQuotes"
 					ref="quoteButton"
 					class="_button"
 					:class="$style.noteFooterButton"
diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue
index c5f5f4514d..ca227d649a 100644
--- a/packages/frontend/src/components/MkPostForm.vue
+++ b/packages/frontend/src/components/MkPostForm.vue
@@ -640,7 +640,7 @@ async function onPaste(ev: ClipboardEvent) {
 
 	const paste = ev.clipboardData.getData('text');
 
-	if (!renoteTargetNote.value && !quoteId.value && paste.startsWith(url + '/notes/')) {
+	if (!renoteTargetNote.value && !quoteId.value && paste.startsWith(url + '/notes/') && !$i.rejectQuotes) {
 		ev.preventDefault();
 
 		os.confirm({
diff --git a/packages/frontend/src/components/SkErrorList.vue b/packages/frontend/src/components/SkErrorList.vue
new file mode 100644
index 0000000000..31d6596e3c
--- /dev/null
+++ b/packages/frontend/src/components/SkErrorList.vue
@@ -0,0 +1,43 @@
+<!--
+SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<!-- Match appearance of MkRemoteCaution.vue -->
+<div v-for="error of displayErrors" :key="error" :class="$style.root">
+	<i :class="$style.icon" class="ti ti-alert-triangle"></i>{{ i18n.ts._processErrors[error] ?? error }}
+</div>
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue';
+import { i18n } from '@/i18n.js';
+
+const props = defineProps<{
+	errors?: string[] | null;
+}>();
+
+const displayErrors = computed<Iterable<string>>(() => {
+	if (!props.errors?.length) return [];
+
+	// Set constructor preserve order, so we can sort first to avoid a copy operation.
+	return new Set(props.errors.toSorted());
+});
+</script>
+
+<style module lang="scss">
+.root {
+	font-size: 0.8em;
+	padding: 16px;
+	background: color-mix(in srgb, var(--MI_THEME-infoWarnBg) 65%, transparent);
+	color: var(--MI_THEME-infoWarnFg);
+	border-radius: var(--MI-radius);
+	overflow: clip;
+	z-index: 1;
+}
+
+.icon {
+	margin-right: 8px;
+}
+</style>
diff --git a/packages/frontend/src/components/SkNote.vue b/packages/frontend/src/components/SkNote.vue
index 777e95593d..576def41ed 100644
--- a/packages/frontend/src/components/SkNote.vue
+++ b/packages/frontend/src/components/SkNote.vue
@@ -143,7 +143,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 					<i class="ti ti-ban"></i>
 				</button>
 				<button
-					v-if="canRenote && !props.mock"
+					v-if="canRenote && !props.mock && !$i?.rejectQuotes"
 					ref="quoteButton"
 					:class="$style.footerButton"
 					class="_button"
diff --git a/packages/frontend/src/components/SkNoteDetailed.vue b/packages/frontend/src/components/SkNoteDetailed.vue
index 471a4ce7d7..610e1eea52 100644
--- a/packages/frontend/src/components/SkNoteDetailed.vue
+++ b/packages/frontend/src/components/SkNoteDetailed.vue
@@ -158,7 +158,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 				<i class="ti ti-ban"></i>
 			</button>
 			<button
-				v-if="canRenote"
+				v-if="canRenote && !$i?.rejectQuotes"
 				ref="quoteButton"
 				class="_button"
 				:class="$style.noteFooterButton"
diff --git a/packages/frontend/src/components/SkNoteSub.vue b/packages/frontend/src/components/SkNoteSub.vue
index 060e66be16..12703e1945 100644
--- a/packages/frontend/src/components/SkNoteSub.vue
+++ b/packages/frontend/src/components/SkNoteSub.vue
@@ -46,7 +46,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 					<p v-if="note.renoteCount > 0" :class="$style.noteFooterButtonCount">{{ note.renoteCount }}</p>
 				</button>
 				<button
-					v-if="canRenote"
+					v-if="canRenote && !$i?.rejectQuotes"
 					ref="quoteButton"
 					class="_button"
 					:class="$style.noteFooterButton"
diff --git a/packages/frontend/src/pages/admin-user.vue b/packages/frontend/src/pages/admin-user.vue
index 229f581672..30298a9af6 100644
--- a/packages/frontend/src/pages/admin-user.vue
+++ b/packages/frontend/src/pages/admin-user.vue
@@ -81,6 +81,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 					<div class="_gaps">
 						<MkSwitch v-model="silenced" @update:modelValue="toggleSilence">{{ i18n.ts.silence }}</MkSwitch>
 						<MkSwitch v-if="!isSystem" v-model="suspended" @update:modelValue="toggleSuspend">{{ i18n.ts.suspend }}</MkSwitch>
+						<MkSwitch v-model="rejectQuotes" @update:modelValue="toggleRejectQuotes">{{ i18n.ts.rejectQuotesUser }}</MkSwitch>
 						<MkSwitch v-model="markedAsNSFW" @update:modelValue="toggleNSFW">{{ i18n.ts.markAsNSFW }}</MkSwitch>
 
 						<MkInput v-model="mandatoryCW" type="text" manualSave @update:modelValue="onMandatoryCWChanged">
@@ -247,6 +248,7 @@ const moderator = ref(false);
 const silenced = ref(false);
 const approved = ref(false);
 const suspended = ref(false);
+const rejectQuotes = ref(false);
 const markedAsNSFW = ref(false);
 const moderationNote = ref('');
 const mandatoryCW = ref<string | null>(null);
@@ -287,6 +289,7 @@ function createFetcher() {
 		approved.value = info.value.approved;
 		markedAsNSFW.value = info.value.alwaysMarkNsfw;
 		suspended.value = info.value.isSuspended;
+		rejectQuotes.value = user.value.rejectQuotes ?? false;
 		moderationNote.value = info.value.moderationNote;
 		mandatoryCW.value = user.value.mandatoryCW;
 	});
@@ -368,6 +371,22 @@ async function toggleSuspend(v) {
 	}
 }
 
+async function toggleRejectQuotes(v: boolean): Promise<void> {
+	const confirm = await os.confirm({
+		type: 'warning',
+		text: v ? i18n.ts.rejectQuotesConfirm : i18n.ts.allowQuotesConfirm,
+	});
+	if (confirm.canceled) {
+		rejectQuotes.value = !v;
+	} else {
+		await misskeyApi('admin/reject-quotes', {
+			userId: props.userId,
+			rejectQuotes: v,
+		});
+		await refreshUser();
+	}
+}
+
 async function unsetUserAvatar() {
 	const confirm = await os.confirm({
 		type: 'warning',
diff --git a/packages/frontend/src/pages/admin/modlog.ModLog.vue b/packages/frontend/src/pages/admin/modlog.ModLog.vue
index 741de875bc..306a873173 100644
--- a/packages/frontend/src/pages/admin/modlog.ModLog.vue
+++ b/packages/frontend/src/pages/admin/modlog.ModLog.vue
@@ -28,6 +28,8 @@ SPDX-License-Identifier: AGPL-3.0-only
 					'unsetRemoteInstanceNSFW',
 					'rejectRemoteInstanceReports',
 					'acceptRemoteInstanceReports',
+					'rejectQuotesUser',
+					'acceptQuotesUser',
 				].includes(log.type),
 				[$style.logRed]: [
 					'suspend',
@@ -53,6 +55,10 @@ SPDX-License-Identifier: AGPL-3.0-only
 		<span v-if="log.type === 'updateUserNote'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</span>
 		<span v-else-if="log.type === 'suspend'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</span>
 		<span v-else-if="log.type === 'approve'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</span>
+		<span v-else-if="log.type === 'rejectQuotesUser'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</span>
+		<span v-else-if="log.type === 'acceptQuotesUser'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</span>
+		<span v-else-if="log.type === 'rejectQuotesInstance'">: {{ log.info.host }}</span>
+		<span v-else-if="log.type === 'acceptQuotesInstance'">: {{ log.info.host }}</span>
 		<span v-else-if="log.type === 'decline'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</span>
 		<span v-else-if="log.type === 'unsuspend'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</span>
 		<span v-else-if="log.type === 'resetPassword'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</span>
@@ -134,6 +140,12 @@ SPDX-License-Identifier: AGPL-3.0-only
 		<template v-else-if="log.type === 'unsuspend'">
 			<div>{{ i18n.ts.user }}: <MkA :to="`/admin/user/${log.info.userId}`" class="_link">@{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</MkA></div>
 		</template>
+		<template v-else-if="log.type === 'rejectQuotesUser'">
+			<div>{{ i18n.ts.user }}: <MkA :to="`/admin/user/${log.info.userId}`" class="_link">@{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</MkA></div>
+		</template>
+		<template v-else-if="log.type === 'acceptQuotesUser'">
+			<div>{{ i18n.ts.user }}: <MkA :to="`/admin/user/${log.info.userId}`" class="_link">@{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</MkA></div>
+		</template>
 		<template v-else-if="log.type === 'updateRole'">
 			<div :class="$style.diff">
 				<CodeDiff :context="5" :hideHeader="true" :oldString="JSON5.stringify(log.info.before, null, '\t')" :newString="JSON5.stringify(log.info.after, null, '\t')" language="javascript" maxHeight="300px"/>
diff --git a/packages/frontend/src/pages/instance-info.vue b/packages/frontend/src/pages/instance-info.vue
index 96aebb14d2..32a9d30264 100644
--- a/packages/frontend/src/pages/instance-info.vue
+++ b/packages/frontend/src/pages/instance-info.vue
@@ -53,6 +53,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 						<MkInfo v-if="isBaseSilenced" warn>{{ i18n.ts.silencedByBase }}</MkInfo>
 						<MkSwitch v-model="isSilenced" :disabled="!meta || !instance || isBaseSilenced" @update:modelValue="toggleSilenced">{{ i18n.ts.silenceThisInstance }}</MkSwitch>
 						<MkSwitch v-model="isNSFW" :disabled="!instance" @update:modelValue="toggleNSFW">{{ i18n.ts.markInstanceAsNSFW }}</MkSwitch>
+						<MkSwitch v-model="rejectQuotes" :disabled="!instance" @update:modelValue="toggleRejectQuotes">{{ i18n.ts.rejectQuotesInstance }}</MkSwitch>
 						<MkSwitch v-model="rejectReports" :disabled="!instance" @update:modelValue="toggleRejectReports">{{ i18n.ts.rejectReports }}</MkSwitch>
 						<MkInfo v-if="isBaseMediaSilenced" warn>{{ i18n.ts.mediaSilencedByBase }}</MkInfo>
 						<MkSwitch v-model="isMediaSilenced" :disabled="!meta || !instance || isBaseMediaSilenced" @update:modelValue="toggleMediaSilenced">{{ i18n.ts.mediaSilenceThisInstance }}</MkSwitch>
@@ -211,6 +212,7 @@ const isSuspended = ref(false);
 const isBlocked = ref(false);
 const isSilenced = ref(false);
 const isNSFW = ref(false);
+const rejectQuotes = ref(false);
 const rejectReports = ref(false);
 const isMediaSilenced = ref(false);
 const faviconUrl = ref<string | null>(null);
@@ -282,6 +284,7 @@ async function fetch(): Promise<void> {
 	isSilenced.value = instance.value?.isSilenced ?? false;
 	isNSFW.value = instance.value?.isNSFW ?? false;
 	rejectReports.value = instance.value?.rejectReports ?? false;
+	rejectQuotes.value = instance.value?.rejectQuotes ?? false;
 	isMediaSilenced.value = instance.value?.isMediaSilenced ?? false;
 	faviconUrl.value = getProxiedImageUrlNullable(instance.value?.faviconUrl, 'preview') ?? getProxiedImageUrlNullable(instance.value?.iconUrl, 'preview');
 	moderationNote.value = instance.value?.moderationNote ?? '';
@@ -347,6 +350,15 @@ async function toggleRejectReports(): Promise<void> {
 	});
 }
 
+async function toggleRejectQuotes(): Promise<void> {
+	if (!iAmModerator) return;
+	if (!instance.value) throw new Error('No instance?');
+	await misskeyApi('admin/federation/update-instance', {
+		host: instance.value.host,
+		rejectQuotes: rejectQuotes.value,
+	});
+}
+
 function refreshMetadata(): void {
 	if (!iAmModerator) return;
 	if (!instance.value) throw new Error('No instance?');
diff --git a/packages/frontend/src/pages/note.vue b/packages/frontend/src/pages/note.vue
index b70bff052a..5214ca4849 100644
--- a/packages/frontend/src/pages/note.vue
+++ b/packages/frontend/src/pages/note.vue
@@ -21,6 +21,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 						</div>
 						<div class="_margin _gaps_s">
 							<MkRemoteCaution v-if="note.user.host != null" :href="note.url ?? note.uri"/>
+							<SkErrorList :errors="note.processErrors"/>
 							<MkNoteDetailed :key="note.id" v-model:note="note" :initialTab="initialTab" :class="$style.note" :expandAllCws="expandAllCws"/>
 						</div>
 						<div v-if="clips && clips.length > 0" class="_margin">
@@ -60,6 +61,7 @@ import { definePageMetadata } from '@/scripts/page-metadata.js';
 import { i18n } from '@/i18n.js';
 import { dateString } from '@/filters/date.js';
 import MkClipPreview from '@/components/MkClipPreview.vue';
+import SkErrorList from '@/components/SkErrorList.vue';
 import { defaultStore } from '@/store.js';
 import { pleaseLogin } from '@/scripts/please-login.js';
 import { serverContext, assertServerContext } from '@/server-context.js';
@@ -69,9 +71,9 @@ import { $i } from '@/account.js';
 const CTX_NOTE = !$i && assertServerContext(serverContext, 'note') ? serverContext.note : null;
 
 const MkNoteDetailed = defineAsyncComponent(() =>
-	(defaultStore.state.noteDesign === 'misskey') ? import('@/components/MkNoteDetailed.vue') :
-	(defaultStore.state.noteDesign === 'sharkey') ? import('@/components/SkNoteDetailed.vue') :
-	null
+	(defaultStore.state.noteDesign === 'misskey')
+		? import('@/components/MkNoteDetailed.vue')
+		: import('@/components/SkNoteDetailed.vue'),
 );
 
 const props = defineProps<{
diff --git a/packages/misskey-js/src/consts.ts b/packages/misskey-js/src/consts.ts
index 96da0a8fad..b520c05d8e 100644
--- a/packages/misskey-js/src/consts.ts
+++ b/packages/misskey-js/src/consts.ts
@@ -89,6 +89,7 @@ export const permissions = [
 	'write:admin:unset-user-avatar',
 	'write:admin:unset-user-banner',
 	'write:admin:unsuspend-user',
+	'write:admin:reject-quotes',
 	'write:admin:meta',
 	'write:admin:user-note',
 	'write:admin:roles',
@@ -491,4 +492,22 @@ export type ModerationLogPayloads = {
 		postUserUsername: string;
 		post: GalleryPost;
 	};
+	acceptQuotesUser: {
+		userId: string,
+		userUsername: string,
+		userHost: string | null,
+	};
+	rejectQuotesUser: {
+		userId: string,
+		userUsername: string,
+		userHost: string | null,
+	};
+	acceptQuotesInstance: {
+		id: string;
+		host: string;
+	};
+	rejectQuotesInstance: {
+		id: string;
+		host: string;
+	};
 };
diff --git a/packages/misskey-js/src/entities.ts b/packages/misskey-js/src/entities.ts
index 3e88eae275..7b5d56f01c 100644
--- a/packages/misskey-js/src/entities.ts
+++ b/packages/misskey-js/src/entities.ts
@@ -60,6 +60,18 @@ export type ModerationLog = {
 } | {
 	type: 'unsuspend';
 	info: ModerationLogPayloads['unsuspend'];
+} | {
+	type: 'acceptQuotesUser';
+	info: ModerationLogPayloads['acceptQuotesUser'];
+} | {
+	type: 'rejectQuotesUser';
+	info: ModerationLogPayloads['rejectQuotesUser'];
+} | {
+	type: 'acceptQuotesInstance';
+	info: ModerationLogPayloads['acceptQuotesInstance'];
+} | {
+	type: 'rejectQuotesInstance';
+	info: ModerationLogPayloads['rejectQuotesInstance'];
 } | {
 	type: 'updateUserNote';
 	info: ModerationLogPayloads['updateUserNote'];
diff --git a/sharkey-locales/en-US.yml b/sharkey-locales/en-US.yml
index af2db91286..6c21c76959 100644
--- a/sharkey-locales/en-US.yml
+++ b/sharkey-locales/en-US.yml
@@ -41,6 +41,10 @@ continueOnRemote: "Continue on remote instance"
 chooseServerOnMisskeyHub: "Choose a instance from Misskey Hub"
 mediaSilenceThisInstance: "Silence media from this instance"
 rejectReports: "Reject reports from this instance"
+rejectQuotesInstance: "Reject quote posts from this instance"
+rejectQuotesUser: "Reject quote posts from this user"
+rejectQuotesConfirm: "Are you sure you wish to reject quote posts?"
+allowQuotesConfirm: "Are you sure you wish to allow quote posts?"
 silencedInstancesDescription: "List the host names of the instances that you want to silence, separated by a new line. All accounts belonging to the listed instances will be treated as silenced, and can only make follow requests, and cannot mention local accounts if not followed. This will not affect the blocked instances."
 mediaSilencedInstances: "Media-silenced instances"
 mediaSilencedInstancesDescription: "List the host names of the instances that you want to media-silence, separated by a new line. All accounts belonging to the listed instances will be treated as sensitive, and can't use custom emojis. This will not affect the blocked instances."
@@ -309,6 +313,8 @@ _moderationLogTypes:
   unsetRemoteInstanceNSFW: "Set remote instance as NSFW"
   rejectRemoteInstanceReports: "Rejected reports from remote instance"
   acceptRemoteInstanceReports: "Accepted reports from remote instance"
+  rejectQuotesUser: "Rejected quotes from user"
+  allowQuotesUser: "Allowed quotes from user"
 _mfm:
   uncommonFeature: "This is not a widespread feature, it may not display properly on most other fedi software, including other Misskey forks"
   intro: "MFM is a markup language used on Misskey, Sharkey, Firefish, Akkoma, and more that can be used in many places. Here you can view a list of all available MFM syntax."
@@ -441,6 +447,7 @@ _permissions:
   "write:admin:cw-user": "Apply mandatory CW on users"
   "write:admin:silence-user": "Silence users"
   "write:admin:unsilence-user": "Un-silence users"
+  "write:admin:reject-quotes": "Allow/Reject quote posts from a user"
   "read:notes-schedule": "View your list of scheduled notes"
   "write:notes-schedule": "Compose or delete scheduled notes"
 
@@ -477,3 +484,6 @@ id: "ID"
 
 mandatoryCW: "Force content warning"
 mandatoryCWDescription: "Applies a content warning to all posts created by this user. If the post already has a CW, then this is appended to the end."
+
+_processErrors:
+  quoteUnavailable: "Unable to process quote. This post may be missing context."
-- 
cgit v1.2.3-freya


From 3642ea022b36dea2ab356a2cc5e1f38042c69a8b Mon Sep 17 00:00:00 2001
From: Hazelnoot <acomputerdog@gmail.com>
Date: Thu, 20 Feb 2025 21:34:09 -0500
Subject: disable inline note previews from users with quotes disabled

---
 packages/backend/src/models/json-schema/note.ts     |  5 +++++
 packages/frontend/src/components/MkNote.vue         |  2 +-
 packages/frontend/src/components/MkNoteDetailed.vue |  2 +-
 packages/frontend/src/components/MkUrlPreview.vue   | 15 +++++++++------
 packages/frontend/src/components/SkNote.vue         |  2 +-
 packages/frontend/src/components/SkNoteDetailed.vue |  2 +-
 packages/frontend/src/components/page/page.text.vue |  2 +-
 packages/misskey-js/src/autogen/types.ts            |  2 ++
 8 files changed, 21 insertions(+), 11 deletions(-)

(limited to 'packages/frontend/src/components/MkNoteDetailed.vue')

diff --git a/packages/backend/src/models/json-schema/note.ts b/packages/backend/src/models/json-schema/note.ts
index 51d23fe5e7..16e240ab11 100644
--- a/packages/backend/src/models/json-schema/note.ts
+++ b/packages/backend/src/models/json-schema/note.ts
@@ -17,6 +17,11 @@ export const packedNoteSchema = {
 			optional: false, nullable: false,
 			format: 'date-time',
 		},
+		updatedAt: {
+			type: 'string',
+			optional: true, nullable: false,
+			format: 'date-time',
+		},
 		deletedAt: {
 			type: 'string',
 			optional: true, nullable: true,
diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue
index 4b174d7336..3f52244bdc 100644
--- a/packages/frontend/src/components/MkNote.vue
+++ b/packages/frontend/src/components/MkNote.vue
@@ -102,7 +102,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 					</div>
 					<MkPoll v-if="appearNote.poll" :noteId="appearNote.id" :poll="appearNote.poll" :local="!appearNote.user.host" :author="appearNote.user" :emojiUrls="appearNote.emojis" :class="$style.poll" @click.stop/>
 					<div v-if="isEnabledUrlPreview">
-						<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" :showAsQuote="true" :skipNoteIds="[appearNote.renote?.id]" :class="$style.urlPreview" @click.stop/>
+						<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" :showAsQuote="!appearNote.user.rejectQuotes" :skipNoteIds="[appearNote.renote?.id]" :class="$style.urlPreview" @click.stop/>
 					</div>
 					<div v-if="appearNote.renote" :class="$style.quote"><MkNoteSimple :note="appearNote.renote" :class="$style.quoteNote"/></div>
 					<button v-if="isLong && collapsed" :class="$style.collapsed" class="_button" @click.stop @click="collapsed = false">
diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue
index 7c5494e2fa..1daca114c0 100644
--- a/packages/frontend/src/components/MkNoteDetailed.vue
+++ b/packages/frontend/src/components/MkNoteDetailed.vue
@@ -117,7 +117,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 				</div>
 				<MkPoll v-if="appearNote.poll" ref="pollViewer" :noteId="appearNote.id" :poll="appearNote.poll" :local="!appearNote.user.host" :class="$style.poll" :author="appearNote.user" :emojiUrls="appearNote.emojis"/>
 				<div v-if="isEnabledUrlPreview">
-					<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="true" :showAsQuote="true" :skipNoteIds="[appearNote.renote?.id]" style="margin-top: 6px;"/>
+					<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="true" :showAsQuote="!appearNote.user.rejectQuotes" :skipNoteIds="[appearNote.renote?.id]" style="margin-top: 6px;"/>
 				</div>
 				<div v-if="appearNote.renote" :class="$style.quote"><MkNoteSimple :note="appearNote.renote" :class="$style.quoteNote" :expandAllCws="props.expandAllCws"/></div>
 			</div>
diff --git a/packages/frontend/src/components/MkUrlPreview.vue b/packages/frontend/src/components/MkUrlPreview.vue
index 3fbfdda92c..3063c77c0e 100644
--- a/packages/frontend/src/components/MkUrlPreview.vue
+++ b/packages/frontend/src/components/MkUrlPreview.vue
@@ -87,20 +87,22 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { defineAsyncComponent, onDeactivated, onUnmounted, ref, watch } from 'vue';
 import { url as local } from '@@/js/config.js';
 import { versatileLang } from '@@/js/intl-const.js';
+import * as Misskey from 'misskey-js';
 import type { summaly } from '@misskey-dev/summaly';
+import type MkNoteSimple from '@/components/MkNoteSimple.vue';
+import type SkNoteSimple from '@/components/SkNoteSimple.vue';
 import { i18n } from '@/i18n.js';
 import * as os from '@/os.js';
 import { deviceKind } from '@/scripts/device-kind.js';
 import MkButton from '@/components/MkButton.vue';
 import { transformPlayerUrl } from '@/scripts/player-url-transform.js';
 import { defaultStore } from '@/store.js';
-import * as Misskey from 'misskey-js';
 import { misskeyApi } from '@/scripts/misskey-api.js';
 
-const XNoteSimple = defineAsyncComponent(() =>
-	(defaultStore.state.noteDesign === 'misskey') ? import('@/components/MkNoteSimple.vue') :
-	(defaultStore.state.noteDesign === 'sharkey') ? import('@/components/SkNoteSimple.vue') :
-	null
+const XNoteSimple = defineAsyncComponent<typeof MkNoteSimple | typeof SkNoteSimple>(() =>
+	defaultStore.state.noteDesign === 'misskey'
+		? import('@/components/MkNoteSimple.vue')
+		: import('@/components/SkNoteSimple.vue'),
 );
 
 type SummalyResult = Awaited<ReturnType<typeof summaly>>;
@@ -111,12 +113,13 @@ const props = withDefaults(defineProps<{
 	compact?: boolean;
 	showAsQuote?: boolean;
 	showActions?: boolean;
-	skipNoteIds?: string[];
+	skipNoteIds?: (string | undefined)[];
 }>(), {
 	detail: false,
 	compact: false,
 	showAsQuote: false,
 	showActions: true,
+	skipNoteIds: undefined,
 });
 
 const MOBILE_THRESHOLD = 500;
diff --git a/packages/frontend/src/components/SkNote.vue b/packages/frontend/src/components/SkNote.vue
index 576def41ed..d4d4ca699d 100644
--- a/packages/frontend/src/components/SkNote.vue
+++ b/packages/frontend/src/components/SkNote.vue
@@ -104,7 +104,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 					</div>
 					<MkPoll v-if="appearNote.poll" :noteId="appearNote.id" :poll="appearNote.poll" :local="!appearNote.user.host" :author="appearNote.user" :emojiUrls="appearNote.emojis" :class="$style.poll" @click.stop/>
 					<div v-if="isEnabledUrlPreview">
-						<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" :showAsQuote="true" :skipNoteIds="[appearNote.renote?.id]" :class="$style.urlPreview" @click.stop/>
+						<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" :showAsQuote="!appearNote.user.rejectQuotes" :skipNoteIds="[appearNote.renote?.id]" :class="$style.urlPreview" @click.stop/>
 					</div>
 					<div v-if="appearNote.renote" :class="$style.quote"><SkNoteSimple :note="appearNote.renote" :class="$style.quoteNote"/></div>
 					<button v-if="isLong && collapsed" :class="$style.collapsed" class="_button" @click.stop @click="collapsed = false">
diff --git a/packages/frontend/src/components/SkNoteDetailed.vue b/packages/frontend/src/components/SkNoteDetailed.vue
index 610e1eea52..3787b54333 100644
--- a/packages/frontend/src/components/SkNoteDetailed.vue
+++ b/packages/frontend/src/components/SkNoteDetailed.vue
@@ -122,7 +122,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 				</div>
 				<MkPoll v-if="appearNote.poll" ref="pollViewer" :noteId="appearNote.id" :poll="appearNote.poll" :local="!appearNote.user.host" :class="$style.poll" :author="appearNote.user" :emojiUrls="appearNote.emojis"/>
 				<div v-if="isEnabledUrlPreview">
-					<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="true" :showAsQuote="true" :skipNoteIds="[appearNote.renote?.id]" style="margin-top: 6px;"/>
+					<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="true" :showAsQuote="!appearNote.user.rejectQuotes" :skipNoteIds="[appearNote.renote?.id]" style="margin-top: 6px;"/>
 				</div>
 				<div v-if="appearNote.renote" :class="$style.quote"><SkNoteSimple :note="appearNote.renote" :class="$style.quoteNote" :expandAllCws="props.expandAllCws"/></div>
 			</div>
diff --git a/packages/frontend/src/components/page/page.text.vue b/packages/frontend/src/components/page/page.text.vue
index 5f88acb11d..e5b1eff294 100644
--- a/packages/frontend/src/components/page/page.text.vue
+++ b/packages/frontend/src/components/page/page.text.vue
@@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 <div class="_gaps" :class="$style.textRoot">
 	<Mfm :text="block.text ?? ''" :isBlock="true" :isNote="false"/>
 	<div v-if="isEnabledUrlPreview" class="_gaps_s">
-		<MkUrlPreview v-for="url in urls" :key="url" :url="url"/>
+		<MkUrlPreview v-for="url in urls" :key="url" :url="url" :showAsQuote="!page.user.rejectQuotes"/>
 	</div>
 </div>
 </template>
diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts
index f9dc6525d0..d58607bb3b 100644
--- a/packages/misskey-js/src/autogen/types.ts
+++ b/packages/misskey-js/src/autogen/types.ts
@@ -4350,6 +4350,8 @@ export type components = {
       /** Format: date-time */
       createdAt: string;
       /** Format: date-time */
+      updatedAt?: string;
+      /** Format: date-time */
       deletedAt?: string | null;
       text: string | null;
       cw?: string | null;
-- 
cgit v1.2.3-freya

