From 69ed5611cfb80cc1b1c84717dfc660a73050941e Mon Sep 17 00:00:00 2001
From: Hazelnoot <acomputerdog@gmail.com>
Date: Wed, 28 May 2025 13:32:51 -0400
Subject: re-implement preview groups as SkUrlPreviewGroup

---
 .../frontend/src/components/SkUrlPreviewGroup.vue  | 280 +++++++++++++++++++++
 1 file changed, 280 insertions(+)
 create mode 100644 packages/frontend/src/components/SkUrlPreviewGroup.vue

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

diff --git a/packages/frontend/src/components/SkUrlPreviewGroup.vue b/packages/frontend/src/components/SkUrlPreviewGroup.vue
new file mode 100644
index 0000000000..4c43c989bd
--- /dev/null
+++ b/packages/frontend/src/components/SkUrlPreviewGroup.vue
@@ -0,0 +1,280 @@
+<!--
+SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div v-if="isRefreshing">
+	<MkLoading :class="$style.loading"></MkLoading>
+</div>
+<template v-else>
+	<MkUrlPreview
+		v-for="preview of urlPreviews"
+		:key="preview.url"
+		:url="preview.url"
+		:previewHint="preview"
+		:noteHint="preview.note"
+		:detail="detail"
+		:compact="compact"
+		:showAsQuote="showAsQuote"
+		:showActions="showActions"
+		:skipNoteIds="skipNoteIds"
+	></MkUrlPreview>
+</template>
+</template>
+
+<script setup lang="ts">
+import * as Misskey from 'misskey-js';
+import * as mfm from '@transfem-org/sfm-js';
+import { computed, ref, watch } from 'vue';
+import { versatileLang } from '@@/js/intl-const';
+import promiseLimit from 'promise-limit';
+import type { summaly } from '@misskey-dev/summaly';
+import { extractPreviewUrls } from '@/utility/extract-preview-urls';
+import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm';
+import { $i } from '@/i';
+import { misskeyApi } from '@/utility/misskey-api';
+import MkUrlPreview from '@/components/MkUrlPreview.vue';
+
+type Summary = Awaited<ReturnType<typeof summaly>> & {
+	haveNoteLocally?: boolean;
+	note?: Misskey.entities.Note | null;
+};
+
+const props = withDefaults(defineProps<{
+	sourceUrls?: string[];
+	sourceNodes?: mfm.MfmNode[];
+	sourceText?: string;
+	sourceNote?: Misskey.entities.Note;
+
+	detail?: boolean;
+	compact?: boolean;
+	showAsQuote?: boolean;
+	showActions?: boolean;
+	skipNoteIds?: string[];
+}>(), {
+	sourceUrls: undefined,
+	sourceText: undefined,
+	sourceNodes: undefined,
+	sourceNote: undefined,
+
+	detail: undefined,
+	compact: undefined,
+	showAsQuote: undefined,
+	showActions: undefined,
+	skipNoteIds: () => [],
+});
+
+const urlPreviews = ref<Summary[]>([]);
+
+const urls = computed<string[]>(() => {
+	if (props.sourceUrls) {
+		return props.sourceUrls;
+	}
+
+	// sourceNodes > sourceText > sourceNote
+	const source =
+		props.sourceNodes ??
+		(props.sourceText ? mfm.parse(props.sourceText) : null) ??
+		(props.sourceNote?.text ? mfm.parse(props.sourceNote.text) : null);
+
+	if (source) {
+		if (props.sourceNote) {
+			return extractPreviewUrls(props.sourceNote, source);
+		} else {
+			return extractUrlFromMfm(source);
+		}
+	}
+
+	return [];
+});
+
+const isRefreshing = ref<Promise<void> | false>(false);
+const cachedNotes = ref(new Map<string, Misskey.entities.Note | null>());
+const cachedPreviews = ref(new Map<string, Summary | null>());
+
+/**
+ * Refreshes the group.
+ * Calls are automatically de-duplicated.
+ */
+function refresh(): Promise<void> {
+	if (isRefreshing.value) return isRefreshing.value;
+
+	const promise = doRefresh();
+	promise.finally(() => isRefreshing.value = false);
+	isRefreshing.value = promise;
+	return promise;
+}
+
+/**
+ * Refreshes the group.
+ * Don't call this directly - use refresh() instead!
+ */
+async function doRefresh(): Promise<void> {
+	let previews = await fetchPreviews();
+
+	// Remove duplicates
+	previews = deduplicatePreviews(previews);
+
+	// Remove any with hidden notes
+	previews = previews.filter(preview => !preview.note || !props.skipNoteIds.includes(preview.note.id));
+
+	urlPreviews.value = previews;
+}
+
+async function fetchPreviews(): Promise<Summary[]> {
+	const noteLimiter = promiseLimit<Misskey.entities.Note | null>(2);
+	const summaryLimiter = promiseLimit<Summary | null>(5);
+
+	const summaries = await Promise.all(urls.value.map(url =>
+		summaryLimiter(async () => {
+			return await fetchPreview(url);
+		}).then(async (summary) => {
+			if (summary && props.showAsQuote && summary.activityPub && summary.haveNoteLocally) {
+				// Have to pull this out to make TS happy
+				const noteUri = summary.activityPub;
+
+				summary.note = await noteLimiter(async () => {
+					return await fetchNote(noteUri);
+				});
+			}
+
+			return summary;
+		})));
+
+	return summaries.filter((preview): preview is Summary => preview != null);
+}
+
+async function fetchPreview(url: string): Promise<Summary | null> {
+	const cached = cachedPreviews.value.get(url);
+	if (cached) {
+		return cached;
+	}
+
+	const headers = $i ? { Authorization: `Bearer ${$i.token}` } : undefined;
+	const params = new URLSearchParams({ url, lang: versatileLang });
+	const res = await window.fetch(`/url?${params.toString()}`, { headers }).catch(() => null);
+
+	if (res?.ok) {
+		// Success - got the summary
+		const summary: Summary = await res.json();
+		cachedPreviews.value.set(url, summary);
+		if (summary.url !== url) {
+			cachedPreviews.value.set(summary.url, summary);
+		}
+		return summary;
+	}
+
+	// Failed, blocked, or not found
+	cachedPreviews.value.set(url, null);
+	return null;
+}
+
+async function fetchNote(noteUri: string): Promise<Misskey.entities.Note | null> {
+	const cached = cachedNotes.value.get(noteUri);
+	if (cached) {
+		return cached;
+	}
+
+	const response = await misskeyApi('ap/show', { uri: noteUri }).catch(() => null);
+	if (response && response.type === 'Note') {
+		const note = response['object'];
+
+		// Success - got the note
+		cachedNotes.value.set(noteUri, note);
+		if (note.uri && note.uri !== noteUri) {
+			cachedNotes.value.set(note.uri, note);
+		}
+		return note;
+	}
+
+	// Failed, blocked, or not found
+	cachedNotes.value.set(noteUri, null);
+	return null;
+}
+
+function deduplicatePreviews(previews: Summary[]): Summary[] {
+	// eslint-disable-next-line no-param-reassign
+	previews = previews
+		// Remove any previews with duplicate URL
+		.filter((preview, index) => !previews.some((p, i) => {
+			// Skip the current preview (don't count self as duplicate).
+			if (p === preview) return false;
+
+			// Skip differing URLs (not duplicate).
+			if (p.url !== preview.url) return false;
+
+			// Skip if we have AP and the other doesn't
+			if (preview.activityPub && !p.activityPub) return false;
+
+			// Skip later previews (keep the earliest instance)...
+			// ...but only if we have AP or the later one doesn't.
+			if (i > index && (preview.activityPub || !p.activityPub)) return false;
+
+			// If we get here, then "preview" is a duplicate of "p" and should be skipped.
+			return true;
+		}));
+
+	// eslint-disable-next-line no-param-reassign
+	previews = previews
+		// Remove any previews with duplicate AP
+		.filter((preview, index) => !previews.some((p, i) => {
+			// Skip the current preview (don't count self as duplicate).
+			if (p === preview) return false;
+
+			// Skip if we don't have AP
+			if (!preview.activityPub) return false;
+
+			// Skip if other does not have AP
+			if (!p.activityPub) return false;
+
+			// Skip differing URLs (not duplicate).
+			if (p.activityPub !== preview.activityPub) return false;
+
+			// Skip later previews (keep the earliest instance)
+			if (i > index) return false;
+
+			// If we get here, then "preview" is a duplicate of "p" and should be skipped.
+			return true;
+		}));
+
+	// eslint-disable-next-line no-param-reassign
+	previews = previews
+		// Remove any previews with duplicate note
+		.filter((preview, index) => !previews.some((p, i) => {
+			// Skip the current preview (don't count self as duplicate).
+			if (p === preview) return false;
+
+			// Skip if we don't have a note
+			if (!preview.note) return false;
+
+			// Skip if other does not have a note
+			if (!p.note) return false;
+
+			// Skip differing notes (not duplicate).
+			if (p.note.id !== preview.note.id) return false;
+
+			// Skip later previews (keep the earliest instance)
+			if (i > index) return false;
+
+			// If we get here, then "preview" is a duplicate of "p" and should be skipped.
+			return true;
+		}));
+
+	return previews;
+}
+
+// Kick everything off, and watch for changes.
+watch(
+	[urls, () => props.showAsQuote, () => props.skipNoteIds],
+	() => refresh(),
+	{ immediate: true },
+);
+</script>
+
+<style module lang="scss">
+.loading {
+	box-shadow: 0 0 0 1px var(--MI_THEME-divider);
+	border-radius: var(--MI-radius-sm);
+}
+</style>
-- 
cgit v1.2.3-freya


