summaryrefslogtreecommitdiff
path: root/packages/frontend/src/components
diff options
context:
space:
mode:
authorHazelnoot <acomputerdog@gmail.com>2025-05-28 13:32:51 -0400
committerHazelnoot <acomputerdog@gmail.com>2025-06-04 10:48:21 -0400
commit69ed5611cfb80cc1b1c84717dfc660a73050941e (patch)
treeddee718e19d1561f7e8e21dc214f4463394272fc /packages/frontend/src/components
parentcache alternate URLs in UrlPreviewService (diff)
downloadsharkey-69ed5611cfb80cc1b1c84717dfc660a73050941e.tar.gz
sharkey-69ed5611cfb80cc1b1c84717dfc660a73050941e.tar.bz2
sharkey-69ed5611cfb80cc1b1c84717dfc660a73050941e.zip
re-implement preview groups as SkUrlPreviewGroup
Diffstat (limited to 'packages/frontend/src/components')
-rw-r--r--packages/frontend/src/components/MkAbuseReport.vue11
-rw-r--r--packages/frontend/src/components/MkUrlPreview.vue148
-rw-r--r--packages/frontend/src/components/SkUrlPreviewGroup.vue280
3 files changed, 326 insertions, 113 deletions
diff --git a/packages/frontend/src/components/MkAbuseReport.vue b/packages/frontend/src/components/MkAbuseReport.vue
index b2999d3899..ba95034d01 100644
--- a/packages/frontend/src/components/MkAbuseReport.vue
+++ b/packages/frontend/src/components/MkAbuseReport.vue
@@ -62,8 +62,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #icon><i class="ti ti-message-2"></i></template>
<template #label>{{ i18n.ts.details }}</template>
<div class="_gaps_s">
- <Mfm :text="report.comment" :isBlock="true" :linkNavigationBehavior="'window'"/>
- <MkUrlPreview v-for="url in urls" :key="url" :group="previewGroup" :url="url" :compact="false" :detail="false" :showAsQuote="true"/>
+ <Mfm :text="report.comment" :parsedNodes="parsedComment" :isBlock="true" :linkNavigationBehavior="'window'" :author="report.reporter" :nyaize="false" :isAnim="false"/>
+ <SkUrlPreviewGroup :sourceNodes="parsedComment" :compact="false" :detail="false" :showAsQuote="true"/>
</div>
</MkFolder>
@@ -111,13 +111,12 @@ import RouterView from '@/components/global/RouterView.vue';
import MkTextarea from '@/components/MkTextarea.vue';
import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
import { createRouter } from '@/router.js';
-import MkUrlPreview, { PreviewGroup } from '@/components/MkUrlPreview.vue';
-import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm';
import { getProxiedImageUrlNullable } from '@/utility/media-proxy';
import InstanceInfo from '@/pages/instance-info.vue';
import { iAmAdmin } from '@/i';
import { misskeyApi } from '@/utility/misskey-api';
import AdminUser from '@/pages/admin-user.vue';
+import SkUrlPreviewGroup from '@/components/SkUrlPreviewGroup.vue';
const props = defineProps<{
report: Misskey.entities.AdminAbuseUserReportsResponse[number];
@@ -134,9 +133,7 @@ const reporterRouter = createRouter(`/admin/user/${props.report.reporterId}`);
reporterRouter.init();
*/
-const parsed = computed(() => props.report.comment ? mfm.parse(props.report.comment) : null);
-const urls = computed(() => parsed.value ? extractUrlFromMfm(parsed.value) : null);
-const previewGroup = computed(() => new PreviewGroup()); // Lazy-load
+const parsedComment = computed(() => mfm.parse(props.report.comment));
const metaHint = ref<Misskey.entities.AdminMetaResponse | undefined>(undefined);
const targetInstanceIcon = computed(() => props.report.targetInstance?.faviconUrl
diff --git a/packages/frontend/src/components/MkUrlPreview.vue b/packages/frontend/src/components/MkUrlPreview.vue
index eb4a4efa33..680a9a7141 100644
--- a/packages/frontend/src/components/MkUrlPreview.vue
+++ b/packages/frontend/src/components/MkUrlPreview.vue
@@ -4,8 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
-<div v-if="groupLockout" style="display: none"></div>
-<template v-else-if="player.url && playerEnabled">
+<template v-if="player.url && playerEnabled">
<div
:class="$style.player"
:style="player.width ? `padding: ${(player.height || 0) / player.width * 100}% 0 0` : `padding: ${(player.height || 0)}px 0 0`"
@@ -77,7 +76,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</I18n>
<p v-else-if="linkAttribution" :class="$style.linkAttribution"><MkEllipsis/></p>
- <template v-if="showActions && !groupLockout">
+ <template v-if="showActions">
<div v-if="tweetId" :class="$style.action">
<MkButton :small="true" inline @click="tweetExpanded = true">
<i class="ti ti-brand-x"></i> {{ i18n.ts.expandTweet }}
@@ -100,53 +99,8 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</template>
-<script lang="ts">
-/**
- * Links a group of previews to de-duplicate the results.
- * Between all MkUrlPreview instances that share a group, each URL and Note are guaranteed to appear only once.
- */
-export class PreviewGroup {
- private readonly urls = new Map<string, number>();
- private readonly noteIds = new Map<string, number>();
-
- public claimUrl(url: string, componentUid: number): boolean {
- return this.claim(this.urls, url, componentUid);
- }
-
- public claimNoteId(noteId: string, componentUid: number): boolean {
- return this.claim(this.noteIds, noteId, componentUid);
- }
-
- private claim(group: Map<string, number>, key: string, uid: number): boolean {
- const claim = group.get(key);
-
- // Already claimed
- if (claim != null && claim !== uid) {
- return false;
- }
-
- group.set(key, uid);
- return true;
- }
-
- public releaseUrl(url: string, componentUid: number): void {
- this.release(this.urls, url, componentUid);
- }
-
- public releaseNoteId(noteId: string, componentUid: number): void {
- this.release(this.noteIds, noteId, componentUid);
- }
-
- private release(group: Map<string, number>, key: string, uid: number): void {
- if (group.get(key) === uid) {
- group.delete(key);
- }
- }
-}
-</script>
-
<script lang="ts" setup>
-import { defineAsyncComponent, onDeactivated, onUnmounted, ref, getCurrentInstance } from 'vue';
+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';
@@ -165,12 +119,12 @@ import DynamicNoteSimple from '@/components/DynamicNoteSimple.vue';
import { $i } from '@/i';
import { userPage } from '@/filters/user.js';
-const uid = getCurrentInstance()?.uid ?? -1;
-if (uid === -1) {
- console.warn('[MkUrlPreview] Component has null instance??');
-}
-
-type SummalyResult = Awaited<ReturnType<typeof summaly>>;
+type SummalyResult = Awaited<ReturnType<typeof summaly>> & {
+ haveNoteLocally?: boolean,
+ linkAttribution?: {
+ userId: string,
+ }
+};
const props = withDefaults(defineProps<{
url: string;
@@ -179,24 +133,21 @@ const props = withDefaults(defineProps<{
showAsQuote?: boolean;
showActions?: boolean;
skipNoteIds?: (string | undefined)[];
- group?: PreviewGroup;
+ previewHint?: SummalyResult;
+ noteHint?: Misskey.entities.Note | null;
}>(), {
detail: false,
compact: false,
showAsQuote: false,
showActions: true,
skipNoteIds: undefined,
- group: undefined,
+ previewHint: undefined,
+ noteHint: undefined,
});
-const emit = defineEmits<{
- (event: 'loaded', preview: SummalyResult & { haveNoteLocally?: boolean } | null, note: Misskey.entities.Note | null): void;
-}>();
-
const MOBILE_THRESHOLD = 500;
const isMobile = ref(deviceKind === 'smartphone' || window.innerWidth <= MOBILE_THRESHOLD);
-const groupLockout = ref<boolean>(false);
const hidePreview = ref<boolean>(false);
const maybeRelativeUrl = maybeMakeRelative(props.url, local);
const self = maybeRelativeUrl !== props.url;
@@ -228,13 +179,12 @@ const tweetHeight = ref(150);
const unknownUrl = ref(false);
const theNote = ref<Misskey.entities.Note | null>(null);
const fetchingTheNote = ref(false);
-const preview = ref<SummalyResult & { haveNoteLocally?: boolean } | null>(null);
onDeactivated(() => {
playerEnabled.value = false;
});
-async function fetchNote() {
+async function fetchNote(initial: boolean) {
if (!props.showAsQuote) return;
if (!activityPub.value) return;
if (theNote.value) return;
@@ -242,16 +192,20 @@ async function fetchNote() {
fetchingTheNote.value = true;
try {
- const response = await misskeyApi('ap/show', { uri: activityPub.value });
+ const response = (initial && props.noteHint !== undefined)
+ ? { type: 'Note', object: props.noteHint }
+ : await misskeyApi('ap/show', { uri: activityPub.value });
if (response.type !== 'Note') return;
+ if (!response.object) {
+ activityPub.value = null;
+ theNote.value = null;
+ return;
+ }
const theNoteId = response['object'].id;
if (theNoteId && props.skipNoteIds && props.skipNoteIds.includes(theNoteId)) {
hidePreview.value = true;
return;
}
- if (props.group && !props.group.claimNoteId(response['object'].id, uid)) {
- groupLockout.value = true;
- }
theNote.value = response['object'];
} catch (err) {
if (_DEV_) {
@@ -272,22 +226,16 @@ if (requestUrl.hostname === 'twitter.com' || requestUrl.hostname === 'mobile.twi
if (m) tweetId.value = m[1];
}
+// This is now handled on the backend
+/*
if (requestUrl.hostname === 'music.youtube.com' && requestUrl.pathname.match('^/(?:watch|channel)')) {
requestUrl.hostname = 'www.youtube.com';
}
requestUrl.hash = '';
+*/
-const refresh = (withFetch = false) => {
- // Release URL/noteID when refreshing, in case it changes.
- // (Could happen since redirects are allowed.)
- if (preview.value && props.group) {
- props.group.releaseUrl(preview.value.url, uid);
- }
- if (theNote.value && props.group) {
- props.group.releaseNoteId(theNote.value.id, uid);
- }
-
+function refresh(withFetch = false, initial = false) {
const params = new URLSearchParams({
url: requestUrl.href,
lang: versatileLang,
@@ -297,24 +245,21 @@ const refresh = (withFetch = false) => {
}
const headers = $i ? { Authorization: `Bearer ${$i.token}` } : undefined;
- return fetching.value ??= window.fetch(`/url?${params.toString()}`, { headers })
- .then(res => {
- if (!res.ok) {
- if (_DEV_) {
- console.warn(`[HTTP${res.status}] Failed to fetch url preview`);
+ const fetchPromise: Promise<SummalyResult | null> = (initial && props.previewHint)
+ ? Promise.resolve(props.previewHint)
+ : window.fetch(`/url?${params.toString()}`, { headers })
+ .then(res => {
+ if (!res.ok) {
+ if (_DEV_) {
+ console.warn(`[HTTP${res.status}] Failed to fetch url preview`);
+ }
+ return null;
}
- return null;
- }
- return res.json();
- })
- .then(async (info: SummalyResult & {
- haveNoteLocally?: boolean,
- linkAttribution?: {
- userId: string,
- }
- } | null) => {
- preview.value = info;
+ return res.json();
+ });
+ return fetching.value ??= fetchPromise
+ .then(async (info: SummalyResult | null) => {
unknownUrl.value = info == null;
title.value = info?.title ?? null;
description.value = info?.description ?? null;
@@ -329,7 +274,6 @@ const refresh = (withFetch = false) => {
};
sensitive.value = info?.sensitive ?? false;
activityPub.value = info?.activityPub ?? null;
- groupLockout.value = info != null && props.group != null && !props.group.claimUrl(info.url, uid);
linkAttribution.value = info?.linkAttribution ?? null;
if (linkAttribution.value) {
try {
@@ -343,14 +287,13 @@ const refresh = (withFetch = false) => {
theNote.value = null;
if (info?.haveNoteLocally) {
- await fetchNote();
+ await fetchNote(initial);
}
})
.finally(() => {
fetching.value = null;
- emit('loaded', preview.value, theNote.value);
});
-};
+}
function adjustTweetHeight(message: MessageEvent) {
if (message.origin !== 'https://platform.twitter.com') return;
@@ -375,17 +318,10 @@ window.addEventListener('message', adjustTweetHeight);
onUnmounted(() => {
window.removeEventListener('message', adjustTweetHeight);
-
- if (preview.value && props.group) {
- props.group.releaseUrl(preview.value.url, uid);
- }
- if (theNote.value && props.group) {
- props.group.releaseNoteId(theNote.value.id, uid);
- }
});
// Load initial data
-refresh();
+refresh(false, true);
</script>
<style lang="scss" module>
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>