mirror of
https://github.com/modrinth/code.git
synced 2026-08-30 11:36:05 +00:00
feat: moderation additions for shared instances (#7285)
* feat: moderation additions for shared instances * fix: lint --------- Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
This commit is contained in:
@@ -65,11 +65,17 @@ export interface SharedInstanceReportDetails {
|
||||
other_instances_loaded?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
details: SharedInstanceReportDetails
|
||||
banPending?: boolean
|
||||
loadVersionContent?: (instanceId: string, version: number) => Promise<ContentItem[]>
|
||||
}>()
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
details: SharedInstanceReportDetails
|
||||
banPending?: boolean
|
||||
loadVersionContent?: (instanceId: string, version: number) => Promise<ContentItem[]>
|
||||
contextType?: 'report' | 'moderation'
|
||||
}>(),
|
||||
{
|
||||
contextType: 'report',
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
banOwner: [owner: SharedInstanceReportUser]
|
||||
@@ -223,7 +229,7 @@ function formattedLoader(version: SharedInstanceReportVersion) {
|
||||
<span
|
||||
class="border-blue/60 rounded-full border border-solid bg-highlight-blue px-2 py-0.5 text-xs font-semibold text-blue"
|
||||
>
|
||||
Report context
|
||||
{{ contextType === 'report' ? 'Report context' : 'Moderation context' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -310,7 +316,10 @@ function formattedLoader(version: SharedInstanceReportVersion) {
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="font-semibold text-contrast">Version {{ version.version }}</span>
|
||||
<span
|
||||
v-if="version.version === details.reported_version?.version"
|
||||
v-if="
|
||||
contextType === 'report' &&
|
||||
version.version === details.reported_version?.version
|
||||
"
|
||||
class="bg-orange-highlight rounded-full px-2 py-0.5 text-xs font-semibold text-orange"
|
||||
>
|
||||
Reported
|
||||
@@ -333,7 +342,11 @@ function formattedLoader(version: SharedInstanceReportVersion) {
|
||||
</div>
|
||||
</div>
|
||||
<span v-else class="text-sm text-secondary">
|
||||
No version was attached to this report.
|
||||
{{
|
||||
contextType === 'report'
|
||||
? 'No version was attached to this report.'
|
||||
: 'No version history is available.'
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import type { AbstractModrinthClient, Labrinth, SharedInstances } from '@modrinth/api-client'
|
||||
import type { ContentItem } from '@modrinth/ui'
|
||||
|
||||
type SharedInstanceVersionDependency = Labrinth.Versions.v2.Dependency & {
|
||||
project_id?: string
|
||||
version_id?: string
|
||||
}
|
||||
|
||||
export function createSharedInstanceContentLoader(client: AbstractModrinthClient) {
|
||||
const instanceVersions = new Map<string, SharedInstances.Instances.v1.InstanceVersion>()
|
||||
|
||||
function cacheVersion(instanceId: string, version: SharedInstances.Instances.v1.InstanceVersion) {
|
||||
instanceVersions.set(`${instanceId}:${version.version}`, version)
|
||||
}
|
||||
|
||||
async function getVersion(
|
||||
instanceId: string,
|
||||
versionNumber: number,
|
||||
): Promise<SharedInstances.Instances.v1.InstanceVersion> {
|
||||
const cacheKey = `${instanceId}:${versionNumber}`
|
||||
const cachedVersion = instanceVersions.get(cacheKey)
|
||||
if (cachedVersion) return cachedVersion
|
||||
|
||||
const version = await client.sharedinstances.instances_v1.getVersion(instanceId, versionNumber)
|
||||
instanceVersions.set(cacheKey, version)
|
||||
return version
|
||||
}
|
||||
|
||||
async function loadVersionContent(
|
||||
instanceId: string,
|
||||
versionNumber: number,
|
||||
): Promise<ContentItem[]> {
|
||||
const instanceVersion = await getVersion(instanceId, versionNumber)
|
||||
const modpackVersionId = instanceVersion.modpack_id
|
||||
const directVersionIds = (instanceVersion.modrinth_ids ?? []).filter(
|
||||
(versionId) => versionId !== modpackVersionId,
|
||||
)
|
||||
const modpackVersion = modpackVersionId
|
||||
? await client.labrinth.versions_v2.getVersion(modpackVersionId)
|
||||
: null
|
||||
const modpackDependencies = (modpackVersion?.dependencies ??
|
||||
[]) as SharedInstanceVersionDependency[]
|
||||
const dependencyVersionIds = modpackDependencies.flatMap((dependency) =>
|
||||
dependency.version_id ? [dependency.version_id] : [],
|
||||
)
|
||||
const uniqueVersionIds = [...new Set([...directVersionIds, ...dependencyVersionIds])]
|
||||
const versions = uniqueVersionIds.length
|
||||
? await client.labrinth.versions_v2.getVersions(uniqueVersionIds)
|
||||
: []
|
||||
const dependencyProjectIds = modpackDependencies.flatMap((dependency) =>
|
||||
dependency.project_id ? [dependency.project_id] : [],
|
||||
)
|
||||
const projectIds = [
|
||||
...new Set([
|
||||
...versions.map((version) => version.project_id),
|
||||
...dependencyProjectIds,
|
||||
...(modpackVersion ? [modpackVersion.project_id] : []),
|
||||
]),
|
||||
]
|
||||
const projects = projectIds.length
|
||||
? await client.labrinth.projects_v2.getMultiple(projectIds)
|
||||
: []
|
||||
const versionsById = new Map(versions.map((version) => [version.id, version]))
|
||||
const projectsById = new Map(projects.map((project) => [project.id, project]))
|
||||
const modpackProject = modpackVersion ? projectsById.get(modpackVersion.project_id) : undefined
|
||||
|
||||
const directContent: ContentItem[] = [...new Set(directVersionIds)].flatMap((versionId) => {
|
||||
const version = versionsById.get(versionId)
|
||||
if (!version) return []
|
||||
|
||||
const project = projectsById.get(version.project_id)
|
||||
return [sharedInstanceContentItem(version, project)]
|
||||
})
|
||||
|
||||
const modpackContent: ContentItem[] = modpackDependencies.map((dependency) => {
|
||||
const version = dependency.version_id ? versionsById.get(dependency.version_id) : undefined
|
||||
const project = dependency.project_id
|
||||
? projectsById.get(dependency.project_id)
|
||||
: version
|
||||
? projectsById.get(version.project_id)
|
||||
: undefined
|
||||
const primaryFile = version
|
||||
? (version.files.find((file) => file.primary) ?? version.files[0])
|
||||
: undefined
|
||||
const fileName =
|
||||
primaryFile?.filename ??
|
||||
dependency.file_name ??
|
||||
project?.title ??
|
||||
version?.name ??
|
||||
'Unknown'
|
||||
|
||||
const item = sharedInstanceContentItem(
|
||||
version,
|
||||
project,
|
||||
fileName,
|
||||
dependency.project_id ?? fileName,
|
||||
!project && !version,
|
||||
)
|
||||
return modpackProject
|
||||
? {
|
||||
...item,
|
||||
source: { project: modpackProject },
|
||||
}
|
||||
: item
|
||||
})
|
||||
|
||||
const externalContent: ContentItem[] = instanceVersion.external_files.map((file, index) => ({
|
||||
id: `external:${file.file_type}:${file.file_name}:${index}`,
|
||||
file_name: file.file_name,
|
||||
size: file.file_size,
|
||||
project_type: file.file_type,
|
||||
has_update: false,
|
||||
update_version_id: null,
|
||||
source_kind: 'shared_instance',
|
||||
external: true,
|
||||
external_url: file.url,
|
||||
project: {
|
||||
id: file.file_name,
|
||||
slug: file.file_name,
|
||||
title: file.file_name,
|
||||
icon_url: undefined,
|
||||
},
|
||||
}))
|
||||
|
||||
return [...externalContent, ...modpackContent, ...directContent]
|
||||
}
|
||||
|
||||
return { cacheVersion, getVersion, loadVersionContent }
|
||||
}
|
||||
|
||||
function sharedInstanceContentItem(
|
||||
version: Labrinth.Versions.v2.Version | undefined,
|
||||
project: Labrinth.Projects.v2.Project | undefined,
|
||||
fallbackFileName?: string,
|
||||
fallbackProjectId = version?.project_id ?? fallbackFileName ?? 'unknown',
|
||||
external = false,
|
||||
): ContentItem {
|
||||
const primaryFile = version
|
||||
? (version.files.find((file) => file.primary) ?? version.files[0])
|
||||
: undefined
|
||||
const fileName =
|
||||
primaryFile?.filename ?? fallbackFileName ?? project?.title ?? version?.name ?? 'Unknown'
|
||||
|
||||
return {
|
||||
id: version?.id ?? project?.id ?? fileName,
|
||||
file_name: fileName,
|
||||
size: primaryFile?.size,
|
||||
project_type: project?.project_type ?? 'mod',
|
||||
has_update: false,
|
||||
update_version_id: null,
|
||||
source_kind: 'shared_instance',
|
||||
external,
|
||||
project: {
|
||||
id: project?.id ?? fallbackProjectId,
|
||||
slug: project?.slug ?? fallbackProjectId,
|
||||
title: project?.title ?? version?.name ?? fileName,
|
||||
icon_url: project?.icon_url ?? undefined,
|
||||
},
|
||||
...(version
|
||||
? {
|
||||
version: {
|
||||
id: version.id,
|
||||
version_number: version.version_number,
|
||||
file_name: fileName,
|
||||
date_published: version.date_published,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,566 @@
|
||||
<template>
|
||||
<NewModal
|
||||
ref="contextModal"
|
||||
:header="
|
||||
selectedInstance ? `${selectedInstance.name} moderation context` : 'Shared instance context'
|
||||
"
|
||||
no-padding
|
||||
scrollable
|
||||
max-width="90rem"
|
||||
width="calc(100vw - 2rem)"
|
||||
max-content-height="85vh"
|
||||
>
|
||||
<div
|
||||
v-if="selectedContextPending"
|
||||
class="flex min-h-64 items-center justify-center gap-2 text-secondary"
|
||||
>
|
||||
<LoaderCircleIcon class="size-6 animate-spin" />
|
||||
Loading moderation context…
|
||||
</div>
|
||||
<div v-else-if="selectedContextError" class="p-6">
|
||||
<Admonition
|
||||
type="critical"
|
||||
header="Failed to load moderation context"
|
||||
:body="selectedContextErrorMessage"
|
||||
show-actions-underneath
|
||||
>
|
||||
<template #actions>
|
||||
<Button :disabled="selectedContextFetching" @click="refetchSelectedContext()">
|
||||
<LoaderCircleIcon v-if="selectedContextFetching" class="animate-spin" />
|
||||
Try again
|
||||
</Button>
|
||||
</template>
|
||||
</Admonition>
|
||||
</div>
|
||||
<SharedInstanceReportContext
|
||||
v-else-if="selectedContext"
|
||||
:key="selectedContext.id"
|
||||
:details="selectedContext"
|
||||
:ban-pending="banOwnerPending"
|
||||
:load-version-content="sharedInstanceContent.loadVersionContent"
|
||||
context-type="moderation"
|
||||
class="!border-t-0"
|
||||
@ban-owner="banSharedInstanceOwner"
|
||||
@content-error="showSharedInstanceContentError"
|
||||
/>
|
||||
</NewModal>
|
||||
|
||||
<div class="normal-page no-sidebar !pb-6">
|
||||
<div class="normal-page__content flex flex-col gap-4">
|
||||
<header
|
||||
class="flex flex-col gap-3 border-0 border-b border-solid border-divider pb-4 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<Avatar
|
||||
:src="user?.avatar_url"
|
||||
:alt="user?.username ?? userId"
|
||||
:tint-by="user?.id ?? userId"
|
||||
size="48px"
|
||||
circle
|
||||
/>
|
||||
<div class="min-w-0">
|
||||
<h1 class="m-0 truncate text-2xl font-extrabold text-contrast">
|
||||
{{ user?.username ?? userId }}'s shared instances
|
||||
</h1>
|
||||
<p class="m-0 text-secondary">All shared instances this user owns or belongs to.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ButtonLink :to="`/user/${user?.id ?? userId}`" target="_blank" class="w-fit">
|
||||
<UserIcon />
|
||||
User profile
|
||||
<ExternalIcon class="size-4" />
|
||||
</ButtonLink>
|
||||
</header>
|
||||
|
||||
<div
|
||||
v-if="isPending"
|
||||
class="flex min-h-48 items-center justify-center gap-2 rounded-2xl border border-solid border-surface-4 bg-surface-3 text-secondary"
|
||||
>
|
||||
<LoaderCircleIcon class="size-6 animate-spin" />
|
||||
Loading shared instances…
|
||||
</div>
|
||||
|
||||
<Admonition
|
||||
v-else-if="error"
|
||||
type="critical"
|
||||
header="Failed to load shared instances"
|
||||
:body="errorMessage"
|
||||
show-actions-underneath
|
||||
>
|
||||
<template #actions>
|
||||
<Button :disabled="isFetching" @click="retry">
|
||||
<LoaderCircleIcon v-if="isFetching" class="animate-spin" />
|
||||
Try again
|
||||
</Button>
|
||||
</template>
|
||||
</Admonition>
|
||||
|
||||
<template v-else>
|
||||
<Admonition
|
||||
v-if="sharedInstances?.unavailableCount"
|
||||
type="warning"
|
||||
header="Some shared instances are unavailable"
|
||||
:body="`${sharedInstances.unavailableCount} ${sharedInstances.unavailableCount === 1 ? 'instance could' : 'instances could'} not be loaded.`"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="!sharedInstances?.instances.length"
|
||||
class="flex min-h-48 flex-col items-center justify-center gap-2 rounded-2xl border border-solid border-surface-4 bg-surface-3 p-6 text-center"
|
||||
>
|
||||
<BoxesIcon class="size-10 text-secondary" />
|
||||
<h2 class="m-0 text-lg font-semibold text-contrast">No shared instances</h2>
|
||||
<p class="m-0 text-secondary">
|
||||
This user does not own or belong to any shared instances.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="grid gap-3 md:grid-cols-2">
|
||||
<article
|
||||
v-for="instance in sharedInstances.instances"
|
||||
:key="instance.id"
|
||||
class="flex min-w-0 flex-col gap-4 rounded-2xl border border-solid border-surface-4 bg-surface-3 p-4"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<Avatar
|
||||
:src="instance.iconUrl"
|
||||
:alt="instance.name"
|
||||
:tint-by="instance.id"
|
||||
size="48px"
|
||||
no-shadow
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<h2 class="m-0 truncate text-lg font-semibold text-contrast">
|
||||
{{ instance.name }}
|
||||
</h2>
|
||||
<span
|
||||
v-if="instance.quarantine"
|
||||
class="bg-orange-highlight inline-flex shrink-0 items-center gap-1 rounded-full px-2 py-0.5 text-xs font-semibold text-orange"
|
||||
>
|
||||
<LockIcon class="size-3.5" />
|
||||
Quarantined
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-sm text-secondary">{{ relationLabel(instance.membership) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2 text-sm text-primary sm:grid-cols-2">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<VersionIcon class="size-5 shrink-0 text-secondary" />
|
||||
<span class="truncate">
|
||||
<template v-if="instance.latestVersion">
|
||||
Version {{ instance.latestVersion.version }} ·
|
||||
{{ formattedVersion(instance.latestVersion) }}
|
||||
</template>
|
||||
<template v-else>Version unavailable</template>
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<UsersIcon class="size-5 shrink-0 text-secondary" />
|
||||
{{ instance.memberCount }}
|
||||
{{ instance.memberCount === 1 ? 'member' : 'members' }}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<HistoryIcon class="size-5 shrink-0 text-secondary" />
|
||||
<span
|
||||
v-if="instance.membership?.last_played"
|
||||
v-tooltip="formatDateTime(instance.membership.last_played)"
|
||||
>
|
||||
Last played {{ formatRelativeTime(instance.membership.last_played) }}
|
||||
</span>
|
||||
<span v-else>Never played</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<CalendarIcon class="size-5 shrink-0 text-secondary" />
|
||||
<span
|
||||
v-if="instance.membership?.joined_at"
|
||||
v-tooltip="formatDateTime(instance.membership.joined_at)"
|
||||
>
|
||||
Joined {{ formatRelativeTime(instance.membership.joined_at) }}
|
||||
</span>
|
||||
<span v-else>Join date unavailable</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-auto flex flex-col gap-1.5">
|
||||
<span class="text-xs font-semibold uppercase tracking-wide text-secondary">
|
||||
Instance ID
|
||||
</span>
|
||||
<CopyCode :text="instance.id" />
|
||||
</div>
|
||||
<Button
|
||||
type="colored"
|
||||
color="orange"
|
||||
class="w-full"
|
||||
@click="openInstanceContext(instance)"
|
||||
>
|
||||
<ScaleIcon />
|
||||
View context
|
||||
</Button>
|
||||
</article>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { SharedInstances } from '@modrinth/api-client'
|
||||
import {
|
||||
BoxesIcon,
|
||||
CalendarIcon,
|
||||
ExternalIcon,
|
||||
HistoryIcon,
|
||||
LoaderCircleIcon,
|
||||
LockIcon,
|
||||
ScaleIcon,
|
||||
UserIcon,
|
||||
UsersIcon,
|
||||
VersionIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
Admonition,
|
||||
Avatar,
|
||||
Button,
|
||||
ButtonLink,
|
||||
CopyCode,
|
||||
injectModrinthClient,
|
||||
injectNotificationManager,
|
||||
NewModal,
|
||||
useFormatDateTime,
|
||||
useRelativeTime,
|
||||
} from '@modrinth/ui'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { computed, onServerPrefetch, ref } from 'vue'
|
||||
|
||||
import SharedInstanceReportContext, {
|
||||
type SharedInstanceOwnerInstance,
|
||||
type SharedInstanceReportDetails,
|
||||
type SharedInstanceReportUser,
|
||||
} from '~/components/ui/moderation/SharedInstanceReportContext.vue'
|
||||
import { createSharedInstanceContentLoader } from '~/helpers/shared-instance-content'
|
||||
|
||||
type InstanceMembership = SharedInstances.Instances.v1.InstanceUser
|
||||
type InstanceVersion = SharedInstances.Instances.v1.InstanceVersion
|
||||
type UserSharedInstance = {
|
||||
id: string
|
||||
name: string
|
||||
iconUrl: string | null
|
||||
quarantine: boolean
|
||||
memberCount: number
|
||||
membership?: InstanceMembership
|
||||
latestVersion: InstanceVersion | null
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const client = injectModrinthClient()
|
||||
const queryClient = useQueryClient()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const userId = computed(() => String(route.params.user ?? ''))
|
||||
const canLoadUser = computed(() => userId.value.length > 0)
|
||||
const selectedInstance = ref<UserSharedInstance | null>(null)
|
||||
const contextModal = ref<InstanceType<typeof NewModal> | null>(null)
|
||||
const sharedInstanceContent = createSharedInstanceContentLoader(client)
|
||||
const formatRelativeTime = useRelativeTime({ style: 'narrow' })
|
||||
const formatDateTime = useFormatDateTime({ dateStyle: 'medium', timeStyle: 'short' })
|
||||
|
||||
const {
|
||||
data: user,
|
||||
error: userError,
|
||||
isPending: userPending,
|
||||
isFetching: userFetching,
|
||||
refetch: refetchUser,
|
||||
suspense: userSuspense,
|
||||
} = useQuery({
|
||||
queryKey: computed(() => ['user', 'v3', userId.value] as const),
|
||||
queryFn: () => client.labrinth.users_v3.get(userId.value),
|
||||
enabled: canLoadUser,
|
||||
})
|
||||
|
||||
const {
|
||||
data: sharedInstances,
|
||||
error: sharedInstancesError,
|
||||
isPending: sharedInstancesPending,
|
||||
isFetching: sharedInstancesFetching,
|
||||
refetch: refetchSharedInstances,
|
||||
suspense: sharedInstancesSuspense,
|
||||
} = useQuery({
|
||||
queryKey: computed(() => ['shared-instances', 'user', userId.value] as const),
|
||||
queryFn: loadSharedInstances,
|
||||
enabled: canLoadUser,
|
||||
})
|
||||
|
||||
const selectedInstanceId = computed(() => selectedInstance.value?.id ?? null)
|
||||
const {
|
||||
data: selectedContext,
|
||||
error: selectedContextError,
|
||||
isPending: selectedContextPending,
|
||||
isFetching: selectedContextFetching,
|
||||
refetch: refetchSelectedContext,
|
||||
} = useQuery({
|
||||
queryKey: computed(() => sharedInstanceContextQueryKey(selectedInstanceId.value)),
|
||||
queryFn: () => loadSharedInstanceContext(selectedInstanceId.value),
|
||||
enabled: computed(() => selectedInstanceId.value !== null),
|
||||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
const banOwnerMutation = useMutation({
|
||||
mutationFn: (owner: SharedInstanceReportUser) =>
|
||||
client.sharedinstances.moderation_v1.blacklistUsers({ user_ids: [owner.id] }),
|
||||
onSuccess: async (_data, owner) => {
|
||||
const instanceId = selectedInstanceId.value
|
||||
if (instanceId) {
|
||||
queryClient.setQueryData<SharedInstanceReportDetails>(
|
||||
sharedInstanceContextQueryKey(instanceId),
|
||||
(details) =>
|
||||
details
|
||||
? {
|
||||
...details,
|
||||
quarantine: true,
|
||||
other_instances: details.other_instances.map((instance) => ({
|
||||
...instance,
|
||||
quarantine: true,
|
||||
})),
|
||||
}
|
||||
: details,
|
||||
)
|
||||
}
|
||||
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: 'Owner banned from shared instances',
|
||||
text: `${owner.username} has been banned and all of their shared instances have been quarantined.`,
|
||||
})
|
||||
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['shared-instances', 'user', userId.value] }),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['shared-instance-blacklist', 'v1', owner.id],
|
||||
}),
|
||||
])
|
||||
},
|
||||
onError: (requestError, owner) => {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: 'Failed to ban shared instance owner',
|
||||
text: getErrorMessage(requestError, `Could not ban ${owner.username} from shared instances.`),
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const isPending = computed(() => userPending.value || sharedInstancesPending.value)
|
||||
const isFetching = computed(() => userFetching.value || sharedInstancesFetching.value)
|
||||
const error = computed(() => userError.value ?? sharedInstancesError.value)
|
||||
const errorMessage = computed(() =>
|
||||
getErrorMessage(error.value, 'The shared instances service could not be reached.'),
|
||||
)
|
||||
const selectedContextErrorMessage = computed(() =>
|
||||
getErrorMessage(selectedContextError.value, 'The moderation context could not be loaded.'),
|
||||
)
|
||||
const banOwnerPending = computed(() => banOwnerMutation.isPending.value)
|
||||
|
||||
useHead({
|
||||
title: computed(() => `${user.value?.username ?? userId.value}'s shared instances - Modrinth`),
|
||||
})
|
||||
|
||||
onServerPrefetch(() => Promise.all([userSuspense(), sharedInstancesSuspense()]))
|
||||
|
||||
async function loadSharedInstances(): Promise<{
|
||||
instances: UserSharedInstance[]
|
||||
unavailableCount: number
|
||||
}> {
|
||||
const currentUserId = userId.value
|
||||
const instanceIds = [
|
||||
...new Set(await client.sharedinstances.instances_v1.getForUser(currentUserId)),
|
||||
]
|
||||
const results = await Promise.allSettled(
|
||||
instanceIds.map(async (instanceId): Promise<UserSharedInstance> => {
|
||||
const [instance, instanceUsers, latestVersion] = await Promise.all([
|
||||
client.sharedinstances.instances_v1.get(instanceId),
|
||||
client.sharedinstances.instances_v1.getUsers(instanceId),
|
||||
client.sharedinstances.instances_v1.getLatestVersion(instanceId).catch(() => null),
|
||||
])
|
||||
|
||||
return {
|
||||
id: instanceId,
|
||||
name: instance.name,
|
||||
iconUrl: instance.icon,
|
||||
quarantine: instance.quarantine,
|
||||
memberCount: instanceUsers.users.length,
|
||||
membership: instanceUsers.users.find((membership) => membership.id === currentUserId),
|
||||
latestVersion,
|
||||
}
|
||||
}),
|
||||
)
|
||||
const instances = results
|
||||
.flatMap((result) => (result.status === 'fulfilled' ? [result.value] : []))
|
||||
.sort((first, second) => first.name.localeCompare(second.name))
|
||||
const unavailableCount = results.length - instances.length
|
||||
|
||||
if (instanceIds.length > 0 && instances.length === 0) {
|
||||
throw new Error("None of this user's shared instances could be loaded.")
|
||||
}
|
||||
|
||||
return { instances, unavailableCount }
|
||||
}
|
||||
|
||||
function sharedInstanceContextQueryKey(instanceId: string | null) {
|
||||
return ['shared-instance', 'moderation-context', 'v1', instanceId] as const
|
||||
}
|
||||
|
||||
function openInstanceContext(instance: UserSharedInstance) {
|
||||
selectedInstance.value = instance
|
||||
contextModal.value?.show()
|
||||
}
|
||||
|
||||
async function loadSharedInstanceContext(
|
||||
instanceId: string | null,
|
||||
): Promise<SharedInstanceReportDetails> {
|
||||
if (!instanceId) throw new Error('No shared instance was selected.')
|
||||
|
||||
const [instance, instanceUsers, latestVersion] = await Promise.all([
|
||||
client.sharedinstances.instances_v1.get(instanceId),
|
||||
client.sharedinstances.instances_v1.getUsers(instanceId),
|
||||
client.sharedinstances.instances_v1.getLatestVersion(instanceId),
|
||||
])
|
||||
sharedInstanceContent.cacheVersion(instanceId, latestVersion)
|
||||
|
||||
const memberIds = [...new Set(instanceUsers.users.map((membership) => membership.id))]
|
||||
const members = memberIds.length ? await client.labrinth.users_v2.getMultiple(memberIds) : []
|
||||
const membersById = new Map(members.map((member) => [member.id, member]))
|
||||
const ownerMembership = instanceUsers.users.find((membership) => membership.join_type === 'owner')
|
||||
if (!ownerMembership) throw new Error('The shared instance has no owner.')
|
||||
|
||||
const toContextUser = (membership: InstanceMembership): SharedInstanceReportUser => {
|
||||
const member = membersById.get(membership.id)
|
||||
return {
|
||||
id: membership.id,
|
||||
username: member?.username ?? membership.id,
|
||||
avatar_url: member?.avatar_url,
|
||||
joined_at: membership.joined_at,
|
||||
last_played: membership.last_played,
|
||||
join_type: membership.join_type,
|
||||
}
|
||||
}
|
||||
|
||||
const versionNumbers = Array.from(
|
||||
{ length: latestVersion.version + 1 },
|
||||
(_value, index) => latestVersion.version - index,
|
||||
)
|
||||
const [versionDetails, otherInstancesResult] = await Promise.all([
|
||||
Promise.all(
|
||||
versionNumbers.map(async (versionNumber) => {
|
||||
try {
|
||||
const version = await sharedInstanceContent.getVersion(instanceId, versionNumber)
|
||||
return {
|
||||
version: version.version,
|
||||
game_version: version.game_version,
|
||||
loader: version.loader,
|
||||
loader_version: version.loader_version,
|
||||
}
|
||||
} catch {
|
||||
return { version: versionNumber }
|
||||
}
|
||||
}),
|
||||
),
|
||||
loadOtherSharedInstances(ownerMembership.id, instanceId)
|
||||
.then((instances) => ({ instances, loaded: true }))
|
||||
.catch(() => ({ instances: [], loaded: false })),
|
||||
])
|
||||
|
||||
return {
|
||||
id: instanceId,
|
||||
name: instance.name,
|
||||
icon_url: instance.icon,
|
||||
quarantine: instance.quarantine,
|
||||
owner: toContextUser(ownerMembership),
|
||||
members: instanceUsers.users
|
||||
.filter((membership) => membership.id !== ownerMembership.id)
|
||||
.map(toContextUser),
|
||||
reported_version: versionDetails[0],
|
||||
previous_versions: versionDetails.slice(1),
|
||||
other_instances: otherInstancesResult.instances,
|
||||
other_instances_loaded: otherInstancesResult.loaded,
|
||||
}
|
||||
}
|
||||
|
||||
async function loadOtherSharedInstances(
|
||||
ownerId: string,
|
||||
selectedInstanceId: string,
|
||||
): Promise<SharedInstanceOwnerInstance[]> {
|
||||
const instanceIds = await client.sharedinstances.instances_v1.getForUser(ownerId)
|
||||
const otherInstanceIds = [...new Set(instanceIds)].filter(
|
||||
(instanceId) => instanceId !== selectedInstanceId,
|
||||
)
|
||||
const results = await Promise.allSettled(
|
||||
otherInstanceIds.map(async (instanceId): Promise<SharedInstanceOwnerInstance> => {
|
||||
const [instance, instanceUsers, latestVersion] = await Promise.all([
|
||||
client.sharedinstances.instances_v1.get(instanceId),
|
||||
client.sharedinstances.instances_v1.getUsers(instanceId),
|
||||
client.sharedinstances.instances_v1.getLatestVersion(instanceId),
|
||||
])
|
||||
sharedInstanceContent.cacheVersion(instanceId, latestVersion)
|
||||
|
||||
return {
|
||||
id: instanceId,
|
||||
name: instance.name,
|
||||
icon_url: instance.icon,
|
||||
latest_version: latestVersion.version,
|
||||
member_count: instanceUsers.users.length,
|
||||
quarantine: instance.quarantine,
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
return results
|
||||
.flatMap((result) => (result.status === 'fulfilled' ? [result.value] : []))
|
||||
.sort((first, second) => first.name.localeCompare(second.name))
|
||||
}
|
||||
|
||||
function banSharedInstanceOwner(owner: SharedInstanceReportUser) {
|
||||
banOwnerMutation.mutate(owner)
|
||||
}
|
||||
|
||||
function showSharedInstanceContentError(requestError: unknown) {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: 'Failed to load version content',
|
||||
text: getErrorMessage(
|
||||
requestError,
|
||||
'The content for this shared instance version could not be loaded.',
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
function relationLabel(membership?: InstanceMembership): string {
|
||||
if (!membership) return 'Membership unavailable'
|
||||
if (membership.join_type === 'owner') return 'Owner'
|
||||
if (membership.join_type === 'link') return 'Joined via share link'
|
||||
return 'Joined via direct invite'
|
||||
}
|
||||
|
||||
function formattedVersion(version: InstanceVersion): string {
|
||||
const loader = version.loader
|
||||
? version.loader.charAt(0).toUpperCase() + version.loader.slice(1)
|
||||
: 'Vanilla'
|
||||
const formattedLoader = version.loader_version ? `${loader} ${version.loader_version}` : loader
|
||||
return `Minecraft ${version.game_version} · ${formattedLoader}`
|
||||
}
|
||||
|
||||
function getErrorMessage(requestError: unknown, fallback: string): string {
|
||||
if (typeof requestError === 'string') return requestError
|
||||
if (!requestError || typeof requestError !== 'object') return fallback
|
||||
|
||||
const typedError = requestError as {
|
||||
message?: string
|
||||
data?: { description?: string }
|
||||
}
|
||||
return typedError.data?.description ?? typedError.message ?? fallback
|
||||
}
|
||||
|
||||
function retry() {
|
||||
void Promise.all([refetchUser(), refetchSharedInstances()])
|
||||
}
|
||||
</script>
|
||||
@@ -106,6 +106,18 @@
|
||||
{{ formatMessage(messages.acceptInvite) }}
|
||||
</Button>
|
||||
|
||||
<ButtonLink
|
||||
v-if="isStaff && moderationUserId"
|
||||
:to="`/admin/shared-instances/${moderationUserId}`"
|
||||
target="_blank"
|
||||
type="colored"
|
||||
color="orange"
|
||||
>
|
||||
<ScaleIcon />
|
||||
View shared instances
|
||||
<ExternalIcon class="size-4" />
|
||||
</ButtonLink>
|
||||
|
||||
<div class="flex w-full flex-col gap-2.5">
|
||||
<span class="pl-3 font-medium text-primary">{{
|
||||
formatMessage(messages.knowThisUser)
|
||||
@@ -124,14 +136,17 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
CircleAlertIcon,
|
||||
ExternalIcon,
|
||||
InviteBackgroundIllustration,
|
||||
LoaderCircleIcon,
|
||||
ScaleIcon,
|
||||
UserPlusIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
Admonition,
|
||||
Avatar,
|
||||
Button,
|
||||
ButtonLink,
|
||||
defineMessages,
|
||||
injectModrinthClient,
|
||||
useVIntl,
|
||||
@@ -189,6 +204,7 @@ const messages = defineMessages({
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const auth = await useAuth()
|
||||
const client = injectModrinthClient()
|
||||
const inviteId = computed(() => String(route.params.inviteId))
|
||||
const openInAppModal = useTemplateRef('openInAppModal')
|
||||
@@ -232,6 +248,11 @@ const inviterAvatar = computed(() => {
|
||||
if (!inviter.value) return null
|
||||
return inviter.value.type === 'user' ? inviter.value.avatar : inviter.value.icon
|
||||
})
|
||||
const moderationUserId = computed(() => (inviter.value?.type === 'user' ? inviter.value.id : null))
|
||||
const isStaff = computed(() => {
|
||||
const role = auth.value.user?.role
|
||||
return role === 'admin' || role === 'moderator'
|
||||
})
|
||||
|
||||
function acceptInvite() {
|
||||
if (!invite.value) return
|
||||
|
||||
@@ -89,6 +89,7 @@ import {
|
||||
AffiliateIcon,
|
||||
BadgeCheckIcon,
|
||||
BanIcon,
|
||||
BoxesIcon,
|
||||
BoxIcon,
|
||||
CalendarIcon,
|
||||
ChartIcon,
|
||||
@@ -140,6 +141,10 @@ const messages = defineMessages({
|
||||
id: 'profile.button.info',
|
||||
defaultMessage: 'View user details',
|
||||
},
|
||||
sharedInstancesButton: {
|
||||
id: 'profile.button.shared-instances',
|
||||
defaultMessage: 'View shared instances',
|
||||
},
|
||||
officialAccount: {
|
||||
id: 'profile.official-account',
|
||||
defaultMessage: 'Official Modrinth account',
|
||||
@@ -215,6 +220,7 @@ const emit = defineEmits<{
|
||||
openBilling: []
|
||||
toggleAffiliate: []
|
||||
openInfo: []
|
||||
openSharedInstances: []
|
||||
openAnalytics: []
|
||||
editUser: []
|
||||
}>()
|
||||
@@ -293,6 +299,14 @@ const moreActions = computed<OverflowMenuOption[]>(() => [
|
||||
tone: 'orange',
|
||||
shown: props.showStaffActions && props.isStaff,
|
||||
},
|
||||
{
|
||||
id: 'open-shared-instances',
|
||||
label: formatMessage(messages.sharedInstancesButton),
|
||||
icon: BoxesIcon,
|
||||
action: () => emit('openSharedInstances'),
|
||||
tone: 'orange',
|
||||
shown: props.showStaffActions && props.isStaff,
|
||||
},
|
||||
{
|
||||
id: 'open-analytics',
|
||||
label: formatMessage(messages.analyticsButton),
|
||||
|
||||
@@ -189,6 +189,7 @@
|
||||
@open-billing="openPath(`/admin/billing/${user.id}`)"
|
||||
@toggle-affiliate="toggleAffiliate"
|
||||
@open-info="openUserDetails"
|
||||
@open-shared-instances="openPath(`/admin/shared-instances/${user.id}`)"
|
||||
@open-analytics="
|
||||
openPath(`/dashboard/analytics?user=${encodeURIComponent(user.username)}`)
|
||||
"
|
||||
|
||||
@@ -3113,6 +3113,9 @@
|
||||
"profile.button.set-affiliate": {
|
||||
"defaultMessage": "Set as affiliate"
|
||||
},
|
||||
"profile.button.shared-instances": {
|
||||
"defaultMessage": "View shared instances"
|
||||
},
|
||||
"profile.button.unblock": {
|
||||
"defaultMessage": "Unblock"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user