From 2536768133a716c0a5f45ee7ef7e0694b907a205 Mon Sep 17 00:00:00 2001
From: Hazelnoot <acomputerdog@gmail.com>
Date: Fri, 30 May 2025 12:57:52 -0400
Subject: check for note in initial de-duplication pass

---
 packages/frontend/src/components/SkUrlPreviewGroup.vue | 8 ++++++--
 1 file changed, 6 insertions(+), 2 deletions(-)

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

diff --git a/packages/frontend/src/components/SkUrlPreviewGroup.vue b/packages/frontend/src/components/SkUrlPreviewGroup.vue
index 4c43c989bd..beba20f783 100644
--- a/packages/frontend/src/components/SkUrlPreviewGroup.vue
+++ b/packages/frontend/src/components/SkUrlPreviewGroup.vue
@@ -207,9 +207,13 @@ function deduplicatePreviews(previews: Summary[]): Summary[] {
 			// Skip if we have AP and the other doesn't
 			if (preview.activityPub && !p.activityPub) return false;
 
+			// Skip if we have a note and the other doesn't
+			if (preview.note && !p.note) return false;
+
 			// Skip later previews (keep the earliest instance)...
-			// ...but only if we have AP or the later one doesn't.
-			if (i > index && (preview.activityPub || !p.activityPub)) return false;
+			// ...but only if we have AP or the later one doesn't...
+			// ...and only if we have note or the later one doesn't.
+			if (i > index && (preview.activityPub || !p.activityPub) && (preview.note || !p.note)) return false;
 
 			// If we get here, then "preview" is a duplicate of "p" and should be skipped.
 			return true;
-- 
cgit v1.2.3-freya


From 876ec968bd6883dccec2a0a3fb839fd06cdd4063 Mon Sep 17 00:00:00 2001
From: Hazelnoot <acomputerdog@gmail.com>
Date: Fri, 30 May 2025 12:58:35 -0400
Subject: consider duplicate previews where one URL can't be resolved, but
 another URL resolves to a local copy of the same note

---
 .../frontend/src/components/SkUrlPreviewGroup.vue  | 26 ++++++++++++++++++++++
 1 file changed, 26 insertions(+)

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

diff --git a/packages/frontend/src/components/SkUrlPreviewGroup.vue b/packages/frontend/src/components/SkUrlPreviewGroup.vue
index beba20f783..5175e16cfc 100644
--- a/packages/frontend/src/components/SkUrlPreviewGroup.vue
+++ b/packages/frontend/src/components/SkUrlPreviewGroup.vue
@@ -35,6 +35,7 @@ import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm';
 import { $i } from '@/i';
 import { misskeyApi } from '@/utility/misskey-api';
 import MkUrlPreview from '@/components/MkUrlPreview.vue';
+import { getNoteUrls } from '@/utility/getNoteUrls';
 
 type Summary = Awaited<ReturnType<typeof summaly>> & {
 	haveNoteLocally?: boolean;
@@ -265,6 +266,31 @@ function deduplicatePreviews(previews: Summary[]): Summary[] {
 			return true;
 		}));
 
