support link attributions in SkUrlPreviewGroup

This commit is contained in:
Hazelnoot 2025-06-04 11:15:42 -04:00
parent b811a8f0ab
commit bae4c07bb3
2 changed files with 92 additions and 30 deletions

View file

@ -99,13 +99,24 @@ SPDX-License-Identifier: AGPL-3.0-only
</div> </div>
</template> </template>
<script lang="ts">
// eslint-disable-next-line import/order
import type { summaly } from '@misskey-dev/summaly';
export type SummalyResult = Awaited<ReturnType<typeof summaly>> & {
haveNoteLocally?: boolean,
linkAttribution?: {
userId: string,
}
};
</script>
<script lang="ts" setup> <script lang="ts" setup>
import { defineAsyncComponent, onDeactivated, onUnmounted, ref } 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';
import { maybeMakeRelative } from '@@/js/url.js'; import { maybeMakeRelative } from '@@/js/url.js';
import type { summaly } from '@misskey-dev/summaly';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import * as os from '@/os.js'; import * as os from '@/os.js';
import { deviceKind } from '@/utility/device-kind.js'; import { deviceKind } from '@/utility/device-kind.js';
@ -119,13 +130,6 @@ 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';
type SummalyResult = Awaited<ReturnType<typeof summaly>> & {
haveNoteLocally?: boolean,
linkAttribution?: {
userId: string,
}
};
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
url: string; url: string;
detail?: boolean; detail?: boolean;
@ -135,6 +139,7 @@ const props = withDefaults(defineProps<{
skipNoteIds?: (string | undefined)[]; skipNoteIds?: (string | undefined)[];
previewHint?: SummalyResult; previewHint?: SummalyResult;
noteHint?: Misskey.entities.Note | null; noteHint?: Misskey.entities.Note | null;
attributionHint?: Misskey.entities.User | null;
}>(), { }>(), {
detail: false, detail: false,
compact: false, compact: false,
@ -143,6 +148,7 @@ const props = withDefaults(defineProps<{
skipNoteIds: undefined, skipNoteIds: undefined,
previewHint: undefined, previewHint: undefined,
noteHint: undefined, noteHint: undefined,
attributionHint: undefined,
}); });
const MOBILE_THRESHOLD = 500; const MOBILE_THRESHOLD = 500;
@ -179,11 +185,34 @@ 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 fetchingAttribution = ref<Promise<void> | null>(null);
onDeactivated(() => { onDeactivated(() => {
playerEnabled.value = false; playerEnabled.value = false;
}); });
async function fetchAttribution(initial: boolean): Promise<void> {
if (!linkAttribution.value) return;
if (attributionUser.value) return;
if (fetchingAttribution.value) return fetchingAttribution.value;
return fetchingAttribution.value ??= (async (userId: string): Promise<void> => {
try {
if (initial && props.attributionHint !== undefined) {
attributionUser.value = props.attributionHint;
} else {
attributionUser.value = await misskeyApi('users/show', { userId });
}
} catch {
// makes the loading ellipsis vanish.
linkAttribution.value = null;
} finally {
// Reset promise to mark as done
fetchingAttribution.value = null;
}
})(linkAttribution.value.userId);
}
async function fetchNote(initial: boolean) { async function fetchNote(initial: boolean) {
if (!props.showAsQuote) return; if (!props.showAsQuote) return;
if (!activityPub.value) return; if (!activityPub.value) return;
@ -275,20 +304,15 @@ function refresh(withFetch = false, initial = false) {
sensitive.value = info?.sensitive ?? false; sensitive.value = info?.sensitive ?? false;
activityPub.value = info?.activityPub ?? null; activityPub.value = info?.activityPub ?? null;
linkAttribution.value = info?.linkAttribution ?? null; linkAttribution.value = info?.linkAttribution ?? null;
if (linkAttribution.value) {
try {
const response = await misskeyApi('users/show', { userId: linkAttribution.value.userId });
attributionUser.value = response;
} catch {
// makes the loading ellipsis vanish.
linkAttribution.value = null;
}
}
// These will be populated by the fetch* functions
attributionUser.value = null;
theNote.value = null; theNote.value = null;
if (info?.haveNoteLocally) {
await fetchNote(initial); await Promise.all([
} fetchAttribution(initial),
fetchNote(initial),
]);
}) })
.finally(() => { .finally(() => {
fetching.value = null; fetching.value = null;

View file

@ -14,6 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only
:url="preview.url" :url="preview.url"
:previewHint="preview" :previewHint="preview"
:noteHint="preview.note" :noteHint="preview.note"
:attributionHint="preview.attributionUser"
:detail="detail" :detail="detail"
:compact="compact" :compact="compact"
:showAsQuote="showAsQuote" :showAsQuote="showAsQuote"
@ -29,7 +30,7 @@ import * as mfm from '@transfem-org/sfm-js';
import { computed, ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import { versatileLang } from '@@/js/intl-const'; import { versatileLang } from '@@/js/intl-const';
import promiseLimit from 'promise-limit'; import promiseLimit from 'promise-limit';
import type { summaly } from '@misskey-dev/summaly'; import type { SummalyResult } from '@/components/MkUrlPreview.vue';
import { extractPreviewUrls } from '@/utility/extract-preview-urls'; import { extractPreviewUrls } from '@/utility/extract-preview-urls';
import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm'; import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm';
import { $i } from '@/i'; import { $i } from '@/i';
@ -37,11 +38,13 @@ import { misskeyApi } from '@/utility/misskey-api';
import MkUrlPreview from '@/components/MkUrlPreview.vue'; import MkUrlPreview from '@/components/MkUrlPreview.vue';
import { getNoteUrls } from '@/utility/getNoteUrls'; import { getNoteUrls } from '@/utility/getNoteUrls';
type Summary = Awaited<ReturnType<typeof summaly>> & { type Summary = SummalyResult & {
haveNoteLocally?: boolean;
note?: Misskey.entities.Note | null; note?: Misskey.entities.Note | null;
attributionUser?: Misskey.entities.User | null;
}; };
type Limiter<T> = ReturnType<typeof promiseLimit<T>>;
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
sourceUrls?: string[]; sourceUrls?: string[];
sourceNodes?: mfm.MfmNode[]; sourceNodes?: mfm.MfmNode[];
@ -90,9 +93,11 @@ const urls = computed<string[]>(() => {
return []; return [];
}); });
// todo un-ref these
const isRefreshing = ref<Promise<void> | false>(false); const isRefreshing = ref<Promise<void> | false>(false);
const cachedNotes = ref(new Map<string, Misskey.entities.Note | null>()); const cachedNotes = ref(new Map<string, Misskey.entities.Note | null>());
const cachedPreviews = ref(new Map<string, Summary | null>()); const cachedPreviews = ref(new Map<string, Summary | null>());
const cachedUsers = new Map<string, Misskey.entities.User | null>();
/** /**
* Refreshes the group. * Refreshes the group.
@ -124,6 +129,7 @@ async function doRefresh(): Promise<void> {
} }
async function fetchPreviews(): Promise<Summary[]> { async function fetchPreviews(): Promise<Summary[]> {
const userLimiter = promiseLimit<Misskey.entities.User | null>(4);
const noteLimiter = promiseLimit<Misskey.entities.Note | null>(2); const noteLimiter = promiseLimit<Misskey.entities.Note | null>(2);
const summaryLimiter = promiseLimit<Summary | null>(5); const summaryLimiter = promiseLimit<Summary | null>(5);
@ -131,13 +137,11 @@ async function fetchPreviews(): Promise<Summary[]> {
summaryLimiter(async () => { summaryLimiter(async () => {
return await fetchPreview(url); return await fetchPreview(url);
}).then(async (summary) => { }).then(async (summary) => {
if (summary && props.showAsQuote && summary.activityPub && summary.haveNoteLocally) { if (summary) {
// Have to pull this out to make TS happy await Promise.all([
const noteUri = summary.activityPub; attachNote(summary, noteLimiter),
attachAttribution(summary, userLimiter),
summary.note = await noteLimiter(async () => { ]);
return await fetchNote(noteUri);
});
} }
return summary; return summary;
@ -171,6 +175,17 @@ async function fetchPreview(url: string): Promise<Summary | null> {
return null; return null;
} }
async function attachNote(summary: Summary, noteLimiter: Limiter<Misskey.entities.Note | null>): Promise<void> {
if (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);
});
}
}
async function fetchNote(noteUri: string): Promise<Misskey.entities.Note | null> { async function fetchNote(noteUri: string): Promise<Misskey.entities.Note | null> {
const cached = cachedNotes.value.get(noteUri); const cached = cachedNotes.value.get(noteUri);
if (cached) { if (cached) {
@ -194,6 +209,29 @@ async function fetchNote(noteUri: string): Promise<Misskey.entities.Note | null>
return null; return null;
} }
async function attachAttribution(summary: Summary, userLimiter: Limiter<Misskey.entities.User | null>): Promise<void> {
if (summary.linkAttribution) {
// Have to pull this out to make TS happy
const userId = summary.linkAttribution.userId;
summary.attributionUser = await userLimiter(async () => {
return await fetchUser(userId);
});
}
}
async function fetchUser(userId: string): Promise<Misskey.entities.User | null> {
const cached = cachedUsers.get(userId);
if (cached) {
return cached;
}
const user = await misskeyApi('users/show', { userId }).catch(() => null);
cachedUsers.set(userId, user);
return user;
}
function deduplicatePreviews(previews: Summary[]): Summary[] { function deduplicatePreviews(previews: Summary[]): Summary[] {
// eslint-disable-next-line no-param-reassign // eslint-disable-next-line no-param-reassign
previews = previews previews = previews