diff options
Diffstat (limited to 'packages/frontend/src')
| -rw-r--r-- | packages/frontend/src/components/MkAbuseReport.vue | 65 | ||||
| -rw-r--r-- | packages/frontend/src/components/MkUrlPreview.vue | 111 | ||||
| -rw-r--r-- | packages/frontend/src/components/SkDateSeparatedList.vue | 55 | ||||
| -rw-r--r-- | packages/frontend/src/components/SkUrlPreviewGroup.vue | 348 | ||||
| -rw-r--r-- | packages/frontend/src/components/global/PageWithHeader.vue | 9 | ||||
| -rw-r--r-- | packages/frontend/src/pages/admin-user.vue | 39 | ||||
| -rw-r--r-- | packages/frontend/src/pages/admin/abuses.vue | 7 | ||||
| -rw-r--r-- | packages/frontend/src/pages/instance-info.vue | 29 | ||||
| -rw-r--r-- | packages/frontend/src/utility/extract-preview-urls.ts | 27 | ||||
| -rw-r--r-- | packages/frontend/src/utility/getNoteUrls.ts | 44 | ||||
| -rw-r--r-- | packages/frontend/src/utility/timeline-date-separate.ts | 4 |
11 files changed, 641 insertions, 97 deletions
diff --git a/packages/frontend/src/components/MkAbuseReport.vue b/packages/frontend/src/components/MkAbuseReport.vue index c52fdb898e..5bf5380a1e 100644 --- a/packages/frontend/src/components/MkAbuseReport.vue +++ b/packages/frontend/src/components/MkAbuseReport.vue @@ -33,10 +33,28 @@ SPDX-License-Identifier: AGPL-3.0-only <MkFolder :withSpacer="false"> <template #icon><MkAvatar :user="report.targetUser" style="width: 18px; height: 18px;"/></template> <template #label>{{ i18n.ts.target }}: <MkAcct :user="report.targetUser"/></template> - <template #suffix>#{{ report.targetUserId.toUpperCase() }}</template> + <template #suffix>{{ i18n.ts.id }}# {{ report.targetUserId }}</template> - <div style="height: 300px; --MI-stickyTop: 0; --MI-stickyBottom: 0;"> - <RouterView :router="targetRouter"/> + <div style="--MI-stickyTop: 0; --MI-stickyBottom: 0;"> + <admin-user :userId="report.targetUserId" :userHint="report.targetUser"></admin-user> + </div> + </MkFolder> + + <MkFolder v-if="report.targetInstance" :withSpacer="false"> + <template #icon> + <img + v-if="targetInstanceIcon" + :src="targetInstanceIcon" + :alt="i18n.tsx.instanceIconAlt({ name: report.targetInstance.name || report.targetInstance.host })" + :class="$style.instanceIcon" + class="icon" + /> + </template> + <template #label>{{ i18n.ts.instance }}: {{ report.targetInstance.name || report.targetInstance.host }}</template> + <template #suffix>{{ i18n.ts.id }}# {{ report.targetInstance.id }}</template> + + <div style="--MI-stickyTop: 0; --MI-stickyBottom: 0;"> + <instance-info :host="report.targetInstance.host" :instanceHint="report.targetInstance" :metaHint="metaHint"></instance-info> </div> </MkFolder> @@ -44,23 +62,24 @@ SPDX-License-Identifier: AGPL-3.0-only <template #icon><i class="ti ti-message-2"></i></template> <template #label>{{ i18n.ts.details }}</template> <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"/> + <SkUrlPreviewGroup :sourceNodes="parsedComment" :compact="false" :detail="false" :showAsQuote="true"/> </div> </MkFolder> <MkFolder :withSpacer="false"> <template #icon><MkAvatar :user="report.reporter" style="width: 18px; height: 18px;"/></template> <template #label>{{ i18n.ts.reporter }}: <MkAcct :user="report.reporter"/></template> - <template #suffix>#{{ report.reporterId.toUpperCase() }}</template> + <template #suffix>{{ i18n.ts.id }}# {{ report.reporterId }}</template> - <div style="height: 300px; --MI-stickyTop: 0; --MI-stickyBottom: 0;"> - <RouterView :router="reporterRouter"/> + <div style="--MI-stickyTop: 0; --MI-stickyBottom: 0;"> + <admin-user :userId="report.reporterId" :userHint="report.reporter"></admin-user> </div> </MkFolder> <MkFolder :defaultOpen="false"> <template #icon><i class="ti ti-message-2"></i></template> - <template #label>{{ i18n.ts.moderationNote }}</template> + <template #label>{{ i18n.ts.staffNotes }}</template> <template #suffix>{{ moderationNote.length > 0 ? '...' : i18n.ts.none }}</template> <div class="_gaps_s"> <MkTextarea v-model="moderationNote" manualSave> @@ -78,8 +97,9 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { provide, ref, watch } from 'vue'; +import { computed, provide, ref, watch } from 'vue'; import * as Misskey from 'misskey-js'; +import * as mfm from '@transfem-org/sfm-js'; import MkButton from '@/components/MkButton.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import MkKeyValue from '@/components/MkKeyValue.vue'; @@ -91,6 +111,12 @@ import RouterView from '@/components/global/RouterView.vue'; import MkTextarea from '@/components/MkTextarea.vue'; import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; import { createRouter } from '@/router.js'; +import { getProxiedImageUrlNullable } from '@/utility/media-proxy'; +import InstanceInfo from '@/pages/instance-info.vue'; +import { iAmAdmin } from '@/i'; +import { misskeyApi } from '@/utility/misskey-api'; +import AdminUser from '@/pages/admin-user.vue'; +import SkUrlPreviewGroup from '@/components/SkUrlPreviewGroup.vue'; const props = defineProps<{ report: Misskey.entities.AdminAbuseUserReportsResponse[number]; @@ -100,10 +126,27 @@ const emit = defineEmits<{ (ev: 'resolved', reportId: string): void; }>(); +/* const targetRouter = createRouter(`/admin/user/${props.report.targetUserId}`); targetRouter.init(); const reporterRouter = createRouter(`/admin/user/${props.report.reporterId}`); reporterRouter.init(); +*/ + +const parsedComment = computed(() => mfm.parse(props.report.comment)); +const metaHint = ref<Misskey.entities.AdminMetaResponse | undefined>(undefined); + +const targetInstanceIcon = computed(() => props.report.targetInstance?.faviconUrl + ? getProxiedImageUrlNullable(props.report.targetInstance.faviconUrl, 'preview') + : props.report.targetInstance?.iconUrl + ? getProxiedImageUrlNullable(props.report.targetInstance.iconUrl, 'preview') + : null); + +if (iAmAdmin) { + misskeyApi('admin/meta') + .then(meta => metaHint.value = meta) + .catch(err => console.error('[MkAbuseReport] Error fetching meta:', err)); +} const moderationNote = ref(props.report.moderationNote ?? ''); @@ -150,4 +193,8 @@ function showMenu(ev: MouseEvent) { </script> <style lang="scss" module> +.instanceIcon { + width: 18px; + height: 18px; +} </style> diff --git a/packages/frontend/src/components/MkUrlPreview.vue b/packages/frontend/src/components/MkUrlPreview.vue index 69a1540600..5d0e6e3df7 100644 --- a/packages/frontend/src/components/MkUrlPreview.vue +++ b/packages/frontend/src/components/MkUrlPreview.vue @@ -99,13 +99,24 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </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> import { defineAsyncComponent, onDeactivated, onUnmounted, ref } from 'vue'; import { url as local } from '@@/js/config.js'; import { versatileLang } from '@@/js/intl-const.js'; import * as Misskey from 'misskey-js'; import { maybeMakeRelative } from '@@/js/url.js'; -import type { summaly } from '@misskey-dev/summaly'; import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; import { deviceKind } from '@/utility/device-kind.js'; @@ -119,8 +130,6 @@ import DynamicNoteSimple from '@/components/DynamicNoteSimple.vue'; import { $i } from '@/i'; import { userPage } from '@/filters/user.js'; -type SummalyResult = Awaited<ReturnType<typeof summaly>>; - const props = withDefaults(defineProps<{ url: string; detail?: boolean; @@ -128,12 +137,18 @@ const props = withDefaults(defineProps<{ showAsQuote?: boolean; showActions?: boolean; skipNoteIds?: (string | undefined)[]; + previewHint?: SummalyResult; + noteHint?: Misskey.entities.Note | null; + attributionHint?: Misskey.entities.User | null; }>(), { detail: false, compact: false, showAsQuote: false, showActions: true, skipNoteIds: undefined, + previewHint: undefined, + noteHint: undefined, + attributionHint: undefined, }); const MOBILE_THRESHOLD = 500; @@ -170,12 +185,35 @@ const tweetHeight = ref(150); const unknownUrl = ref(false); const theNote = ref<Misskey.entities.Note | null>(null); const fetchingTheNote = ref(false); +const fetchingAttribution = ref<Promise<void> | null>(null); onDeactivated(() => { playerEnabled.value = false; }); -async function fetchNote() { +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) { if (!props.showAsQuote) return; if (!activityPub.value) return; if (theNote.value) return; @@ -183,8 +221,15 @@ async function fetchNote() { fetchingTheNote.value = true; 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.object) { + activityPub.value = null; + theNote.value = null; + return; + } const theNoteId = response['object'].id; if (theNoteId && props.skipNoteIds && props.skipNoteIds.includes(theNoteId)) { hidePreview.value = true; @@ -210,13 +255,16 @@ if (requestUrl.hostname === 'twitter.com' || requestUrl.hostname === 'mobile.twi if (m) tweetId.value = m[1]; } +// This is now handled on the backend +/* if (requestUrl.hostname === 'music.youtube.com' && requestUrl.pathname.match('^/(?:watch|channel)')) { requestUrl.hostname = 'www.youtube.com'; } requestUrl.hash = ''; +*/ -function refresh(withFetch = false) { +function refresh(withFetch = false, initial = false) { const params = new URLSearchParams({ url: requestUrl.href, lang: versatileLang, @@ -226,23 +274,21 @@ function refresh(withFetch = false) { } const headers = $i ? { Authorization: `Bearer ${$i.token}` } : undefined; - return fetching.value ??= window.fetch(`/url?${params.toString()}`, { headers }) - .then(res => { - if (!res.ok) { - if (_DEV_) { - console.warn(`[HTTP${res.status}] Failed to fetch url preview`); + const fetchPromise: Promise<SummalyResult | null> = (initial && props.previewHint) + ? Promise.resolve(props.previewHint) + : window.fetch(`/url?${params.toString()}`, { headers }) + .then(res => { + if (!res.ok) { + if (_DEV_) { + console.warn(`[HTTP${res.status}] Failed to fetch url preview`); + } + return null; } - return null; - } - return res.json(); - }) - .then(async (info: SummalyResult & { - haveNoteLocally?: boolean, - linkAttribution?: { - userId: string, - } - } | null) => { + return res.json(); + }); + return fetching.value ??= fetchPromise + .then(async (info: SummalyResult | null) => { unknownUrl.value = info == null; title.value = info?.title ?? null; description.value = info?.description ?? null; @@ -258,20 +304,15 @@ function refresh(withFetch = false) { sensitive.value = info?.sensitive ?? false; activityPub.value = info?.activityPub ?? 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; - if (info?.haveNoteLocally) { - await fetchNote(); - } + + await Promise.all([ + fetchAttribution(initial), + fetchNote(initial), + ]); }) .finally(() => { fetching.value = null; @@ -304,7 +345,7 @@ onUnmounted(() => { }); // Load initial data -refresh(); +refresh(false, true); </script> <style lang="scss" module> @@ -388,7 +429,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 { diff --git a/packages/frontend/src/components/SkDateSeparatedList.vue b/packages/frontend/src/components/SkDateSeparatedList.vue new file mode 100644 index 0000000000..239d0c1939 --- /dev/null +++ b/packages/frontend/src/components/SkDateSeparatedList.vue @@ -0,0 +1,55 @@ +<!-- +SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div class="_gaps"> + <template v-for="(item, index) in timeline" :key="item.id"> + <slot v-if="item.type === 'item'" :id="item.id" :index="index" :item="item.data"></slot> + <slot v-else-if="item.type === 'date'" :id="item.id" :index="index" :prev="item.prev" :prevText="item.prevText" :next="item.next" :nextText="item.nextText" name="date"> + <div :class="$style.dateDivider"> + <span><i class="ti ti-chevron-up"></i> {{ item.nextText }}</span> + <span :class="$style.dateSeparator"></span> + <span>{{ item.prevText }} <i class="ti ti-chevron-down"></i></span> + </div> + </slot> + </template> +</div> +</template> + +<script setup lang="ts" generic="T extends { id: string; createdAt: string; }"> +import { computed } from 'vue'; +import { makeDateSeparatedTimelineComputedRef } from '@/utility/timeline-date-separate'; + +const props = defineProps<{ + items: T[], +}>(); + +const itemsRef = computed(() => props.items); +const timeline = makeDateSeparatedTimelineComputedRef(itemsRef); +</script> + +<style module lang="scss"> +// From room.vue +.dateDivider { + display: flex; + font-size: 85%; + align-items: center; + justify-content: center; + gap: 0.5em; + opacity: 0.75; + border: solid 0.5px var(--MI_THEME-divider); + border-radius: 999px; + width: fit-content; + padding: 0.5em 1em; + margin: 0 auto; +} + +// From room.vue +.dateSeparator { + height: 1em; + width: 1px; + background: var(--MI_THEME-divider); +} +</style> diff --git a/packages/frontend/src/components/SkUrlPreviewGroup.vue b/packages/frontend/src/components/SkUrlPreviewGroup.vue new file mode 100644 index 0000000000..32b11d9db4 --- /dev/null +++ b/packages/frontend/src/components/SkUrlPreviewGroup.vue @@ -0,0 +1,348 @@ +<!-- +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" + :attributionHint="preview.attributionUser" + :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 { SummalyResult } from '@/components/MkUrlPreview.vue'; +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'; +import { getNoteUrls } from '@/utility/getNoteUrls'; + +type Summary = SummalyResult & { + note?: Misskey.entities.Note | null; + attributionUser?: Misskey.entities.User | null; +}; + +type Limiter<T> = ReturnType<typeof promiseLimit<T>>; + +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 []; +}); + +// todo un-ref these +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>()); +const cachedUsers = new Map<string, Misskey.entities.User | 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 userLimiter = promiseLimit<Misskey.entities.User | null>(4); + 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) { + await Promise.all([ + attachNote(summary, noteLimiter), + attachAttribution(summary, userLimiter), + ]); + } + + 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 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> { + 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; +} + +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[] { + // 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 if we have a note and the other doesn't + if (preview.note && !p.note) return false; + + // Skip later previews (keep the earliest instance)... + // ...but only if we have AP or the later one doesn't... + // ...and only if we have note or the later one doesn't. + if (i > index && (preview.activityPub || !p.activityPub) && (preview.note || !p.note)) 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; + })); + + // eslint-disable-next-line no-param-reassign + previews = previews + // Remove any previews where the note duplicates url + .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 have a note + if (preview.note) return false; + + // Skip if other does not have a note + if (!p.note) return false; + + // Skip later previews (keep the earliest instance) + if (i > index) return false; + + const noteUrls = getNoteUrls(p.note); + + // Remove if other duplicates our AP URL + if (preview.activityPub && noteUrls.includes(preview.activityPub)) return true; + + // Remove if other duplicates our main URL + return noteUrls.includes(preview.url); + })); + + 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> diff --git a/packages/frontend/src/components/global/PageWithHeader.vue b/packages/frontend/src/components/global/PageWithHeader.vue index d2e59bf4ad..485ea687de 100644 --- a/packages/frontend/src/components/global/PageWithHeader.vue +++ b/packages/frontend/src/components/global/PageWithHeader.vue @@ -6,8 +6,8 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div ref="rootEl" :class="[$style.root, reversed ? '_pageScrollableReversed' : '_pageScrollable']"> <MkStickyContainer> - <template #header><MkPageHeader v-model:tab="tab" v-bind="pageHeaderProps"/></template> - <div :class="$style.body"> + <template #header><MkPageHeader v-model:tab="tab" v-bind="pageHeaderProps" :class="{ _spacer: spacer }"/></template> + <div :class="[ $style.body, { _spacer: spacer } ]"> <MkSwiper v-if="swipable && (props.tabs?.length ?? 1) > 1" v-model:tab="tab" :class="$style.swiper" :tabs="props.tabs" :page="props.page"> <slot></slot> </MkSwiper> @@ -30,13 +30,16 @@ const props = withDefaults(defineProps<PageHeaderProps & { reversed?: boolean; swipable?: boolean; page?: string; + spacer?: boolean; }>(), { reversed: false, swipable: true, + page: undefined, + spacer: false, }); const pageHeaderProps = computed(() => { - const { reversed, ...rest } = props; + const { reversed, spacer, ...rest } = props; return rest; }); diff --git a/packages/frontend/src/pages/admin-user.vue b/packages/frontend/src/pages/admin-user.vue index 0596b165fd..3d17aca7d9 100644 --- a/packages/frontend/src/pages/admin-user.vue +++ b/packages/frontend/src/pages/admin-user.vue @@ -4,8 +4,8 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"> - <div class="_spacer" style="--MI_SPACER-w: 600px; --MI_SPACER-min: 16px; --MI_SPACER-max: 32px;"> +<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs" :spacer="true" style="--MI_SPACER-w: 600px; --MI_SPACER-min: 16px; --MI_SPACER-max: 32px;"> + <div> <FormSuspense :p="init"> <div v-if="tab === 'overview'" class="_gaps"> <div v-if="user" class="aeakzknw"> @@ -273,8 +273,14 @@ import SkBadgeStrip from '@/components/SkBadgeStrip.vue'; const props = withDefaults(defineProps<{ userId: string; initialTab?: string; + userHint?: Misskey.entities.UserDetailed; + infoHint?: Misskey.entities.AdminShowUserResponse; + ipsHint?: Misskey.entities.AdminGetUserIpsResponse; }>(), { initialTab: 'overview', + userHint: undefined, + infoHint: undefined, + ipsHint: undefined, }); const tab = ref(props.initialTab); @@ -405,16 +411,23 @@ const announcementsPagination = { }; const expandedRoles = ref([]); -function createFetcher() { - return () => Promise.all([misskeyApi('users/show', { - userId: props.userId, - }), misskeyApi('admin/show-user', { - userId: props.userId, - }), iAmAdmin ? misskeyApi('admin/get-user-ips', { - userId: props.userId, - }) : Promise.resolve(null), iAmAdmin ? misskeyApi('ap/get', { - uri: `${url}/users/${props.userId}`, - }).catch(() => null) : null]).then(([_user, _info, _ips, _ap]) => { +function createFetcher(withHint = true) { + return () => Promise.all([ + (withHint && props.userHint) ? props.userHint : misskeyApi('users/show', { + userId: props.userId, + }), + (withHint && props.infoHint) ? props.infoHint : misskeyApi('admin/show-user', { + userId: props.userId, + }), + iAmAdmin + ? (withHint && props.ipsHint) ? props.ipsHint : misskeyApi('admin/get-user-ips', { + userId: props.userId, + }) + : null, + iAmAdmin ? misskeyApi('ap/get', { + uri: `${url}/users/${props.userId}`, + }).catch(() => null) : null], + ).then(([_user, _info, _ips, _ap]) => { user.value = _user; info.value = _info; ips.value = _ips; @@ -432,7 +445,7 @@ function createFetcher() { async function refreshUser() { // Not a typo - createFetcher() returns a function() - await createFetcher()(); + await createFetcher(false)(); } async function onMandatoryCWChanged(value: string) { diff --git a/packages/frontend/src/pages/admin/abuses.vue b/packages/frontend/src/pages/admin/abuses.vue index 4ec4372492..6531a3e49d 100644 --- a/packages/frontend/src/pages/admin/abuses.vue +++ b/packages/frontend/src/pages/admin/abuses.vue @@ -48,9 +48,9 @@ SPDX-License-Identifier: AGPL-3.0-only --> <MkPagination v-slot="{items}" ref="reports" :pagination="pagination" :displayLimit="50"> - <div class="_gaps"> - <XAbuseReport v-for="report in items" :key="report.id" :report="report" @resolved="resolved"/> - </div> + <SkDateSeparatedList v-slot="{ item: report }" :items="items"> + <XAbuseReport :report="report" @resolved="resolved"/> + </SkDateSeparatedList> </MkPagination> </div> </div> @@ -67,6 +67,7 @@ import { definePage } from '@/page.js'; import MkButton from '@/components/MkButton.vue'; import MkInfo from '@/components/MkInfo.vue'; import { store } from '@/store.js'; +import SkDateSeparatedList from '@/components/SkDateSeparatedList.vue'; const reports = useTemplateRef('reports'); diff --git a/packages/frontend/src/pages/instance-info.vue b/packages/frontend/src/pages/instance-info.vue index b60bdf3a72..3f14cdabbe 100644 --- a/packages/frontend/src/pages/instance-info.vue +++ b/packages/frontend/src/pages/instance-info.vue @@ -4,8 +4,8 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs" :swipable="true"> - <div v-if="instance" class="_spacer" style="--MI_SPACER-w: 600px; --MI_SPACER-min: 16px; --MI_SPACER-max: 32px;"> +<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs" :spacer="true" style="--MI_SPACER-w: 600px; --MI_SPACER-min: 16px; --MI_SPACER-max: 32px;"> + <div v-if="instance"> <!-- This empty div is preserved to avoid merge conflicts --> <div> <div v-if="tab === 'overview'" class="_gaps"> @@ -238,9 +238,14 @@ import SkBadgeStrip from '@/components/SkBadgeStrip.vue'; const $style = useCssModule(); -const props = defineProps<{ +const props = withDefaults(defineProps<{ host: string; -}>(); + metaHint?: Misskey.entities.AdminMetaResponse; + instanceHint?: Misskey.entities.FederationInstance; +}>(), { + metaHint: undefined, + instanceHint: undefined, +}); const tab = ref('overview'); @@ -363,12 +368,16 @@ async function saveModerationNote() { } } -async function fetch(): Promise<void> { +async function fetch(withHint = false): Promise<void> { const [m, i] = await Promise.all([ - iAmAdmin ? misskeyApi('admin/meta') : null, - misskeyApi('federation/show-instance', { - host: props.host, - }), + (withHint && props.metaHint) + ? props.metaHint + : iAmAdmin ? misskeyApi('admin/meta') : null, + (withHint && props.instanceHint) + ? props.instanceHint + : misskeyApi('federation/show-instance', { + host: props.host, + }), ]); meta.value = m; instance.value = i; @@ -531,7 +540,7 @@ async function severAllFollowRelations(): Promise<void> { }); } -fetch(); +fetch(true); const headerActions = computed(() => [{ text: `https://${props.host}`, diff --git a/packages/frontend/src/utility/extract-preview-urls.ts b/packages/frontend/src/utility/extract-preview-urls.ts index 5fc9c87a32..e3bd62c993 100644 --- a/packages/frontend/src/utility/extract-preview-urls.ts +++ b/packages/frontend/src/utility/extract-preview-urls.ts @@ -3,35 +3,18 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import * as config from '@@/js/config.js'; import type * as Misskey from 'misskey-js'; import type * as mfm from '@transfem-org/sfm-js'; import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm.js'; +import { getNoteUrls } from '@/utility/getNoteUrls'; /** * Extracts all previewable URLs from a note. */ export function extractPreviewUrls(note: Misskey.entities.Note, contents: mfm.MfmNode[]): string[] { const links = extractUrlFromMfm(contents); - return links.filter(url => - // Remote note - url !== note.url && - url !== note.uri && - // Local note - url !== `${config.url}/notes/${note.id}` && - // Remote reply - url !== note.reply?.url && - url !== note.reply?.uri && - // Local reply - url !== `${config.url}/notes/${note.reply?.id}` && - // Remote renote or quote - url !== note.renote?.url && - url !== note.renote?.uri && - // Local renote or quote - url !== `${config.url}/notes/${note.renote?.id}` && - // Remote renote *of* a quote - url !== note.renote?.renote?.url && - url !== note.renote?.renote?.uri && - // Local renote *of* a quote - url !== `${config.url}/notes/${note.renote?.renote?.id}`); + if (links.length < 0) return []; + + const self = getNoteUrls(note); + return links.filter(url => !self.includes(url)); } diff --git a/packages/frontend/src/utility/getNoteUrls.ts b/packages/frontend/src/utility/getNoteUrls.ts new file mode 100644 index 0000000000..efd014cbf0 --- /dev/null +++ b/packages/frontend/src/utility/getNoteUrls.ts @@ -0,0 +1,44 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import * as config from '@@/js/config.js'; +import type * as Misskey from 'misskey-js'; + +export function getNoteUrls(note: Misskey.entities.Note): string[] { + const urls: string[] = [ + // Any note + `${config.url}/notes/${note.id}`, + ]; + + // Remote note + if (note.url) urls.push(note.url); + if (note.uri) urls.push(note.uri); + + if (note.reply) { + // Any Reply + urls.push(`${config.url}/notes/${note.reply.id}`); + // Remote Reply + if (note.reply.url) urls.push(note.reply.url); + if (note.reply.uri) urls.push(note.reply.uri); + } + + if (note.renote) { + // Any Renote + urls.push(`${config.url}/notes/${note.renote.id}`); + // Remote Renote + if (note.renote.url) urls.push(note.renote.url); + if (note.renote.uri) urls.push(note.renote.uri); + } + + if (note.renote?.renote) { + // Any Quote + urls.push(`${config.url}/notes/${note.renote.renote.id}`); + // Remote Quote + if (note.renote.renote.url) urls.push(note.renote.renote.url); + if (note.renote.renote.uri) urls.push(note.renote.renote.uri); + } + + return urls; +} diff --git a/packages/frontend/src/utility/timeline-date-separate.ts b/packages/frontend/src/utility/timeline-date-separate.ts index e1bc9790b9..ef946b11d6 100644 --- a/packages/frontend/src/utility/timeline-date-separate.ts +++ b/packages/frontend/src/utility/timeline-date-separate.ts @@ -4,7 +4,7 @@ */ import { computed } from 'vue'; -import type { Ref } from 'vue'; +import type { Ref, ComputedRef } from 'vue'; export function getDateText(dateInstance: Date) { const date = dateInstance.getDate(); @@ -25,7 +25,7 @@ export type DateSeparetedTimelineItem<T> = { nextText: string; }; -export function makeDateSeparatedTimelineComputedRef<T extends { id: string; createdAt: string; }>(items: Ref<T[]>) { +export function makeDateSeparatedTimelineComputedRef<T extends { id: string; createdAt: string; }>(items: Ref<T[]> | ComputedRef<T[]>) { return computed<DateSeparetedTimelineItem<T>[]>(() => { const tl: DateSeparetedTimelineItem<T>[] = []; for (let i = 0; i < items.value.length; i++) { |