+	// eslint-disable-next-line no-param-reassign
+	previews = previews
+		// Remove any previews where the note duplicates url
+		.filter((preview, index) => !previews.some((p, i) => {
+			// Skip the current preview (don't count self as duplicate).
+			if (p === preview) return false;
+
+			// Skip if we have a note
+			if (preview.note) return false;
+
+			// Skip if other does not have a note
+			if (!p.note) return false;
+
+			// Skip later previews (keep the earliest instance)
+			if (i > index) return false;
+
+			const noteUrls = getNoteUrls(p.note);
+
+			// Remove if other duplicates our AP URL
+			if (preview.activityPub && noteUrls.includes(preview.activityPub)) return true;
+
+			// Remove if other duplicates our main URL
+			return noteUrls.includes(preview.url);
+		}));
+
 	return previews;
 }
 
-- 
cgit v1.2.3-freya


From bae4c07bb3ff2714286719ea47d4fb3601411b74 Mon Sep 17 00:00:00 2001
From: Hazelnoot <acomputerdog@gmail.com>
Date: Wed, 4 Jun 2025 11:15:42 -0400
Subject: support link attributions in SkUrlPreviewGroup

---
 packages/frontend/src/components/MkUrlPreview.vue  | 64 +++++++++++++++-------
 .../frontend/src/components/SkUrlPreviewGroup.vue  | 58 ++++++++++++++++----
 2 files changed, 92 insertions(+), 30 deletions(-)

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

