merge: Report admin UX improvements (!1060)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/1060 Approved-by: Marie <github@yuugi.dev> Approved-by: dakkar <dakkar@thenautilus.net>
This commit is contained in:
commit
f88253b95f
27 changed files with 846 additions and 135 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
55
packages/frontend/src/components/SkDateSeparatedList.vue
Normal file
55
packages/frontend/src/components/SkDateSeparatedList.vue
Normal file
|
|
@ -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>
|
||||
348
packages/frontend/src/components/SkUrlPreviewGroup.vue
Normal file
348
packages/frontend/src/components/SkUrlPreviewGroup.vue
Normal file
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
44
packages/frontend/src/utility/getNoteUrls.ts
Normal file
44
packages/frontend/src/utility/getNoteUrls.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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++) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue