implement de-duplication for MkUrlPreview

This commit is contained in:
Hazelnoot 2025-05-28 02:04:08 -04:00
parent b05ccbc3ac
commit c18edd106b

View file

@ -4,7 +4,8 @@ SPDX-License-Identifier: AGPL-3.0-only
--> -->
<template> <template>
<template v-if="player.url && playerEnabled"> <div v-if="groupLockout" style="display: none"></div>
<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`"
@ -76,7 +77,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"> <template v-if="showActions && !groupLockout">
<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 }}
@ -99,8 +100,53 @@ 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 } from 'vue'; import { defineAsyncComponent, onDeactivated, onUnmounted, ref, getCurrentInstance } 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';
@ -119,6 +165,11 @@ 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;
if (uid === -1) {
console.warn('[MkUrlPreview] Component has null instance??');
}
type SummalyResult = Awaited<ReturnType<typeof summaly>>; type SummalyResult = Awaited<ReturnType<typeof summaly>>;
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
@ -128,17 +179,24 @@ const props = withDefaults(defineProps<{
showAsQuote?: boolean; showAsQuote?: boolean;
showActions?: boolean; showActions?: boolean;
skipNoteIds?: (string | undefined)[]; skipNoteIds?: (string | undefined)[];
group?: PreviewGroup;
}>(), { }>(), {
detail: false, detail: false,
compact: false, compact: false,
showAsQuote: false, showAsQuote: false,
showActions: true, showActions: true,
skipNoteIds: undefined, skipNoteIds: undefined,
group: 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;
@ -170,6 +228,7 @@ 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;
@ -190,6 +249,9 @@ async function fetchNote() {
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_) {
@ -216,7 +278,16 @@ if (requestUrl.hostname === 'music.youtube.com' && requestUrl.pathname.match('^/
requestUrl.hash = ''; requestUrl.hash = '';
function refresh(withFetch = false) { 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);
}
const params = new URLSearchParams({ const params = new URLSearchParams({
url: requestUrl.href, url: requestUrl.href,
lang: versatileLang, lang: versatileLang,
@ -243,6 +314,7 @@ function refresh(withFetch = false) {
userId: string, userId: string,
} }
} | null) => { } | 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;
@ -257,6 +329,7 @@ function 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 {
@ -275,8 +348,9 @@ function refresh(withFetch = false) {
}) })
.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;
@ -301,6 +375,13 @@ 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
@ -388,7 +469,7 @@ refresh();
.body { .body {
position: relative; position: relative;
box-sizing: border-box; box-sizing: border-box;
padding: 16px; padding: 16px !important; // Unfortunately needed to win a specificity race with MkNoteSimple / SkNoteSimple
} }
.header { .header {