diff --git a/packages/frontend/src/components/MkUrlPreview.vue b/packages/frontend/src/components/MkUrlPreview.vue
index 680a9a7141..5d0e6e3df7 100644
--- a/packages/frontend/src/components/MkUrlPreview.vue
+++ b/packages/frontend/src/components/MkUrlPreview.vue
@@ -99,13 +99,24 @@ SPDX-License-Identifier: AGPL-3.0-only
 </div>
 </template>
 
+<script lang="ts">
+// eslint-disable-next-line import/order
+import type { summaly } from '@misskey-dev/summaly';
+
+export type SummalyResult = Awaited<ReturnType<typeof summaly>> & {
+	haveNoteLocally?: boolean,
+	linkAttribution?: {
+		userId: string,
+	}
+};
+</script>
+
 <script lang="ts" setup>
 import { defineAsyncComponent, onDeactivated, onUnmounted, ref } from 'vue';
 import { url as local } from '@@/js/config.js';
 import { versatileLang } from '@@/js/intl-const.js';
 import * as Misskey from 'misskey-js';
 import { maybeMakeRelative } from '@@/js/url.js';
-import type { summaly } from '@misskey-dev/summaly';
 import { i18n } from '@/i18n.js';
 import * as os from '@/os.js';
 import { deviceKind } from '@/utility/device-kind.js';
@@ -119,13 +130,6 @@ import DynamicNoteSimple from '@/components/DynamicNoteSimple.vue';
 import { $i } from '@/i';
 import { userPage } from '@/filters/user.js';
 
