mirror of
https://codeberg.org/yeentown/barkey.git
synced 2025-07-07 20:44:34 +00:00
re-implement preview groups as SkUrlPreviewGroup
This commit is contained in:
parent
a91c0de9b5
commit
69ed5611cf
5 changed files with 337 additions and 120 deletions
|
@ -60,6 +60,7 @@
|
||||||
"misskey-reversi": "workspace:*",
|
"misskey-reversi": "workspace:*",
|
||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
"photoswipe": "5.4.4",
|
"photoswipe": "5.4.4",
|
||||||
|
"promise-limit": "2.7.0",
|
||||||
"punycode.js": "2.3.1",
|
"punycode.js": "2.3.1",
|
||||||
"rollup": "4.40.0",
|
"rollup": "4.40.0",
|
||||||
"sanitize-html": "2.16.0",
|
"sanitize-html": "2.16.0",
|
||||||
|
|
|
@ -62,8 +62,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<template #icon><i class="ti ti-message-2"></i></template>
|
<template #icon><i class="ti ti-message-2"></i></template>
|
||||||
<template #label>{{ i18n.ts.details }}</template>
|
<template #label>{{ i18n.ts.details }}</template>
|
||||||
<div class="_gaps_s">
|
<div class="_gaps_s">
|
||||||
<Mfm :text="report.comment" :isBlock="true" :linkNavigationBehavior="'window'"/>
|
<Mfm :text="report.comment" :parsedNodes="parsedComment" :isBlock="true" :linkNavigationBehavior="'window'" :author="report.reporter" :nyaize="false" :isAnim="false"/>
|
||||||
<MkUrlPreview v-for="url in urls" :key="url" :group="previewGroup" :url="url" :compact="false" :detail="false" :showAsQuote="true"/>
|
<SkUrlPreviewGroup :sourceNodes="parsedComment" :compact="false" :detail="false" :showAsQuote="true"/>
|
||||||
</div>
|
</div>
|
||||||
</MkFolder>
|
</MkFolder>
|
||||||
|
|
||||||
|
@ -111,13 +111,12 @@ import RouterView from '@/components/global/RouterView.vue';
|
||||||
import MkTextarea from '@/components/MkTextarea.vue';
|
import MkTextarea from '@/components/MkTextarea.vue';
|
||||||
import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
|
import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
|
||||||
import { createRouter } from '@/router.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 { getProxiedImageUrlNullable } from '@/utility/media-proxy';
|
||||||
import InstanceInfo from '@/pages/instance-info.vue';
|
import InstanceInfo from '@/pages/instance-info.vue';
|
||||||
import { iAmAdmin } from '@/i';
|
import { iAmAdmin } from '@/i';
|
||||||
import { misskeyApi } from '@/utility/misskey-api';
|
import { misskeyApi } from '@/utility/misskey-api';
|
||||||
import AdminUser from '@/pages/admin-user.vue';
|
import AdminUser from '@/pages/admin-user.vue';
|
||||||
|
import SkUrlPreviewGroup from '@/components/SkUrlPreviewGroup.vue';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
report: Misskey.entities.AdminAbuseUserReportsResponse[number];
|
report: Misskey.entities.AdminAbuseUserReportsResponse[number];
|
||||||
|
@ -134,9 +133,7 @@ const reporterRouter = createRouter(`/admin/user/${props.report.reporterId}`);
|
||||||
reporterRouter.init();
|
reporterRouter.init();
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const parsed = computed(() => props.report.comment ? mfm.parse(props.report.comment) : null);
|
const parsedComment = computed(() => mfm.parse(props.report.comment));
|
||||||
const urls = computed(() => parsed.value ? extractUrlFromMfm(parsed.value) : null);
|
|
||||||
const previewGroup = computed(() => new PreviewGroup()); // Lazy-load
|
|
||||||
const metaHint = ref<Misskey.entities.AdminMetaResponse | undefined>(undefined);
|
const metaHint = ref<Misskey.entities.AdminMetaResponse | undefined>(undefined);
|
||||||
|
|
||||||
const targetInstanceIcon = computed(() => props.report.targetInstance?.faviconUrl
|
const targetInstanceIcon = computed(() => props.report.targetInstance?.faviconUrl
|
||||||
|
|
|
@ -4,8 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
-->
|
-->
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-if="groupLockout" style="display: none"></div>
|
<template v-if="player.url && playerEnabled">
|
||||||
<template v-else-if="player.url && playerEnabled">
|
|
||||||
<div
|
<div
|
||||||
:class="$style.player"
|
:class="$style.player"
|
||||||
:style="player.width ? `padding: ${(player.height || 0) / player.width * 100}% 0 0` : `padding: ${(player.height || 0)}px 0 0`"
|
: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>
|
</I18n>
|
||||||
<p v-else-if="linkAttribution" :class="$style.linkAttribution"><MkEllipsis/></p>
|
<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">
|
<div v-if="tweetId" :class="$style.action">
|
||||||
<MkButton :small="true" inline @click="tweetExpanded = true">
|
<MkButton :small="true" inline @click="tweetExpanded = true">
|
||||||
<i class="ti ti-brand-x"></i> {{ i18n.ts.expandTweet }}
|
<i class="ti ti-brand-x"></i> {{ i18n.ts.expandTweet }}
|
||||||
|
@ -100,53 +99,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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>
|
<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 { url as local } from '@@/js/config.js';
|
||||||
import { versatileLang } from '@@/js/intl-const.js';
|
import { versatileLang } from '@@/js/intl-const.js';
|
||||||
import * as Misskey from 'misskey-js';
|
import * as Misskey from 'misskey-js';
|
||||||
|
@ -165,12 +119,12 @@ import DynamicNoteSimple from '@/components/DynamicNoteSimple.vue';
|
||||||
import { $i } from '@/i';
|
import { $i } from '@/i';
|
||||||
import { userPage } from '@/filters/user.js';
|
import { userPage } from '@/filters/user.js';
|
||||||
|
|
||||||
const uid = getCurrentInstance()?.uid ?? -1;
|
type SummalyResult = Awaited<ReturnType<typeof summaly>> & {
|
||||||
if (uid === -1) {
|
haveNoteLocally?: boolean,
|
||||||
console.warn('[MkUrlPreview] Component has null instance??');
|
linkAttribution?: {
|
||||||
}
|
userId: string,
|
||||||
|
}
|
||||||
type SummalyResult = Awaited<ReturnType<typeof summaly>>;
|
};
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
url: string;
|
url: string;
|
||||||
|
@ -179,24 +133,21 @@ const props = withDefaults(defineProps<{
|
||||||
showAsQuote?: boolean;
|
showAsQuote?: boolean;
|
||||||
showActions?: boolean;
|
showActions?: boolean;
|
||||||
skipNoteIds?: (string | undefined)[];
|
skipNoteIds?: (string | undefined)[];
|
||||||
group?: PreviewGroup;
|
previewHint?: SummalyResult;
|
||||||
|
noteHint?: Misskey.entities.Note | null;
|
||||||
}>(), {
|
}>(), {
|
||||||
detail: false,
|
detail: false,
|
||||||
compact: false,
|
compact: false,
|
||||||
showAsQuote: false,
|
showAsQuote: false,
|
||||||
showActions: true,
|
showActions: true,
|
||||||
skipNoteIds: undefined,
|
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 MOBILE_THRESHOLD = 500;
|
||||||
const isMobile = ref(deviceKind === 'smartphone' || window.innerWidth <= MOBILE_THRESHOLD);
|
const isMobile = ref(deviceKind === 'smartphone' || window.innerWidth <= MOBILE_THRESHOLD);
|
||||||
|
|
||||||
const groupLockout = ref<boolean>(false);
|
|
||||||
const hidePreview = ref<boolean>(false);
|
const hidePreview = ref<boolean>(false);
|
||||||
const maybeRelativeUrl = maybeMakeRelative(props.url, local);
|
const maybeRelativeUrl = maybeMakeRelative(props.url, local);
|
||||||
const self = maybeRelativeUrl !== props.url;
|
const self = maybeRelativeUrl !== props.url;
|
||||||
|
@ -228,13 +179,12 @@ const tweetHeight = ref(150);
|
||||||
const unknownUrl = ref(false);
|
const unknownUrl = ref(false);
|
||||||
const theNote = ref<Misskey.entities.Note | null>(null);
|
const theNote = ref<Misskey.entities.Note | null>(null);
|
||||||
const fetchingTheNote = ref(false);
|
const fetchingTheNote = ref(false);
|
||||||
const preview = ref<SummalyResult & { haveNoteLocally?: boolean } | null>(null);
|
|
||||||
|
|
||||||
onDeactivated(() => {
|
onDeactivated(() => {
|
||||||
playerEnabled.value = false;
|
playerEnabled.value = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
async function fetchNote() {
|
async function fetchNote(initial: boolean) {
|
||||||
if (!props.showAsQuote) return;
|
if (!props.showAsQuote) return;
|
||||||
if (!activityPub.value) return;
|
if (!activityPub.value) return;
|
||||||
if (theNote.value) return;
|
if (theNote.value) return;
|
||||||
|
@ -242,16 +192,20 @@ async function fetchNote() {
|
||||||
|
|
||||||
fetchingTheNote.value = true;
|
fetchingTheNote.value = true;
|
||||||
try {
|
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.type !== 'Note') return;
|
||||||
|
if (!response.object) {
|
||||||
|
activityPub.value = null;
|
||||||
|
theNote.value = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
const theNoteId = response['object'].id;
|
const theNoteId = response['object'].id;
|
||||||
if (theNoteId && props.skipNoteIds && props.skipNoteIds.includes(theNoteId)) {
|
if (theNoteId && props.skipNoteIds && props.skipNoteIds.includes(theNoteId)) {
|
||||||
hidePreview.value = true;
|
hidePreview.value = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (props.group && !props.group.claimNoteId(response['object'].id, uid)) {
|
|
||||||
groupLockout.value = true;
|
|
||||||
}
|
|
||||||
theNote.value = response['object'];
|
theNote.value = response['object'];
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (_DEV_) {
|
if (_DEV_) {
|
||||||
|
@ -272,22 +226,16 @@ if (requestUrl.hostname === 'twitter.com' || requestUrl.hostname === 'mobile.twi
|
||||||
if (m) tweetId.value = m[1];
|
if (m) tweetId.value = m[1];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// This is now handled on the backend
|
||||||
|
/*
|
||||||
if (requestUrl.hostname === 'music.youtube.com' && requestUrl.pathname.match('^/(?:watch|channel)')) {
|
if (requestUrl.hostname === 'music.youtube.com' && requestUrl.pathname.match('^/(?:watch|channel)')) {
|
||||||
requestUrl.hostname = 'www.youtube.com';
|
requestUrl.hostname = 'www.youtube.com';
|
||||||
}
|
}
|
||||||
|
|
||||||
requestUrl.hash = '';
|
requestUrl.hash = '';
|
||||||
|
*/
|
||||||
|
|
||||||
const refresh = (withFetch = false) => {
|
function refresh(withFetch = false, initial = 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
url: requestUrl.href,
|
url: requestUrl.href,
|
||||||
lang: versatileLang,
|
lang: versatileLang,
|
||||||
|
@ -297,7 +245,9 @@ const refresh = (withFetch = false) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const headers = $i ? { Authorization: `Bearer ${$i.token}` } : undefined;
|
const headers = $i ? { Authorization: `Bearer ${$i.token}` } : undefined;
|
||||||
return fetching.value ??= window.fetch(`/url?${params.toString()}`, { headers })
|
const fetchPromise: Promise<SummalyResult | null> = (initial && props.previewHint)
|
||||||
|
? Promise.resolve(props.previewHint)
|
||||||
|
: window.fetch(`/url?${params.toString()}`, { headers })
|
||||||
.then(res => {
|
.then(res => {
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
if (_DEV_) {
|
if (_DEV_) {
|
||||||
|
@ -307,14 +257,9 @@ const refresh = (withFetch = false) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.json();
|
return res.json();
|
||||||
})
|
});
|
||||||
.then(async (info: SummalyResult & {
|
return fetching.value ??= fetchPromise
|
||||||
haveNoteLocally?: boolean,
|
.then(async (info: SummalyResult | null) => {
|
||||||
linkAttribution?: {
|
|
||||||
userId: string,
|
|
||||||
}
|
|
||||||
} | null) => {
|
|
||||||
preview.value = info;
|
|
||||||
unknownUrl.value = info == null;
|
unknownUrl.value = info == null;
|
||||||
title.value = info?.title ?? null;
|
title.value = info?.title ?? null;
|
||||||
description.value = info?.description ?? null;
|
description.value = info?.description ?? null;
|
||||||
|
@ -329,7 +274,6 @@ const refresh = (withFetch = false) => {
|
||||||
};
|
};
|
||||||
sensitive.value = info?.sensitive ?? false;
|
sensitive.value = info?.sensitive ?? false;
|
||||||
activityPub.value = info?.activityPub ?? null;
|
activityPub.value = info?.activityPub ?? null;
|
||||||
groupLockout.value = info != null && props.group != null && !props.group.claimUrl(info.url, uid);
|
|
||||||
linkAttribution.value = info?.linkAttribution ?? null;
|
linkAttribution.value = info?.linkAttribution ?? null;
|
||||||
if (linkAttribution.value) {
|
if (linkAttribution.value) {
|
||||||
try {
|
try {
|
||||||
|
@ -343,14 +287,13 @@ const refresh = (withFetch = false) => {
|
||||||
|
|
||||||
theNote.value = null;
|
theNote.value = null;
|
||||||
if (info?.haveNoteLocally) {
|
if (info?.haveNoteLocally) {
|
||||||
await fetchNote();
|
await fetchNote(initial);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
fetching.value = null;
|
fetching.value = null;
|
||||||
emit('loaded', preview.value, theNote.value);
|
|
||||||
});
|
});
|
||||||
};
|
}
|
||||||
|
|
||||||
function adjustTweetHeight(message: MessageEvent) {
|
function adjustTweetHeight(message: MessageEvent) {
|
||||||
if (message.origin !== 'https://platform.twitter.com') return;
|
if (message.origin !== 'https://platform.twitter.com') return;
|
||||||
|
@ -375,17 +318,10 @@ window.addEventListener('message', adjustTweetHeight);
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
window.removeEventListener('message', adjustTweetHeight);
|
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
|
// Load initial data
|
||||||
refresh();
|
refresh(false, true);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" module>
|
<style lang="scss" module>
|
||||||
|
|
280
packages/frontend/src/components/SkUrlPreviewGroup.vue
Normal file
280
packages/frontend/src/components/SkUrlPreviewGroup.vue
Normal file
|
@ -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>
|
17
pnpm-lock.yaml
generated
17
pnpm-lock.yaml
generated
|
@ -847,6 +847,9 @@ importers:
|
||||||
photoswipe:
|
photoswipe:
|
||||||
specifier: 5.4.4
|
specifier: 5.4.4
|
||||||
version: 5.4.4
|
version: 5.4.4
|
||||||
|
promise-limit:
|
||||||
|
specifier: 2.7.0
|
||||||
|
version: 2.7.0
|
||||||
punycode.js:
|
punycode.js:
|
||||||
specifier: 2.3.1
|
specifier: 2.3.1
|
||||||
version: 2.3.1
|
version: 2.3.1
|
||||||
|
@ -1013,7 +1016,7 @@ importers:
|
||||||
version: 8.31.0(eslint@9.25.1)(typescript@5.8.3)
|
version: 8.31.0(eslint@9.25.1)(typescript@5.8.3)
|
||||||
'@vitest/coverage-v8':
|
'@vitest/coverage-v8':
|
||||||
specifier: 3.1.2
|
specifier: 3.1.2
|
||||||
version: 3.1.2(vitest@3.1.2(@types/debug@4.1.12)(@types/node@22.15.2)(happy-dom@17.4.4)(jsdom@26.1.0)(msw@2.7.5(@types/node@22.15.2)(typescript@5.8.3))(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3))
|
version: 3.1.2(vitest@3.1.2(@types/debug@4.1.12)(@types/node@22.15.2)(happy-dom@17.4.4)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@6.0.5))(msw@2.7.5(@types/node@22.15.2)(typescript@5.8.3))(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3))
|
||||||
'@vue/compiler-core':
|
'@vue/compiler-core':
|
||||||
specifier: 3.5.14
|
specifier: 3.5.14
|
||||||
version: 3.5.14
|
version: 3.5.14
|
||||||
|
@ -1082,7 +1085,7 @@ importers:
|
||||||
version: 1.0.3
|
version: 1.0.3
|
||||||
vitest:
|
vitest:
|
||||||
specifier: 3.1.2
|
specifier: 3.1.2
|
||||||
version: 3.1.2(@types/debug@4.1.12)(@types/node@22.15.2)(happy-dom@17.4.4)(jsdom@26.1.0)(msw@2.7.5(@types/node@22.15.2)(typescript@5.8.3))(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3)
|
version: 3.1.2(@types/debug@4.1.12)(@types/node@22.15.2)(happy-dom@17.4.4)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@6.0.5))(msw@2.7.5(@types/node@22.15.2)(typescript@5.8.3))(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3)
|
||||||
vitest-fetch-mock:
|
vitest-fetch-mock:
|
||||||
specifier: 0.4.5
|
specifier: 0.4.5
|
||||||
version: 0.4.5(vitest@3.1.2(@types/debug@4.1.12)(@types/node@22.15.2)(happy-dom@17.4.4)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@6.0.5))(msw@2.7.5(@types/node@22.15.2)(typescript@5.8.3))(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3))
|
version: 0.4.5(vitest@3.1.2(@types/debug@4.1.12)(@types/node@22.15.2)(happy-dom@17.4.4)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@6.0.5))(msw@2.7.5(@types/node@22.15.2)(typescript@5.8.3))(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3))
|
||||||
|
@ -1209,7 +1212,7 @@ importers:
|
||||||
version: 8.31.0(eslint@9.25.1)(typescript@5.8.3)
|
version: 8.31.0(eslint@9.25.1)(typescript@5.8.3)
|
||||||
'@vitest/coverage-v8':
|
'@vitest/coverage-v8':
|
||||||
specifier: 3.1.2
|
specifier: 3.1.2
|
||||||
version: 3.1.2(vitest@3.1.2(@types/debug@4.1.12)(@types/node@22.15.2)(happy-dom@17.4.4)(jsdom@26.1.0)(msw@2.7.5(@types/node@22.15.2)(typescript@5.8.3))(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3))
|
version: 3.1.2(vitest@3.1.2(@types/debug@4.1.12)(@types/node@22.15.2)(happy-dom@17.4.4)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@6.0.5))(msw@2.7.5(@types/node@22.15.2)(typescript@5.8.3))(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3))
|
||||||
'@vue/runtime-core':
|
'@vue/runtime-core':
|
||||||
specifier: 3.5.14
|
specifier: 3.5.14
|
||||||
version: 3.5.14
|
version: 3.5.14
|
||||||
|
@ -14749,7 +14752,7 @@ snapshots:
|
||||||
vite: 6.3.3(@types/node@22.15.2)(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3)
|
vite: 6.3.3(@types/node@22.15.2)(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3)
|
||||||
vue: 3.5.14(typescript@5.8.3)
|
vue: 3.5.14(typescript@5.8.3)
|
||||||
|
|
||||||
'@vitest/coverage-v8@3.1.2(vitest@3.1.2(@types/debug@4.1.12)(@types/node@22.15.2)(happy-dom@17.4.4)(jsdom@26.1.0)(msw@2.7.5(@types/node@22.15.2)(typescript@5.8.3))(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3))':
|
'@vitest/coverage-v8@3.1.2(vitest@3.1.2(@types/debug@4.1.12)(@types/node@22.15.2)(happy-dom@17.4.4)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@6.0.5))(msw@2.7.5(@types/node@22.15.2)(typescript@5.8.3))(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@ampproject/remapping': 2.3.0
|
'@ampproject/remapping': 2.3.0
|
||||||
'@bcoe/v8-coverage': 1.0.2
|
'@bcoe/v8-coverage': 1.0.2
|
||||||
|
@ -14763,7 +14766,7 @@ snapshots:
|
||||||
std-env: 3.9.0
|
std-env: 3.9.0
|
||||||
test-exclude: 7.0.1
|
test-exclude: 7.0.1
|
||||||
tinyrainbow: 2.0.0
|
tinyrainbow: 2.0.0
|
||||||
vitest: 3.1.2(@types/debug@4.1.12)(@types/node@22.15.2)(happy-dom@17.4.4)(jsdom@26.1.0)(msw@2.7.5(@types/node@22.15.2)(typescript@5.8.3))(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3)
|
vitest: 3.1.2(@types/debug@4.1.12)(@types/node@22.15.2)(happy-dom@17.4.4)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@6.0.5))(msw@2.7.5(@types/node@22.15.2)(typescript@5.8.3))(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
@ -21931,9 +21934,9 @@ snapshots:
|
||||||
|
|
||||||
vitest-fetch-mock@0.4.5(vitest@3.1.2(@types/debug@4.1.12)(@types/node@22.15.2)(happy-dom@17.4.4)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@6.0.5))(msw@2.7.5(@types/node@22.15.2)(typescript@5.8.3))(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3)):
|
vitest-fetch-mock@0.4.5(vitest@3.1.2(@types/debug@4.1.12)(@types/node@22.15.2)(happy-dom@17.4.4)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@6.0.5))(msw@2.7.5(@types/node@22.15.2)(typescript@5.8.3))(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3)):
|
||||||
dependencies:
|
dependencies:
|
||||||
vitest: 3.1.2(@types/debug@4.1.12)(@types/node@22.15.2)(happy-dom@17.4.4)(jsdom@26.1.0)(msw@2.7.5(@types/node@22.15.2)(typescript@5.8.3))(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3)
|
vitest: 3.1.2(@types/debug@4.1.12)(@types/node@22.15.2)(happy-dom@17.4.4)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@6.0.5))(msw@2.7.5(@types/node@22.15.2)(typescript@5.8.3))(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3)
|
||||||
|
|
||||||
vitest@3.1.2(@types/debug@4.1.12)(@types/node@22.15.2)(happy-dom@17.4.4)(jsdom@26.1.0)(msw@2.7.5(@types/node@22.15.2)(typescript@5.8.3))(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3):
|
vitest@3.1.2(@types/debug@4.1.12)(@types/node@22.15.2)(happy-dom@17.4.4)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@6.0.5))(msw@2.7.5(@types/node@22.15.2)(typescript@5.8.3))(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@vitest/expect': 3.1.2
|
'@vitest/expect': 3.1.2
|
||||||
'@vitest/mocker': 3.1.2(msw@2.7.5(@types/node@22.15.2)(typescript@5.8.3))(vite@6.3.3(@types/node@22.15.2)(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3))
|
'@vitest/mocker': 3.1.2(msw@2.7.5(@types/node@22.15.2)(typescript@5.8.3))(vite@6.3.3(@types/node@22.15.2)(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3))
|
||||||
|
|
Loading…
Add table
Reference in a new issue