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 v-if="player.url && playerEnabled">
<div v-if="groupLockout" style="display: none"></div>
<template v-else-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`"
@ -76,7 +77,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</I18n>
<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">
<MkButton :small="true" inline @click="tweetExpanded = true">
<i class="ti ti-brand-x"></i> {{ i18n.ts.expandTweet }}
@ -99,8 +100,53 @@ 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 } from 'vue';
import { defineAsyncComponent, onDeactivated, onUnmounted, ref, getCurrentInstance } from 'vue';
import { url as local } from '@@/js/config.js';
import { versatileLang } from '@@/js/intl-const.js';
import * as Misskey from 'misskey-js';
@ -119,6 +165,11 @@ 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>>;
const props = withDefaults(defineProps<{
@ -128,17 +179,24 @@ const props = withDefaults(defineProps<{
showAsQuote?: boolean;
showActions?: boolean;
skipNoteIds?: (string | undefined)[];
group?: PreviewGroup;
}>(), {
detail: false,
compact: false,
showAsQuote: false,
showActions: true,
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 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;
@ -170,6 +228,7 @@ 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;
@ -190,6 +249,9 @@ async function fetchNote() {
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_) {
@ -216,7 +278,16 @@ if (requestUrl.hostname === 'music.youtube.com' && requestUrl.pathname.match('^/
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({
url: requestUrl.href,
lang: versatileLang,
@ -243,6 +314,7 @@ function refresh(withFetch = false) {
userId: string,
}
} | null) => {
preview.value = info;
unknownUrl.value = info == null;
title.value = info?.title ?? null;
description.value = info?.description ?? null;
@ -257,6 +329,7 @@ function 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 {
@ -275,8 +348,9 @@ function refresh(withFetch = false) {
})
.finally(() => {
fetching.value = null;
emit('loaded', preview.value, theNote.value);
});
}
};
function adjustTweetHeight(message: MessageEvent) {
if (message.origin !== 'https://platform.twitter.com') return;
@ -301,6 +375,13 @@ 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
@ -388,7 +469,7 @@ refresh();
.body {
position: relative;
box-sizing: border-box;
padding: 16px;
padding: 16px !important; // Unfortunately needed to win a specificity race with MkNoteSimple / SkNoteSimple
}
.header {