-type SummalyResult = Awaited<ReturnType<typeof summaly>> & {
-	haveNoteLocally?: boolean,
-	linkAttribution?: {
-		userId: string,
-	}
-};
-
 const props = withDefaults(defineProps<{
 	url: string;
 	detail?: boolean;
@@ -135,6 +139,7 @@ const props = withDefaults(defineProps<{
 	skipNoteIds?: (string | undefined)[];
 	previewHint?: SummalyResult;
 	noteHint?: Misskey.entities.Note | null;
+	attributionHint?: Misskey.entities.User | null;
 }>(), {
 	detail: false,
 	compact: false,
@@ -143,6 +148,7 @@ const props = withDefaults(defineProps<{
 	skipNoteIds: undefined,
 	previewHint: undefined,
 	noteHint: undefined,
+	attributionHint: undefined,
 });
 
 const MOBILE_THRESHOLD = 500;
@@ -179,11 +185,34 @@ const tweetHeight = ref(150);
 const unknownUrl = ref(false);
 const theNote = ref<Misskey.entities.Note | null>(null);
 const fetchingTheNote = ref(false);
+const fetchingAttribution = ref<Promise<void> | null>(null);
 
 onDeactivated(() => {
 	playerEnabled.value = false;
 });
 
+async function fetchAttribution(initial: boolean): Promise<void> {
+	if (!linkAttribution.value) return;
+	if (attributionUser.value) return;
+	if (fetchingAttribution.value) return fetchingAttribution.value;
+
+	return fetchingAttribution.value ??= (async (userId: string): Promise<void> => {
+		try {
+			if (initial && props.attributionHint !== undefined) {
+				attributionUser.value = props.attributionHint;
+			} else {
+				attributionUser.value = await misskeyApi('users/show', { userId });
+			}
+		} catch {
+			// makes the loading ellipsis vanish.
+			linkAttribution.value = null;
+		} finally {
+			// Reset promise to mark as done
+			fetchingAttribution.value = null;
+		}
+	})(linkAttribution.value.userId);
+}
+
 async function fetchNote(initial: boolean) {
 	if (!props.showAsQuote) return;
 	if (!activityPub.value) return;
@@ -275,20 +304,15 @@ function refresh(withFetch = false, initial = false) {
 			sensitive.value = info?.sensitive ?? false;
 			activityPub.value = info?.activityPub ?? null;
 			linkAttribution.value = info?.linkAttribution ?? null;
-			if (linkAttribution.value) {
-				try {
-					const response = await misskeyApi('users/show', { userId: linkAttribution.value.userId });
-					attributionUser.value = response;
-				} catch {
-					// makes the loading ellipsis vanish.
-					linkAttribution.value = null;
-				}
-			}
 
+			// These will be populated by the fetch* functions
+			attributionUser.value = null;
 			theNote.value = null;
-			if (info?.haveNoteLocally) {
-				await fetchNote(initial);
-			}
+
+			await Promise.all([
+				fetchAttribution(initial),
+				fetchNote(initial),
+			]);
 		})
 		.finally(() => {
 			fetching.value = null;
diff --git a/packages/frontend/src/components/SkUrlPreviewGroup.vue b/packages/frontend/src/components/SkUrlPreviewGroup.vue
index 5175e16cfc..32b11d9db4 100644
--- a/packages/frontend/src/components/SkUrlPreviewGroup.vue
+++ b/packages/frontend/src/components/SkUrlPreviewGroup.vue
@@ -14,6 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 		:url="preview.url"
 		:previewHint="preview"
 		:noteHint="preview.note"
+		:attributionHint="preview.attributionUser"
 		:detail="detail"
 		:compact="compact"
 		:showAsQuote="showAsQuote"
@@ -29,7 +30,7 @@ import * as mfm from '@transfem-org/sfm-js';
 import { computed, ref, watch } from 'vue';
 import { versatileLang } from '@@/js/intl-const';
 import promiseLimit from 'promise-limit';
-import type { summaly } from '@misskey-dev/summaly';
+import type { SummalyResult } from '@/components/MkUrlPreview.vue';
 import { extractPreviewUrls } from '@/utility/extract-preview-urls';
 import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm';
 import { $i } from '@/i';
@@ -37,11 +38,13 @@ import { misskeyApi } from '@/utility/misskey-api';
 import MkUrlPreview from '@/components/MkUrlPreview.vue';
 import { getNoteUrls } from '@/utility/getNoteUrls';
 
-type Summary = Awaited<ReturnType<typeof summaly>> & {
-	haveNoteLocally?: boolean;
+type Summary = SummalyResult & {
 	note?: Misskey.entities.Note | null;
+	attributionUser?: Misskey.entities.User | null;
 };
 
+type Limiter<T> = ReturnType<typeof promiseLimit<T>>;
+
 const props = withDefaults(defineProps<{
 	sourceUrls?: string[];
 	sourceNodes?: mfm.MfmNode[];
@@ -90,9 +93,11 @@ const urls = computed<string[]>(() => {
 	return [];
 });
 
+// todo un-ref these
 const isRefreshing = ref<Promise<void> | false>(false);
 const cachedNotes = ref(new Map<string, Misskey.entities.Note | null>());
 const cachedPreviews = ref(new Map<string, Summary | null>());
+const cachedUsers = new Map<string, Misskey.entities.User | null>();
 
 /**
  * Refreshes the group.
@@ -124,6 +129,7 @@ async function doRefresh(): Promise<void> {
 }
 
 async function fetchPreviews(): Promise<Summary[]> {
+	const userLimiter = promiseLimit<Misskey.entities.User | null>(4);
 	const noteLimiter = promiseLimit<Misskey.entities.Note | null>(2);
 	const summaryLimiter = promiseLimit<Summary | null>(5);
 
@@ -131,13 +137,11 @@ async function fetchPreviews(): Promise<Summary[]> {
 		summaryLimiter(async () => {
 			return await fetchPreview(url);
 		}).then(async (summary) => {
-			if (summary && props.showAsQuote && summary.activityPub && summary.haveNoteLocally) {
-				// Have to pull this out to make TS happy
-				const noteUri = summary.activityPub;
-
-				summary.note = await noteLimiter(async () => {
-					return await fetchNote(noteUri);
-				});
+			if (summary) {
+				await Promise.all([
+					attachNote(summary, noteLimiter),
+					attachAttribution(summary, userLimiter),
+				]);
 			}
 
 			return summary;
@@ -171,6 +175,17 @@ async function fetchPreview(url: string): Promise<Summary | null> {
 	return null;
 }
 
+async function attachNote(summary: Summary, noteLimiter: Limiter<Misskey.entities.Note | null>): Promise<void> {
+	if (props.showAsQuote && summary.activityPub && summary.haveNoteLocally) {
+		// Have to pull this out to make TS happy
+		const noteUri = summary.activityPub;
+
+		summary.note = await noteLimiter(async () => {
+			return await fetchNote(noteUri);
+		});
+	}
+}
+
 async function fetchNote(noteUri: string): Promise<Misskey.entities.Note | null> {
 	const cached = cachedNotes.value.get(noteUri);
 	if (cached) {
@@ -194,6 +209,29 @@ async function fetchNote(noteUri: string): Promise<Misskey.entities.Note | null>
 	return null;
 }
 
+async function attachAttribution(summary: Summary, userLimiter: Limiter<Misskey.entities.User | null>): Promise<void> {
+	if (summary.linkAttribution) {
+		// Have to pull this out to make TS happy
+		const userId = summary.linkAttribution.userId;
+
+		summary.attributionUser = await userLimiter(async () => {
+			return await fetchUser(userId);
+		});
+	}
+}
+
+async function fetchUser(userId: string): Promise<Misskey.entities.User | null> {
+	const cached = cachedUsers.get(userId);
+	if (cached) {
+		return cached;
+	}
+
+	const user = await misskeyApi('users/show', { userId }).catch(() => null);
+
+	cachedUsers.set(userId, user);
+	return user;
+}
+
 function deduplicatePreviews(previews: Summary[]): Summary[] {
 	// eslint-disable-next-line no-param-reassign
 	previews = previews
-- 
cgit v1.2.3-freya


From d59ebe99925ee89688d371aca26b54dda720072d Mon Sep 17 00:00:00 2001
From: Hazelnoot <acomputerdog@gmail.com>
Date: Fri, 6 Jun 2025 03:21:54 -0400
Subject: correct MFM import in new/modified components

---
 packages/frontend/src/components/MkAbuseReport.vue     | 2 +-
 packages/frontend/src/components/SkUrlPreviewGroup.vue | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

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

diff --git a/packages/frontend/src/components/MkAbuseReport.vue b/packages/frontend/src/components/MkAbuseReport.vue
index 7ad0ad647c..6025bc44f0 100644
--- a/packages/frontend/src/components/MkAbuseReport.vue
+++ b/packages/frontend/src/components/MkAbuseReport.vue
@@ -99,7 +99,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 <script lang="ts" setup>
 import { computed, provide, ref, watch } from 'vue';
 import * as Misskey from 'misskey-js';
-import * as mfm from '@transfem-org/sfm-js';
+import * as mfm from 'mfm-js';
 import MkButton from '@/components/MkButton.vue';
 import MkSwitch from '@/components/MkSwitch.vue';
 import MkKeyValue from '@/components/MkKeyValue.vue';
diff --git a/packages/frontend/src/components/SkUrlPreviewGroup.vue b/packages/frontend/src/components/SkUrlPreviewGroup.vue
index 32b11d9db4..dbd930248a 100644
--- a/packages/frontend/src/components/SkUrlPreviewGroup.vue
+++ b/packages/frontend/src/components/SkUrlPreviewGroup.vue
@@ -26,7 +26,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <script setup lang="ts">
 import * as Misskey from 'misskey-js';
-import * as mfm from '@transfem-org/sfm-js';
+import * as mfm from 'mfm-js';
 import { computed, ref, watch } from 'vue';
 import { versatileLang } from '@@/js/intl-const';
 import promiseLimit from 'promise-limit';
-- 
cgit v1.2.3-freya

