summaryrefslogtreecommitdiff
path: root/packages/frontend/src
diff options
context:
space:
mode:
authorHazelnoot <acomputerdog@gmail.com>2025-05-28 02:04:08 -0400
committerHazelnoot <acomputerdog@gmail.com>2025-06-04 10:46:48 -0400
commitc18edd106b8f0fef560d354a08f971555410c44f (patch)
treebc311dd29c68464b710583696bbed6e8b27e2c2b /packages/frontend/src
parentadd relations from abuse_user_report->user_profile to speed up admin/abuse-us... (diff)
downloadsharkey-c18edd106b8f0fef560d354a08f971555410c44f.tar.gz
sharkey-c18edd106b8f0fef560d354a08f971555410c44f.tar.bz2
sharkey-c18edd106b8f0fef560d354a08f971555410c44f.zip
implement de-duplication for MkUrlPreview
Diffstat (limited to 'packages/frontend/src')
-rw-r--r--packages/frontend/src/components/MkUrlPreview.vue93
1 files changed, 87 insertions, 6 deletions
diff --git a/packages/frontend/src/components/MkUrlPreview.vue b/packages/frontend/src/components/MkUrlPreview.vue
index 69a1540600..eb4a4efa33 100644
--- a/packages/frontend/src/components/MkUrlPreview.vue
+++ b/packages/frontend/src/components/MkUrlPreview.vue
@@ -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 {