feat: instance sharing thru shared-instances service (#6569)

* feat: implement instance share page + search_users backend call

* feat: invite players modal

* feat: use tanstack queries for friends sync across app pages

* feat: base shared instances implementation

* fix: admon style

* feat: impl instance admonitions like server panel

* fix: impl get + del usage

* feat: support modpack links

* feat: invite notif accepting

* fix: lint + fmt

* feat: impl install to play

* feat: impl usage of UpdateToPlayModal

* feat: warnings on deleting/disabling shared-instance version content

* fix: send instance name

* feat: align with backend

* feat: shared instances qa

* feat: wrong account protection

* feat: qa

* fix: smartly apply updates

* fix: install bug

* fix: 401/404 differentiation

* fix: fmt+prepr

* feat: qa

* feat: qa

* fix: signing out messes up revoke/deleted checks

* feat: qa

* fix: fmt + lint

* feat: lock content if part of shared instance

* fix: lint

* [do not merge] feat: rough invite links impl temp (#6666)

* fix: wrong cmd

* feat: invite page

* fix: server-manager DTO mismatch

* fix: drop anonymous invite link acceptance

* refactor: structured shared-instance unavailable errors

* refactor: centralise error presentations

* refactor: dedupe shared instance diff detection

* fix: logging in reqwests

* refactor: move app.vue shared instances into handler

* refactor: break up Share.vue

* refactor: split up shared instances state outside of instance index

* refactor: dedicated shared instances install/update modals + split up page

* refactor: centralized managed content

* refactor: split up install shared to own runner + shared.rs split up

* refactor: dedupe sql for instance metadata enrichmnt

* refactor: friends composable + dedupe friends logic across usages

* chore: reduced unused code

* fix: align with backend

* fix: lint

* fix: file sha changes

* fix: invite links not working due to icon signed

* feat: qa

* feat: reporting frontend dummy

* fix: try use header

* remove: file hash field

* fix: pin box

* feat: malware warning for shared instances

* fix: cache rule

* feat: config files syncing

* feat: disable config sharing

* fix: header

* fix: use mark ready

* fix: dont cause push update for configs

* fix: lint

* feat: sharing page in settings

* feat: move config + change flow

* fix: qa

* fix: lint prepr

* feat: proxy file upload thru shared instances backend

* fix: use collapisible

* fix: push config

* fix: config

* feat: swap out sign in modal for new one

* fix: report flow

* fix: exclude configs.zip from external warnings

* fix: nuxi init

* fix: config bundle downloading

* fix: error notif

* fix: polling

* fix: qa

* fix: lint + prepr

* feat: shared instances moderation frontend + hook up report flow

* fix: report copy

* fix: lint

* fix: lint

* fix: modrinth ids being undefined

* feat: instance quarantining

* fix: prepr + fmt

* fix: quarantined -> locked terminology

* fix: missing endpoint impls + fmt

* fix: missing api in build.rs

* fix: share tab jittery

* fix: fmt

*PT bug

* fix: invites count as users even if pending

* fix: prepr

* fix: invite page owner in users list

* fix: lint

* fix: qa

* fix: lint

* fix: members stale not clearing

* fix: invite use joined_at field

* fix: lint

* fix: qa

---------

Co-authored-by: sychic <47618543+Sychic@users.noreply.github.com>
This commit is contained in:
Calum H.
2026-07-24 13:06:38 +00:00
committed by GitHub
co-authored by sychic
parent 2e0d797bb0
commit e58af98f21
269 changed files with 18202 additions and 2579 deletions
+1
View File
@@ -1,4 +1,5 @@
BASE_URL=http://127.0.0.1:8000/v2/
BROWSER_BASE_URL=http://127.0.0.1:8000/v2/
PYRO_BASE_URL=https://staging-archon.modrinth.com
SHARED_INSTANCES_API_BASE_URL=https://staging-shared-instances.modrinth.com
PROD_OVERRIDE=true
+1
View File
@@ -1,4 +1,5 @@
BASE_URL=https://api.modrinth.com/v2/
BROWSER_BASE_URL=https://api.modrinth.com/v2/
PYRO_BASE_URL=https://archon.modrinth.com
SHARED_INSTANCES_API_BASE_URL=https://shared-instances.modrinth.com
PROD_OVERRIDE=true
+1
View File
@@ -1,4 +1,5 @@
BASE_URL=https://staging-api.modrinth.com/v2/
BROWSER_BASE_URL=https://staging-api.modrinth.com/v2/
PYRO_BASE_URL=https://staging-archon.modrinth.com
SHARED_INSTANCES_API_BASE_URL=https://staging-shared-instances.modrinth.com
PROD_OVERRIDE=true
+1
View File
@@ -1,4 +1,5 @@
BASE_URL=https://api.modrinth.com/v2/
BROWSER_BASE_URL=https://api.modrinth.com/v2/
PYRO_BASE_URL=https://staging-archon.modrinth.com
SHARED_INSTANCES_API_BASE_URL=https://shared-instances.modrinth.com
PROD_OVERRIDE=true
+12
View File
@@ -7,6 +7,7 @@ import svgLoader from 'vite-svg-loader'
import { GenericModrinthClient, type Labrinth } from '../../packages/api-client/src/index.ts'
const STAGING_API_URL = 'https://staging-api.modrinth.com/v2/'
const STAGING_SHARED_INSTANCES_API_URL = 'https://staging-shared-instances.modrinth.com'
const API_CLIENT_SOURCE = fileURLToPath(
new URL('../../packages/api-client/src/index.ts', import.meta.url),
)
@@ -210,6 +211,7 @@ export default defineNuxtConfig({
// @ts-ignore
rateLimitKey: process.env.RATE_LIMIT_IGNORE_KEY ?? globalThis.RATE_LIMIT_IGNORE_KEY,
pyroBaseUrl: process.env.PYRO_BASE_URL,
sharedInstancesBaseUrl: getSharedInstancesApiUrl(),
intercomIdentitySecret:
process.env.INTERCOM_IDENTITY_SECRET ??
// @ts-ignore
@@ -217,6 +219,7 @@ export default defineNuxtConfig({
public: {
apiBaseUrl: getApiUrl(),
pyroBaseUrl: process.env.PYRO_BASE_URL,
sharedInstancesBaseUrl: getSharedInstancesApiUrl(),
siteUrl: getDomain(),
intercomAppId:
process.env.INTERCOM_APP_ID ||
@@ -354,6 +357,15 @@ function getApiUrl() {
return process.env.BROWSER_BASE_URL ?? globalThis.BROWSER_BASE_URL ?? STAGING_API_URL
}
function getSharedInstancesApiUrl() {
return (
process.env.SHARED_INSTANCES_API_BASE_URL ??
// @ts-ignore
globalThis.SHARED_INSTANCES_API_BASE_URL ??
STAGING_SHARED_INSTANCES_API_URL
)
}
function isProduction() {
return process.env.NODE_ENV === 'production'
}
@@ -0,0 +1,274 @@
<template>
<div v-if="open" class="fixed inset-0 z-[100] flex items-center justify-center">
<div
:class="visible ? 'opacity-100' : 'opacity-0'"
class="fullscreen-overlay fixed inset-0 cursor-pointer backdrop-blur-xl transition-opacity duration-300 ease-out"
@click="hide"
/>
<div
class="relative z-[1] max-h-screen w-full max-w-[28rem] overflow-y-auto px-4 py-10 transition-all duration-300 ease-out"
:class="visible ? 'scale-100 opacity-100' : 'scale-95 opacity-0'"
>
<div class="flex w-full flex-col items-center gap-6">
<div class="flex w-full flex-col gap-6">
<div
v-if="countdown > 0"
class="relative mx-auto flex size-[120px] items-center justify-center"
>
<svg class="absolute size-full -rotate-90" viewBox="0 0 100 100" aria-hidden="true">
<circle
class="stroke-surface-4"
cx="50"
cy="50"
r="45"
fill="none"
stroke-width="6"
/>
<circle
class="stroke-brand transition-[stroke-dashoffset] duration-[50ms] ease-linear"
cx="50"
cy="50"
r="45"
fill="none"
stroke-width="6"
:stroke-dasharray="circumference"
:stroke-dashoffset="strokeDashoffset"
stroke-linecap="round"
/>
</svg>
<span class="z-[1] text-5xl font-bold text-contrast">{{ countdown }}</span>
</div>
<h2 class="m-0 text-center text-3xl font-bold text-contrast">
{{ formatMessage(messages.openingApp) }}
</h2>
<div
class="flex flex-col items-center gap-4 rounded-3xl border border-solid border-surface-5 bg-surface-3 p-6"
>
<div class="flex w-full items-center gap-3 rounded-xl bg-surface-2 p-3">
<Avatar :src="instance.icon" :alt="instance.name" size="48px" />
<div class="flex min-w-0 flex-col gap-1">
<span class="truncate font-semibold text-contrast">{{ instance.name }}</span>
<span class="flex items-center gap-1.5 text-sm font-medium leading-4 text-primary">
<span>{{ formatMessage(messages.managedBy) }}</span>
<Avatar
:src="instance.inviterAvatar"
:alt="instance.inviterName"
size="16px"
circle
no-shadow
/>
<span>{{ instance.inviterName }}</span>
</span>
</div>
</div>
<div class="flex flex-col gap-3 text-left">
<span class="font-semibold text-contrast">{{
formatMessage(messages.whyUseApp)
}}</span>
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2 text-base">
<div
class="flex size-5 shrink-0 items-center justify-center rounded-full border border-solid border-brand bg-brand-highlight text-brand"
>
<CheckIcon />
</div>
<span>{{ formatMessage(messages.benefitJoin) }}</span>
</div>
<div class="flex items-center gap-2 text-base">
<div
class="flex size-5 shrink-0 items-center justify-center rounded-full border border-solid border-brand bg-brand-highlight text-brand"
>
<CheckIcon />
</div>
<span>{{ formatMessage(messages.benefitInstall) }}</span>
</div>
<div class="flex items-center gap-2 text-base">
<div
class="flex size-5 shrink-0 items-center justify-center rounded-full border border-solid border-brand bg-brand-highlight text-brand"
>
<CheckIcon />
</div>
<span>{{ formatMessage(messages.benefitUpdate) }}</span>
</div>
</div>
</div>
</div>
</div>
<span v-if="countdown > 0" class="text-center text-secondary">{{
formatMessage(messages.openingAutomatically)
}}</span>
<div v-else class="grid w-full grid-cols-1 gap-2 sm:grid-cols-2">
<ButtonStyled>
<button class="flex-1" type="button" @click="hide">
<XIcon />
{{ formatMessage(commonMessages.closeButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<a
class="flex-1"
href="https://modrinth.com/app"
target="_blank"
rel="noopener noreferrer"
>
<DownloadIcon />
{{ formatMessage(messages.getApp) }}
</a>
</ButtonStyled>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { CheckIcon, DownloadIcon, XIcon } from '@modrinth/assets'
import { Avatar, ButtonStyled, commonMessages, defineMessages, useVIntl } from '@modrinth/ui'
import { computed, nextTick, onUnmounted, ref } from 'vue'
const { formatMessage } = useVIntl()
const messages = defineMessages({
openingApp: {
id: 'modal.shared-instance.open-in-app.title',
defaultMessage: 'Opening Modrinth App',
},
managedBy: {
id: 'modal.shared-instance.open-in-app.managed-by',
defaultMessage: 'Managed by',
},
whyUseApp: {
id: 'modal.shared-instance.open-in-app.why-use',
defaultMessage: 'Why use the Modrinth App',
},
benefitJoin: {
id: 'modal.shared-instance.open-in-app.benefit.join',
defaultMessage: 'Join the shared instance in one click',
},
benefitInstall: {
id: 'modal.shared-instance.open-in-app.benefit.install',
defaultMessage: 'Automatically install the required game and loader',
},
benefitUpdate: {
id: 'modal.shared-instance.open-in-app.benefit.update',
defaultMessage: 'Keep shared content updated when the instance changes',
},
openingAutomatically: {
id: 'modal.shared-instance.open-in-app.opening-automatically',
defaultMessage: 'The Modrinth App will open automatically...',
},
getApp: {
id: 'modal.shared-instance.open-in-app.get-app',
defaultMessage: 'Get Modrinth App',
},
})
export interface SharedInstanceInviteModalData {
inviteId: string
name: string
icon?: string | null
inviterName: string
inviterAvatar?: string | null
}
const open = ref(false)
const visible = ref(false)
const countdown = ref(3)
const countdownProgress = ref(1)
const instance = ref<SharedInstanceInviteModalData>({
inviteId: '',
name: '',
icon: null,
inviterName: '',
inviterAvatar: null,
})
let countdownInterval: ReturnType<typeof setInterval> | null = null
let progressInterval: ReturnType<typeof setInterval> | null = null
let showTimeout: ReturnType<typeof setTimeout> | null = null
let hideTimeout: ReturnType<typeof setTimeout> | null = null
const circumference = 2 * Math.PI * 45
const strokeDashoffset = computed(() => circumference * (1 - countdownProgress.value))
const appLink = computed(() => `modrinth://share/${encodeURIComponent(instance.value.inviteId)}`)
function startCountdown() {
countdown.value = 3
countdownProgress.value = 1
const totalDuration = 3000
const progressUpdateInterval = 16
const progressDecrement = progressUpdateInterval / totalDuration
progressInterval = setInterval(() => {
countdownProgress.value = Math.max(0, countdownProgress.value - progressDecrement)
}, progressUpdateInterval)
countdownInterval = setInterval(() => {
countdown.value--
if (countdown.value <= 0) stopCountdown()
}, 1000)
}
function stopCountdown() {
if (countdownInterval) {
clearInterval(countdownInterval)
countdownInterval = null
}
if (progressInterval) {
clearInterval(progressInterval)
progressInterval = null
}
}
async function show(options: { instance: SharedInstanceInviteModalData }) {
instance.value = options.instance
open.value = true
document.body.style.overflow = 'hidden'
window.addEventListener('keydown', handleKeyDown)
await nextTick()
window.open(appLink.value, '_self')
showTimeout = setTimeout(() => {
visible.value = true
startCountdown()
}, 50)
}
function hide() {
visible.value = false
document.body.style.overflow = ''
window.removeEventListener('keydown', handleKeyDown)
stopCountdown()
if (showTimeout) clearTimeout(showTimeout)
hideTimeout = setTimeout(() => {
open.value = false
}, 300)
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') hide()
}
onUnmounted(() => {
document.body.style.overflow = ''
window.removeEventListener('keydown', handleKeyDown)
stopCountdown()
if (showTimeout) clearTimeout(showTimeout)
if (hideTimeout) clearTimeout(hideTimeout)
})
defineExpose({ show, hide, open })
</script>
<style scoped>
.fullscreen-overlay {
background: linear-gradient(to bottom, rgb(66 131 92 / 23%) 0%, rgb(17 35 43 / 40%) 97%);
}
</style>
@@ -89,14 +89,18 @@
</div>
<div v-else class="flex flex-col gap-1.5">
<div class="flex items-center gap-2">
<div class="flex flex-wrap items-center gap-2">
<NuxtLink
v-if="report.item_type !== 'shared-instance'"
:to="reportItemUrl"
target="_blank"
class="text-base font-semibold text-contrast hover:underline"
>
{{ reportItemTitle }}
</NuxtLink>
<span v-else class="text-base font-semibold text-contrast">
{{ reportItemTitle }}
</span>
<div
v-if="report.project?.project_type"
@@ -118,6 +122,20 @@
>
{{ report.version.files.find((f) => f.primary)?.filename || 'Unknown Version' }}
</span>
<span
v-if="report.item_type === 'shared-instance' && report.shared_instance_version_id"
class="text-sm text-secondary"
>
Version {{ report.shared_instance_version_id }}
</span>
<CopyCode v-if="report.item_type === 'shared-instance'" :text="report.item_id" />
<span
v-if="report.item_type === 'shared-instance' && sharedInstanceQuarantined"
class="bg-orange-highlight inline-flex items-center gap-1 rounded-full border border-solid border-orange px-2.5 py-1 text-sm font-semibold text-orange"
>
<LockIcon class="size-4" />
Quarantined
</span>
</div>
<div v-if="report.target" class="flex items-center gap-1">
@@ -135,6 +153,24 @@
{{ report.target.name }}
</NuxtLink>
</div>
<div
v-else-if="report.item_type === 'shared-instance' && sharedInstanceDetails"
class="flex items-center gap-1"
>
<Avatar
:src="sharedInstanceDetails.owner.avatar_url"
size="1.5rem"
circle
class="border border-surface-5 bg-surface-4 !shadow-none"
/>
<NuxtLink
:to="`/user/${sharedInstanceDetails.owner.username}`"
target="_blank"
class="text-sm font-medium text-secondary hover:underline"
>
{{ sharedInstanceDetails.owner.username }}
</NuxtLink>
</div>
</div>
</div>
</div>
@@ -154,6 +190,39 @@
:closed="reportClosed"
@update-thread="updateThread"
>
<template #afterMessages>
<template v-if="report.item_type === 'shared-instance'">
<div
v-if="sharedInstanceLoading"
class="flex items-center gap-2 border-0 border-t border-solid border-surface-4 px-4 py-6 text-secondary"
>
<LoaderCircleIcon class="size-5 animate-spin" />
Loading shared instance details
</div>
<div
v-else-if="sharedInstanceError"
class="flex flex-col items-start gap-3 border-0 border-t border-solid border-surface-4 px-4 py-6"
>
<div class="flex flex-col gap-1">
<span class="font-semibold text-contrast">
Shared instance details could not be loaded
</span>
<span class="text-sm text-secondary">{{ sharedInstanceError }}</span>
</div>
<ButtonStyled type="outlined">
<button @click="loadSharedInstanceDetails">Try again</button>
</ButtonStyled>
</div>
<SharedInstanceReportContext
v-else-if="sharedInstanceDetails"
:details="sharedInstanceDetails"
:ban-pending="sharedInstanceBanPending"
:load-version-content="loadSharedInstanceVersionContent"
@ban-owner="banSharedInstanceOwner"
@content-error="showSharedInstanceContentError"
/>
</template>
</template>
<template #closedActions>
<ButtonStyled v-if="isStaff(auth.user)" color="green">
<button class="mt-2 w-full gap-2 sm:w-auto" @click="reopenReport()">
@@ -184,29 +253,55 @@
</div>
</template>
<script setup lang="ts">
import { CheckCircleIcon, ClipboardCopyIcon, ExternalIcon } from '@modrinth/assets'
import type { Labrinth, SharedInstances } from '@modrinth/api-client'
import {
CheckCircleIcon,
ClipboardCopyIcon,
ExternalIcon,
LoaderCircleIcon,
LockIcon,
} from '@modrinth/assets'
import { type ExtendedReport, reportQuickReplies } from '@modrinth/moderation'
import {
Avatar,
ButtonStyled,
CollapsibleRegion,
type ContentItem,
CopyCode,
getProjectTypeIcon,
injectModrinthClient,
injectNotificationManager,
useFormatDateTime,
useRelativeTime,
} from '@modrinth/ui'
import { formatProjectType } from '@modrinth/utils'
import { computed } from 'vue'
import { computed, ref, watch } from 'vue'
import { isStaff } from '~/helpers/users.js'
import ThreadView from '../thread/ThreadView.vue'
import SharedInstanceReportContext, {
type SharedInstanceOwnerInstance,
type SharedInstanceReportDetails,
type SharedInstanceReportUser,
} from './SharedInstanceReportContext.vue'
const { addNotification } = injectNotificationManager()
const client = injectModrinthClient()
const auth = await useAuth()
type SharedInstanceVersionDependency = Labrinth.Versions.v2.Dependency & {
project_id?: string
version_id?: string
}
const props = defineProps<{
report: ExtendedReport
sharedInstanceDetailsLoader?: () => Promise<SharedInstanceReportDetails>
sharedInstanceVersionContentLoader?: (
instanceId: string,
version: number,
) => Promise<ContentItem[]>
}>()
const reportThread = ref<{
@@ -214,11 +309,25 @@ const reportThread = ref<{
sendReply: (privateMessage?: boolean) => Promise<void>
} | null>(null)
const isThreadCollapsed = ref(true)
const sharedInstanceDetails = ref<SharedInstanceReportDetails | null>(null)
const sharedInstanceLoading = ref(false)
const sharedInstanceError = ref<string | null>(null)
const sharedInstanceBanPending = ref(false)
const sharedInstanceVersions = new Map<string, SharedInstances.Instances.v1.InstanceVersion>()
let sharedInstanceDetailsRequest: Promise<void> | null = null
watch(isThreadCollapsed, (collapsed) => {
if (!collapsed) void loadSharedInstanceDetails()
})
const didCloseReport = ref(false)
const reportClosed = computed(() => {
return didCloseReport.value || props.report.closed
})
const sharedInstanceQuarantined = computed(
() =>
sharedInstanceDetails.value?.quarantine ?? props.report.shared_instance?.quarantine ?? false,
)
const threadWithReportBody = computed(() => {
if (!props.report.thread) return null
@@ -322,6 +431,299 @@ function updateThread(newThread: any) {
}
}
async function getSharedInstanceVersion(
instanceId: string,
versionNumber: number,
): Promise<SharedInstances.Instances.v1.InstanceVersion> {
const cacheKey = `${instanceId}:${versionNumber}`
const cachedVersion = sharedInstanceVersions.get(cacheKey)
if (cachedVersion) return cachedVersion
const instanceVersion = await client.sharedinstances.instances_v1.getVersion(
instanceId,
versionNumber,
)
sharedInstanceVersions.set(cacheKey, instanceVersion)
return instanceVersion
}
async function getOtherSharedInstances(ownerId: string): Promise<SharedInstanceOwnerInstance[]> {
const instanceIds = await client.sharedinstances.instances_v1.getForUser(ownerId)
const otherInstanceIds = [...new Set(instanceIds)].filter(
(instanceId) => instanceId !== props.report.item_id,
)
const results = await Promise.allSettled(
otherInstanceIds.map(async (instanceId) => {
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),
])
sharedInstanceVersions.set(`${instanceId}:${latestVersion.version}`, 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((a, b) => a.name.localeCompare(b.name))
}
async function loadSharedInstanceDetails() {
if (props.report.item_type !== 'shared-instance' || sharedInstanceDetails.value) return
if (sharedInstanceDetailsRequest) return sharedInstanceDetailsRequest
sharedInstanceDetailsRequest = (async () => {
sharedInstanceLoading.value = true
sharedInstanceError.value = null
try {
if (props.sharedInstanceDetailsLoader) {
sharedInstanceDetails.value = await props.sharedInstanceDetailsLoader()
return
}
const [instance, instanceUsers] = await Promise.all([
props.report.shared_instance ??
client.sharedinstances.instances_v1.get(props.report.item_id),
client.sharedinstances.instances_v1.getUsers(props.report.item_id),
])
const userIds = [...new Set(instanceUsers.users.map((user) => user.id))]
const users = userIds.length ? await client.labrinth.users_v2.getMultiple(userIds) : []
const usersById = new Map(users.map((user) => [user.id, user]))
const ownerMembership = instanceUsers.users.find((user) => user.join_type === 'owner')
if (!ownerMembership) {
throw new Error('The shared instance has no owner.')
}
const toReportUser = (
membership: (typeof instanceUsers.users)[number],
): SharedInstanceReportUser => {
const user = usersById.get(membership.id)
return {
id: membership.id,
username: user?.username ?? membership.id,
avatar_url: user?.avatar_url,
joined_at: membership.joined_at,
last_played: membership.last_played,
join_type: membership.join_type,
}
}
const reportedVersion = props.report.shared_instance_version_id
const versionNumbers = reportedVersion
? Array.from({ length: reportedVersion }, (_, index) => reportedVersion - index)
: []
const [versionDetails, otherInstancesResult] = await Promise.all([
Promise.all(
versionNumbers.map(async (versionNumber) => {
try {
const version = await getSharedInstanceVersion(props.report.item_id, versionNumber)
return {
version: version.version,
game_version: version.game_version,
loader: version.loader,
loader_version: version.loader_version,
}
} catch {
return { version: versionNumber }
}
}),
),
getOtherSharedInstances(ownerMembership.id)
.then((instances) => ({ instances, loaded: true }))
.catch(() => ({ instances: [], loaded: false })),
])
sharedInstanceDetails.value = {
id: props.report.item_id,
name: instance.name,
icon_url: instance.icon,
quarantine: instance.quarantine,
owner: toReportUser(ownerMembership),
members: instanceUsers.users
.filter((user) => user.id !== ownerMembership.id)
.map(toReportUser),
reported_version: versionDetails[0],
previous_versions: versionDetails.slice(1),
other_instances: otherInstancesResult.instances,
other_instances_loaded: otherInstancesResult.loaded,
}
} catch (error) {
sharedInstanceError.value = getErrorMessage(error, 'Failed to load shared instance details.')
} finally {
sharedInstanceLoading.value = false
sharedInstanceDetailsRequest = null
}
})()
return sharedInstanceDetailsRequest
}
async function loadSharedInstanceVersionContent(
instanceId: string,
versionNumber: number,
): Promise<ContentItem[]> {
if (props.report.item_type !== 'shared-instance') return []
if (props.sharedInstanceVersionContentLoader) {
return props.sharedInstanceVersionContentLoader(instanceId, versionNumber)
}
const instanceVersion = await getSharedInstanceVersion(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]),
]
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 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'
return sharedInstanceContentItem(
version,
project,
fileName,
dependency.project_id ?? fileName,
!project && !version,
)
})
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]
}
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,
},
}
: {}),
}
}
function showSharedInstanceContentError(error: unknown) {
addNotification({
type: 'error',
title: 'Failed to load version content',
text: getErrorMessage(
error,
'The content for this shared instance version could not be loaded.',
),
})
}
function getErrorMessage(error: unknown, fallback: string) {
if (typeof error === 'string') return error
if (!error || typeof error !== 'object') return fallback
const requestError = error as {
message?: string
data?: {
description?: string
}
}
return requestError.data?.description ?? requestError.message ?? fallback
}
const reportItemAvatarUrl = computed(() => {
switch (props.report.item_type) {
case 'project':
@@ -329,6 +731,8 @@ const reportItemAvatarUrl = computed(() => {
return props.report.project?.icon_url || ''
case 'user':
return props.report.user?.avatar_url || ''
case 'shared-instance':
return sharedInstanceDetails.value?.icon_url || props.report.shared_instance?.icon || ''
default:
return undefined
}
@@ -336,6 +740,11 @@ const reportItemAvatarUrl = computed(() => {
const reportItemTitle = computed(() => {
if (props.report.item_type === 'user') return props.report.user?.username || 'Unknown User'
if (props.report.item_type === 'shared-instance') {
return (
sharedInstanceDetails.value?.name || props.report.shared_instance?.name || 'Shared instance'
)
}
return props.report.project?.title || 'Unknown Project'
})
@@ -348,6 +757,8 @@ const reportItemUrl = computed(() => {
return `/${props.report.project?.project_type}/${props.report.project?.slug}`
case 'version':
return `/${props.report.project?.project_type}/${props.report.project?.slug}/version/${props.report.version?.id}`
case 'shared-instance':
return ''
default:
return `/${props.report.item_type}/${props.report.id}`
}
@@ -370,4 +781,40 @@ function copyId() {
})
})
}
async function banSharedInstanceOwner(owner: SharedInstanceReportUser) {
if (sharedInstanceBanPending.value) return
sharedInstanceBanPending.value = true
try {
await client.sharedinstances.moderation_v1.blacklistUsers({
user_ids: [owner.id],
})
if (sharedInstanceDetails.value) {
sharedInstanceDetails.value = {
...sharedInstanceDetails.value,
quarantine: true,
other_instances: sharedInstanceDetails.value.other_instances.map((instance) => ({
...instance,
quarantine: true,
})),
}
}
addNotification({
type: 'success',
title: 'Owner banned from shared instances',
text: `${owner.username} has been banned and all of their shared instances have been quarantined.`,
})
} catch (error) {
addNotification({
type: 'error',
title: 'Failed to ban shared instance owner',
text: getErrorMessage(error, `Could not ban ${owner.username} from shared instances.`),
})
} finally {
sharedInstanceBanPending.value = false
}
}
</script>
@@ -0,0 +1,442 @@
<script setup lang="ts">
import {
BanIcon,
BoxesIcon,
BoxIcon,
EyeIcon,
HistoryIcon,
LinkIcon,
LockIcon,
UserPlusIcon,
UsersIcon,
VersionIcon,
} from '@modrinth/assets'
import {
Avatar,
ButtonStyled,
ConfirmLeaveModal,
type ContentItem,
injectModrinthClient,
ModpackContentModal,
Table,
type TableColumn,
useFormatDateTime,
useRelativeTime,
} from '@modrinth/ui'
import { useQuery } from '@tanstack/vue-query'
import { computed, ref } from 'vue'
export interface SharedInstanceReportUser {
id: string
username: string
avatar_url?: string | null
joined_at?: string | null
last_played?: string | null
join_type?: 'owner' | 'invite' | 'link'
}
export interface SharedInstanceReportVersion {
version: number
game_version?: string
loader?: string
loader_version?: string
created?: string
}
export interface SharedInstanceOwnerInstance {
id: string
name: string
icon_url?: string | null
latest_version: number
member_count: number
quarantine: boolean
}
export interface SharedInstanceReportDetails {
id: string
name: string
icon_url?: string | null
quarantine: boolean
owner: SharedInstanceReportUser
members: SharedInstanceReportUser[]
reported_version?: SharedInstanceReportVersion
previous_versions: SharedInstanceReportVersion[]
other_instances: SharedInstanceOwnerInstance[]
other_instances_loaded?: boolean
}
const props = defineProps<{
details: SharedInstanceReportDetails
banPending?: boolean
loadVersionContent?: (instanceId: string, version: number) => Promise<ContentItem[]>
}>()
const emit = defineEmits<{
banOwner: [owner: SharedInstanceReportUser]
contentError: [error: unknown]
}>()
const contentModal = ref<InstanceType<typeof ModpackContentModal> | null>(null)
const banModal = ref<InstanceType<typeof ConfirmLeaveModal> | null>(null)
const client = injectModrinthClient()
const contentByVersion = new Map<string, ContentItem[]>()
const contentInstance = ref<Pick<SharedInstanceOwnerInstance, 'id' | 'name' | 'icon_url'>>({
id: props.details.id,
name: props.details.name,
icon_url: props.details.icon_url,
})
const formatRelativeTime = useRelativeTime({ style: 'narrow' })
const formatDateTime = useFormatDateTime({ dateStyle: 'medium', timeStyle: 'short' })
type MemberTableColumn = 'username' | 'lastPlayed' | 'joined' | 'method'
type MemberTableRow = {
id: string
username: string
avatarUrl?: string | null
lastPlayed: string | null
joined: string | null
method: 'owner' | 'invite' | 'link'
}
const memberColumns: TableColumn<MemberTableColumn>[] = [
{
key: 'username',
label: 'Username',
width: 'clamp(14rem, 30%, 26rem)',
},
{
key: 'lastPlayed',
label: 'Last played',
width: 'clamp(7rem, 15%, 13rem)',
headerClass: 'whitespace-nowrap !px-2',
cellClass: 'whitespace-nowrap !px-2',
},
{
key: 'joined',
label: 'Joined',
width: 'clamp(7rem, 14%, 12rem)',
headerClass: 'whitespace-nowrap !px-2',
cellClass: 'whitespace-nowrap !px-2',
},
{
key: 'method',
label: 'Method',
headerClass: 'whitespace-nowrap !px-2',
cellClass: 'whitespace-nowrap !px-2',
},
]
const allVersions = computed(() => [
...(props.details.reported_version ? [props.details.reported_version] : []),
...props.details.previous_versions,
])
const { data: ownerBlacklistStatus, isFetching: ownerBlacklistStatusFetching } = useQuery({
queryKey: computed(() => ['shared-instance-blacklist', 'v1', props.details.owner.id]),
queryFn: () => client.sharedinstances.users_v1.getBlacklistStatus(props.details.owner.id),
retry: false,
})
const banDisabled = computed(
() =>
!!props.banPending ||
props.details.quarantine ||
ownerBlacklistStatusFetching.value ||
ownerBlacklistStatus.value?.blacklisted === true,
)
const banButtonLabel = computed(() => {
if (props.banPending) return 'Banning owner…'
if (ownerBlacklistStatus.value?.blacklisted) return 'Owner already banned'
if (props.details.quarantine) return 'Instance already quarantined'
return 'Ban from shared instances'
})
const memberRows = computed<MemberTableRow[]>(() =>
[props.details.owner, ...props.details.members.filter((user) => user.joined_at != null)].map(
(user) => ({
id: user.id,
username: user.username,
avatarUrl: user.avatar_url,
lastPlayed: user.last_played ?? null,
joined: user.joined_at ?? null,
method: user.join_type ?? 'invite',
}),
),
)
async function promptBanOwner() {
if (banDisabled.value || !(await banModal.value?.prompt())) return
emit('banOwner', props.details.owner)
}
async function viewVersionContent(
instance: Pick<SharedInstanceOwnerInstance, 'id' | 'name' | 'icon_url'>,
version: number,
) {
contentInstance.value = instance
contentModal.value?.showLoading()
try {
const cacheKey = `${instance.id}:${version}`
let content = contentByVersion.get(cacheKey)
if (!content) {
content = props.loadVersionContent ? await props.loadVersionContent(instance.id, version) : []
contentByVersion.set(cacheKey, content)
}
contentModal.value?.setItems(content)
} catch (error) {
contentModal.value?.hide()
emit('contentError', error)
}
}
function viewReportedInstanceVersion(version: SharedInstanceReportVersion) {
return viewVersionContent(
{
id: props.details.id,
name: props.details.name,
icon_url: props.details.icon_url,
},
version.version,
)
}
function formattedLoader(version: SharedInstanceReportVersion) {
const loader = version.loader
? version.loader.charAt(0).toUpperCase() + version.loader.slice(1)
: 'Vanilla'
return version.loader_version ? `${loader} ${version.loader_version}` : loader
}
</script>
<template>
<section
class="shared-instance-message relative grid items-center gap-x-[var(--spacing-card-sm)] gap-y-[var(--spacing-card-xs)] break-words border-0 border-t border-solid border-surface-4 px-4 py-3"
>
<div
class="shared-instance-message__icon backed-svg circle raised mb-auto border border-solid border-surface-5 shadow-[var(--shadow-card)]"
style="--size: 2rem"
>
<BoxesIcon />
</div>
<div
class="shared-instance-message__author flex shrink-0 flex-wrap items-center gap-[var(--spacing-card-xs)] font-bold text-[var(--color-text)]"
>
<span>Shared instance details</span>
<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
</span>
</div>
<div
class="shared-instance-message__body overflow-hidden rounded-2xl border border-solid border-surface-4 bg-surface-3"
>
<div class="flex flex-col gap-3 p-4">
<div class="flex items-center gap-2 font-semibold text-contrast">
<UsersIcon class="size-5 text-secondary" />
Owner and members
</div>
<Table :columns="memberColumns" :data="memberRows" row-key="id" table-min-width="42rem">
<template #cell-username="{ row }">
<NuxtLink
:to="`/user/${row.username}`"
target="_blank"
class="inline-flex min-w-0 max-w-full items-center gap-2 text-primary hover:underline"
>
<Avatar
:src="row.avatarUrl"
:alt="`${row.username}'s avatar`"
:tint-by="row.id"
size="24px"
circle
no-shadow
/>
<span class="min-w-0 truncate font-medium">{{ row.username }}</span>
</NuxtLink>
</template>
<template #cell-lastPlayed="{ row }">
<span v-if="row.lastPlayed" v-tooltip="formatDateTime(row.lastPlayed)">
{{ formatRelativeTime(row.lastPlayed) }}
</span>
<span v-else>Never</span>
</template>
<template #cell-joined="{ row }">
<span v-if="row.joined" v-tooltip="formatDateTime(row.joined)">
{{ formatRelativeTime(row.joined) }}
</span>
<span v-else>Unknown</span>
</template>
<template #cell-method="{ row }">
<span class="inline-flex min-w-0 items-center gap-2">
<UsersIcon v-if="row.method === 'owner'" class="size-5 shrink-0" aria-hidden="true" />
<LinkIcon
v-else-if="row.method === 'link'"
class="size-5 shrink-0"
aria-hidden="true"
/>
<UserPlusIcon v-else class="size-5 shrink-0" aria-hidden="true" />
<span class="min-w-0 truncate">
{{
row.method === 'owner'
? 'Owner'
: row.method === 'link'
? 'Share link'
: 'Direct invite'
}}
</span>
</span>
</template>
</Table>
</div>
<div class="border-0 border-t border-solid border-surface-4 p-4">
<div class="flex flex-col gap-3">
<div class="flex items-center gap-2 font-semibold text-contrast">
<HistoryIcon class="size-5 text-secondary" />
Version history
</div>
<div v-if="allVersions.length" class="grid max-h-64 gap-2 overflow-y-auto lg:grid-cols-2">
<div
v-for="version in allVersions"
:key="version.version"
class="flex flex-col gap-2 rounded-xl bg-surface-2 p-3 sm:flex-row sm:items-center sm:justify-between"
>
<div class="flex min-w-0 items-center gap-2.5">
<div class="backed-svg shrink-0">
<VersionIcon />
</div>
<div class="flex min-w-0 flex-col">
<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"
class="bg-orange-highlight rounded-full px-2 py-0.5 text-xs font-semibold text-orange"
>
Reported
</span>
</div>
<span v-if="version.game_version" class="truncate text-sm text-secondary">
Minecraft {{ version.game_version }} · {{ formattedLoader(version) }}
</span>
</div>
</div>
<ButtonStyled type="outlined">
<button class="w-full sm:w-auto" @click="viewReportedInstanceVersion(version)">
<EyeIcon class="size-4" />
View content
</button>
</ButtonStyled>
</div>
</div>
<span v-else class="text-sm text-secondary">
No version was attached to this report.
</span>
</div>
</div>
<div class="flex flex-col gap-3 border-0 border-t border-solid border-surface-4 p-4">
<div class="flex items-center gap-2 font-semibold text-contrast">
<BoxIcon class="size-5 text-secondary" />
Other instances owned by {{ details.owner.username }}
</div>
<div v-if="details.other_instances.length" class="grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
<div
v-for="instance in details.other_instances"
:key="instance.id"
class="flex min-w-0 items-center gap-2 rounded-xl bg-surface-2 p-3"
>
<Avatar :src="instance.icon_url" :tint-by="instance.id" size="2.5rem" no-shadow />
<div class="flex min-w-0 flex-1 flex-col">
<div class="flex min-w-0 items-center gap-1.5">
<span class="truncate font-semibold text-contrast">{{ instance.name }}</span>
<LockIcon
v-if="instance.quarantine"
v-tooltip="'Quarantined'"
class="size-4 shrink-0 text-orange"
/>
</div>
<span class="text-sm text-secondary">
Version {{ instance.latest_version }} · {{ instance.member_count }}
{{ instance.member_count === 1 ? 'member' : 'members' }}
</span>
</div>
<ButtonStyled circular type="transparent">
<button
v-tooltip="`View ${instance.name} version ${instance.latest_version} content`"
:aria-label="`View ${instance.name} version ${instance.latest_version} content`"
@click="viewVersionContent(instance, instance.latest_version)"
>
<EyeIcon class="size-4" />
</button>
</ButtonStyled>
</div>
</div>
<span v-else-if="details.other_instances_loaded" class="text-sm text-secondary">
This owner has no other shared instances.
</span>
<span v-else class="text-sm text-secondary">
Other instances are unavailable from the current shared instances API.
</span>
</div>
<footer
class="flex flex-col gap-3 border-0 border-t border-solid border-surface-4 bg-surface-2 p-4 sm:flex-row sm:items-center sm:justify-between"
>
<div class="flex max-w-2xl flex-col gap-0.5">
<span class="font-semibold text-contrast">Shared instances service ban</span>
<span class="text-sm text-secondary">
Banning this owner quarantines all of their shared instances and prevents members from
launching them.
</span>
</div>
<ButtonStyled color="red">
<button :disabled="banDisabled" class="w-full gap-2 sm:w-auto" @click="promptBanOwner">
<BanIcon class="size-4" />
{{ banButtonLabel }}
</button>
</ButtonStyled>
</footer>
</div>
<ModpackContentModal
ref="contentModal"
header="Shared instance content"
:modpack-name="contentInstance.name"
:modpack-icon-url="contentInstance.icon_url ?? undefined"
/>
<ConfirmLeaveModal
ref="banModal"
title="Ban from shared instances?"
:header="`Ban ${details.owner.username} from shared instances`"
body="This permanently prevents the owner from creating or managing shared instances. All of their shared instances will be quarantined, and members will be unable to launch them."
stay-label="Cancel"
leave-label="Ban owner"
admonition-type="critical"
/>
</section>
</template>
<style scoped>
.shared-instance-message {
grid-template:
'icon author'
'icon body';
grid-template-columns: min-content minmax(0, 1fr);
grid-template-rows: min-content 1fr;
}
.shared-instance-message__icon {
grid-area: icon;
}
.shared-instance-message__author {
grid-area: author;
}
.shared-instance-message__body {
grid-area: body;
}
</style>
@@ -63,6 +63,18 @@
</nuxt-link>
</template>
</div>
<div v-else-if="report.item_type === 'shared-instance'" class="item-info">
<div class="backed-svg" :class="{ raised: raised }">
<BoxesIcon />
</div>
<div class="stacked">
<span class="title">Shared instance</span>
<span>
Version {{ report.shared_instance_version_id ?? 'unknown' }} ·
<CopyCode :text="report.item_id" />
</span>
</div>
</div>
<div v-else class="item-info">
<div class="backed-svg" :class="{ raised: raised }">
<UnknownIcon />
@@ -106,7 +118,7 @@
</template>
<script setup>
import { ReportIcon, UnknownIcon, VersionIcon } from '@modrinth/assets'
import { BoxesIcon, ReportIcon, UnknownIcon, VersionIcon } from '@modrinth/assets'
import { Avatar, Badge, CopyCode, useFormatDateTime, useRelativeTime } from '@modrinth/ui'
import { formatProjectType, renderHighlightedString } from '@modrinth/utils'
@@ -16,6 +16,7 @@
raised
@update-thread="() => updateThreadLocal()"
/>
<slot name="afterMessages" />
</div>
<div v-else class="flex flex-col items-center justify-center space-y-3 py-12">
<MessageIcon class="size-12 text-secondary" />
+1
View File
@@ -123,6 +123,7 @@ setupLoadingStateProvider()
const client = createModrinthClient(auth.value, {
apiBaseUrl: config.public.apiBaseUrl.replace('/v2/', '/'),
archonBaseUrl: config.public.pyroBaseUrl.replace('/v2/', '/'),
sharedInstancesBaseUrl: config.public.sharedInstancesBaseUrl,
rateLimitKey: config.rateLimitKey,
})
provideModrinthClient(client)
+7 -1
View File
@@ -29,7 +29,12 @@ async function getRateLimitKeyFromSecretsStore(): Promise<string | undefined> {
export function createModrinthClient(
auth: Ref<{ token: string | undefined }>,
config: { apiBaseUrl: string; archonBaseUrl: string; rateLimitKey?: string },
config: {
apiBaseUrl: string
archonBaseUrl: string
sharedInstancesBaseUrl: string
rateLimitKey?: string
},
): NuxtModrinthClient {
const flags = useFeatureFlags()
const optionalFeatures = [
@@ -40,6 +45,7 @@ export function createModrinthClient(
labrinthBaseUrl: config.apiBaseUrl,
archonBaseUrl: () =>
withStagingArchonBaseUrl(config.archonBaseUrl, flags.value.archonApiStaging),
sharedInstancesBaseUrl: config.sharedInstancesBaseUrl,
archonSentryCapture: () => flags.value.archonSentryCapture,
rateLimitKey: config.rateLimitKey || getRateLimitKeyFromSecretsStore,
features: [
+34 -12
View File
@@ -1,14 +1,6 @@
import type { Labrinth } from '@modrinth/api-client'
import type { AbstractModrinthClient, Labrinth, SharedInstances } from '@modrinth/api-client'
import type { ExtendedReport, OwnershipTarget } from '@modrinth/moderation'
import type {
Organization,
Project,
Report,
TeamMember,
Thread,
User,
Version,
} from '@modrinth/utils'
import type { Organization, Project, TeamMember, Thread, User, Version } from '@modrinth/utils'
export const useModerationCache = () => ({
threads: useState<Map<string, Thread>>('moderation-report-cache-threads', () => new Map()),
@@ -17,10 +9,17 @@ export const useModerationCache = () => ({
versions: useState<Map<string, Version>>('moderation-report-cache-versions', () => new Map()),
teams: useState<Map<string, TeamMember[]>>('moderation-report-cache-teams', () => new Map()),
orgs: useState<Map<string, Organization>>('moderation-report-cache-orgs', () => new Map()),
sharedInstances: useState<Map<string, SharedInstances.Instances.v1.Instance>>(
'moderation-report-cache-shared-instances',
() => new Map(),
),
})
// TODO: @AlexTMjugador - backend should do all of these functions.
export async function enrichReportBatch(reports: Report[]): Promise<ExtendedReport[]> {
export async function enrichReportBatch(
reports: Labrinth.Reports.v3.Report[],
client: AbstractModrinthClient,
): Promise<ExtendedReport[]> {
if (reports.length === 0) return []
const cache = useModerationCache()
@@ -41,8 +40,16 @@ export async function enrichReportBatch(reports: Report[]): Promise<ExtendedRepo
.filter((r) => r.item_type === 'project')
.map((r) => r.item_id)
.filter((id) => !cache.projects.value.has(id))
const sharedInstanceIDs = [
...new Set(
reports
.filter((r) => r.item_type === 'shared-instance')
.map((r) => r.item_id)
.filter((id) => !cache.sharedInstances.value.has(id)),
),
]
const [newThreads, newVersions, newUsers] = await Promise.all([
const [newThreads, newVersions, newUsers, newSharedInstances] = await Promise.all([
threadIDs.length > 0
? (fetchSegmented(threadIDs, (ids) => `threads?ids=${asEncodedJsonArray(ids)}`) as Promise<
Thread[]
@@ -59,11 +66,22 @@ export async function enrichReportBatch(reports: Report[]): Promise<ExtendedRepo
(ids) => `users?ids=${asEncodedJsonArray(ids)}`,
) as Promise<User[]>)
: Promise.resolve([]),
Promise.allSettled(
sharedInstanceIDs.map(async (instanceId) => ({
id: instanceId,
instance: await client.sharedinstances.instances_v1.get(instanceId),
})),
),
])
newThreads.forEach((t) => cache.threads.value.set(t.id, t))
newVersions.forEach((v) => cache.versions.value.set(v.id, v))
newUsers.forEach((u) => cache.users.value.set(u.id, u))
newSharedInstances.forEach((result) => {
if (result.status === 'fulfilled') {
cache.sharedInstances.value.set(result.value.id, result.value.instance)
}
})
const allVersions = [...newVersions, ...Array.from(cache.versions.value.values())]
const fullProjectIds = new Set([
@@ -177,6 +195,10 @@ export async function enrichReportBatch(reports: Report[]): Promise<ExtendedRepo
user: report.item_type === 'user' ? cache.users.value.get(report.item_id) : undefined,
version,
target,
shared_instance:
report.item_type === 'shared-instance'
? cache.sharedInstances.value.get(report.item_id)
: undefined,
}
})
}
@@ -2921,6 +2921,30 @@
"layout.publish.email-verification-required.title": {
"message": "Email verification required"
},
"modal.shared-instance.open-in-app.benefit.install": {
"message": "Automatically install the required game and loader"
},
"modal.shared-instance.open-in-app.benefit.join": {
"message": "Join the shared instance in one click"
},
"modal.shared-instance.open-in-app.benefit.update": {
"message": "Keep shared content updated when the instance changes"
},
"modal.shared-instance.open-in-app.get-app": {
"message": "Get Modrinth App"
},
"modal.shared-instance.open-in-app.managed-by": {
"message": "Managed by"
},
"modal.shared-instance.open-in-app.opening-automatically": {
"message": "The Modrinth App will open automatically..."
},
"modal.shared-instance.open-in-app.title": {
"message": "Opening Modrinth App"
},
"modal.shared-instance.open-in-app.why-use": {
"message": "Why use the Modrinth App"
},
"moderation.exclude-technical-review": {
"message": "Exclude TR"
},
@@ -5264,6 +5288,33 @@
"settings.sidebar.label.display": {
"message": "Display"
},
"shared-instance.invite.accept": {
"message": "Accept invite"
},
"shared-instance.invite.inviter.unknown": {
"message": "A Modrinth user"
},
"shared-instance.invite.joined-count": {
"message": "{count, plural, one {# person has} other {# people have}} already joined"
},
"shared-instance.invite.loading": {
"message": "Loading invite…"
},
"shared-instance.invite.title": {
"message": "{name} has invited you to play"
},
"shared-instance.invite.trust.description": {
"message": "This invite was created by another Modrinth user, not Modrinth. Only accept invites from people you trust."
},
"shared-instance.invite.trust.heading": {
"message": "Do you know this user?"
},
"shared-instance.invite.unavailable.description": {
"message": "It may have expired, reached its use limit, or been removed by the person who shared it."
},
"shared-instance.invite.unavailable.title": {
"message": "This invite isn't available"
},
"ui.latest-news-row.latest-news": {
"message": "Latest news from Modrinth"
},
@@ -99,6 +99,7 @@ async function getProjectMiddlewareClient() {
return createModrinthClient(auth, {
apiBaseUrl: config.public.apiBaseUrl.replace('/v2/', '/'),
archonBaseUrl: config.public.pyroBaseUrl.replace('/v2/', '/'),
sharedInstancesBaseUrl: config.public.sharedInstancesBaseUrl,
rateLimitKey: config.rateLimitKey,
})
}
@@ -154,12 +154,15 @@ const { data, isPending, error, refetch } = useQuery({
const notifications = await client.labrinth.notifications_v2.getUserNotifications(
auth.value?.user?.id,
)
const visibleNotifications = notifications.filter(
(n) => !n.type?.startsWith('shared_instance_'),
)
const typesInFeed = [
...new Set(notifications.filter((n) => showRead || !n.read).map((n) => n.type)),
...new Set(visibleNotifications.filter((n) => showRead || !n.read).map((n) => n.type)),
]
const filtered = notifications.filter(
const filtered = visibleNotifications.filter(
(n) =>
(selectedType.value === 'all' || n.type === selectedType.value) && (showRead || !n.read),
)
@@ -173,7 +176,7 @@ const { data, isPending, error, refetch } = useQuery({
notifications: notifs,
notifTypes: typesInFeed.length > 1 ? ['all', ...typesInFeed] : typesInFeed,
pages,
hasRead: notifications.some((n) => n.read),
hasRead: visibleNotifications.some((n) => n.read),
}))
},
enabled: computed(() => !!auth.value?.user?.id),
@@ -1,6 +1,5 @@
<script setup lang="ts">
import { injectModrinthClient } from '@modrinth/ui'
import type { Report } from '@modrinth/utils'
import { useQuery } from '@tanstack/vue-query'
import ModerationReportCard from '~/components/ui/moderation/ModerationReportCard.vue'
@@ -13,8 +12,8 @@ const { data: report } = useQuery({
queryKey: computed(() => ['report', reportId]),
queryFn: async () => {
try {
const report = (await client.labrinth.reports_v3.get(reportId)) as Report
const enrichedReport = (await enrichReportBatch([report]))[0]
const report = await client.labrinth.reports_v3.get(reportId)
const enrichedReport = (await enrichReportBatch([report], client))[0]
return enrichedReport
} catch (error) {
console.error('Error fetching report:', error)
@@ -177,7 +177,7 @@
<div class="flex flex-col gap-4">
<div v-if="paginatedReports.length === 0" class="universal-card h-24 animate-pulse"></div>
<ReportCard v-for="report in paginatedReports" v-else :key="report.id" :report="report" />
<ReportCard v-for="report in paginatedReports" :key="report.id" :report="report" />
</div>
<div v-if="totalPages > 1" class="mt-4 flex justify-center">
@@ -204,13 +204,13 @@ import {
type ComboboxOption,
commonMessages,
FloatingPanel,
injectModrinthClient,
MultiSelect,
type MultiSelectItem,
Pagination,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import type { Report, User } from '@modrinth/utils'
import Fuse from 'fuse.js'
import ReportCard from '~/components/ui/moderation/ModerationReportCard.vue'
@@ -222,6 +222,7 @@ const { formatMessage } = useVIntl()
const route = useRoute()
const router = useRouter()
const auth = await useAuth()
const client = injectModrinthClient()
const { data: allReports } = await useLazyAsyncData('new-moderation-reports', async () => {
const startTime = performance.now()
@@ -231,19 +232,19 @@ const { data: allReports } = await useLazyAsyncData('new-moderation-reports', as
const enrichmentPromises: Promise<ExtendedReport[]>[] = []
let reports: Report[]
let reports: Labrinth.Reports.v3.Report[]
let hasMoreReports = true
while (hasMoreReports) {
reports = (await useBaseFetch(`report?count=${REPORT_ENDPOINT_COUNT}&offset=${currentOffset}`, {
apiVersion: 3,
})) as Report[]
})) as Labrinth.Reports.v3.Report[]
hasMoreReports = reports.length > 0
if (!hasMoreReports) {
break
}
const enrichmentPromise = enrichReportBatch(reports)
const enrichmentPromise = enrichReportBatch(reports, client)
enrichmentPromises.push(enrichmentPromise)
// this is explicitly not the length of the reports array, because the API may return fewer reports due to a report in the middle not being
@@ -323,6 +324,7 @@ const projectTypeFilterTypes: ComboboxOption<string>[] = [
{ value: 'plugin', label: 'Plugins' },
{ value: 'shader', label: 'Shaders' },
{ value: 'minecraft_java_server', label: 'Servers' },
{ value: 'shared-instance', label: 'Shared instance' },
]
const currentReportTargetFilter = ref('all')
@@ -331,6 +333,7 @@ const reportTargetFilterTypes: ComboboxOption<string>[] = [
{ value: 'project', label: 'Projects' },
{ value: 'user', label: 'Users' },
{ value: 'version', label: 'Versions' },
{ value: 'shared-instance', label: 'Shared instances' },
]
const currentReportIssueFilter = ref('all')
@@ -505,7 +508,9 @@ const filteredReports = computed(() => {
}
const projectTypeFilterPredicate = (report: ExtendedReport) => {
return projectTypeFilter === 'all' || report.project?.project_type === projectTypeFilter
if (projectTypeFilter === 'all') return true
if (projectTypeFilter === 'shared-instance') return report.item_type === 'shared-instance'
return report.project?.project_type === projectTypeFilter
}
const reportTargetFilterPredicate = (report: ExtendedReport) => {
@@ -0,0 +1,262 @@
<template>
<main
class="relative isolate min-h-screen overflow-hidden bg-surface-1 px-4 pb-16 pt-36 sm:pt-52"
>
<div
class="absolute left-1/2 top-0 -z-10 h-[23.3125rem] w-full -translate-x-1/2 overflow-hidden rounded-none !rounded-b-none sm:top-10 sm:w-[min(70rem,100%)] sm:rounded-[2.375rem]"
aria-hidden="true"
>
<img
:src="InviteBackgroundIllustration"
class="absolute inset-0 size-full scale-[2] object-cover object-[center_25%]"
alt=""
/>
<img
v-if="instanceIcon"
:src="instanceIcon"
class="absolute -inset-1/2 size-[200%] object-cover opacity-90 blur-[160px] saturate-200 [mix-blend-mode:color]"
alt=""
/>
<div
class="absolute inset-0 bg-[linear-gradient(to_bottom,transparent_20%,var(--surface-1)_100%)]"
/>
</div>
<SharedInstanceInviteOpenInAppModal ref="openInAppModal" />
<div v-if="isPending" class="relative z-10 flex flex-col items-center gap-4 text-secondary">
<LoaderCircleIcon class="size-10 animate-spin" />
<span>{{ formatMessage(messages.loadingInvite) }}</span>
</div>
<div
v-else-if="error || !invite"
class="relative z-10 mx-auto flex max-w-md flex-col items-center gap-5 text-center"
>
<CircleAlertIcon class="size-12 text-secondary" />
<div class="flex flex-col gap-2">
<h1 class="m-0 text-2xl font-semibold text-contrast">
{{ formatMessage(messages.unavailableTitle) }}
</h1>
<p class="m-0 text-primary">
{{ formatMessage(messages.unavailableDescription) }}
</p>
</div>
</div>
<div
v-else
class="relative z-10 mx-auto flex w-full max-w-[26.25rem] flex-col items-center gap-7"
>
<div class="invite-avatar relative size-[120px] shrink-0 rounded-full">
<Avatar
:src="inviterAvatar"
:alt="inviterName"
size="120px"
circle
class="!border-0 !shadow-none"
/>
</div>
<div class="flex flex-col items-center gap-4 text-center">
<div class="flex flex-col items-center gap-2.5">
<h1 class="m-0 text-2xl font-semibold leading-8 text-contrast">
{{ formatMessage(messages.invitationTitle, { name: inviterName }) }}
</h1>
<div
class="flex w-max max-w-[calc(100vw-2rem)] flex-nowrap items-center justify-center gap-1.5 overflow-hidden whitespace-nowrap text-lg leading-6 text-primary"
>
<span
class="flex min-w-0 max-w-full items-center gap-1.5 whitespace-nowrap rounded-xl font-semibold text-contrast"
>
<Avatar :src="instanceIcon" :alt="invite.instance_name" size="32px" no-shadow />
<span class="min-w-0 truncate whitespace-nowrap">{{ invite.instance_name }}</span>
</span>
</div>
</div>
<div
v-if="instanceUsers.length"
class="flex items-center gap-2 rounded-full border border-solid border-surface-3 bg-surface-2 px-4 py-2 text-primary [box-shadow:0_1px_1px_rgb(0_0_0/12%)]"
>
<div class="flex shrink-0 items-center">
<Avatar
v-for="(user, index) in visibleInstanceUsers"
:key="user.id"
:src="user.avatar"
:alt="user.name"
:tint-by="user.id"
size="28px"
circle
no-shadow
class="!border-2 !border-surface-2"
:class="{ '-mr-1.5': index < visibleInstanceUsers.length - 1 || hiddenUserCount > 0 }"
/>
<div
v-if="hiddenUserCount"
class="flex size-7 items-center justify-center rounded-full border-2 border-solid border-surface-2 bg-surface-3 text-xs font-medium leading-4 text-primary"
>
+{{ hiddenUserCount }}
</div>
</div>
{{ formatMessage(messages.joinedCount, { count: instanceUsers.length }) }}
</div>
</div>
<ButtonStyled color="brand" size="large">
<button type="button" @click="acceptInvite">
<UserPlusIcon />
{{ formatMessage(messages.acceptInvite) }}
</button>
</ButtonStyled>
<div class="flex w-full flex-col gap-2.5">
<span class="pl-3 font-medium text-primary">{{
formatMessage(messages.knowThisUser)
}}</span>
<Admonition type="neutral" class="shadow-card !text-primary">
<template #icon="{ iconClass }">
<CircleAlertIcon :class="[...iconClass, '!text-orange']" />
</template>
{{ formatMessage(messages.trustWarning) }}
</Admonition>
</div>
</div>
</main>
</template>
<script setup lang="ts">
import {
CircleAlertIcon,
InviteBackgroundIllustration,
LoaderCircleIcon,
UserPlusIcon,
} from '@modrinth/assets'
import {
Admonition,
Avatar,
ButtonStyled,
defineMessages,
injectModrinthClient,
useVIntl,
} from '@modrinth/ui'
import { useQuery } from '@tanstack/vue-query'
import { computed, onServerPrefetch, useTemplateRef } from 'vue'
import SharedInstanceInviteOpenInAppModal from '~/components/ui/SharedInstanceInviteOpenInAppModal.vue'
useSeoMeta({
robots: 'noindex',
})
const { formatMessage } = useVIntl()
const messages = defineMessages({
loadingInvite: {
id: 'shared-instance.invite.loading',
defaultMessage: 'Loading invite…',
},
unavailableTitle: {
id: 'shared-instance.invite.unavailable.title',
defaultMessage: "This invite isn't available",
},
unavailableDescription: {
id: 'shared-instance.invite.unavailable.description',
defaultMessage:
'It may have expired, reached its use limit, or been removed by the person who shared it.',
},
invitationTitle: {
id: 'shared-instance.invite.title',
defaultMessage: '{name} has invited you to play',
},
unknownInviter: {
id: 'shared-instance.invite.inviter.unknown',
defaultMessage: 'A Modrinth user',
},
joinedCount: {
id: 'shared-instance.invite.joined-count',
defaultMessage: '{count, plural, one {# person has} other {# people have}} already joined',
},
acceptInvite: {
id: 'shared-instance.invite.accept',
defaultMessage: 'Accept invite',
},
knowThisUser: {
id: 'shared-instance.invite.trust.heading',
defaultMessage: 'Do you know this user?',
},
trustWarning: {
id: 'shared-instance.invite.trust.description',
defaultMessage:
'This invite was created by another Modrinth user, not Modrinth. Only accept invites from people you trust.',
},
})
const route = useRoute()
const client = injectModrinthClient()
const inviteId = computed(() => String(route.params.inviteId))
const openInAppModal = useTemplateRef('openInAppModal')
const {
data: invite,
error,
isPending,
suspense,
} = useQuery({
queryKey: computed(() => ['shared-instance-invite', inviteId.value]),
queryFn: () => client.sharedinstances.invites_v1.get(inviteId.value),
retry: false,
})
const instanceIcon = computed(() => invite.value?.instance_icon ?? null)
const instanceUsers = computed(() => {
if (!invite.value) return []
const managerIds = new Set(
invite.value.managers.flatMap((manager) => (manager.type === 'user' ? [manager.id] : [])),
)
return (
invite.value.instance_users?.filter(
(user) => user.joined_at !== null && !managerIds.has(user.id),
) ?? []
)
})
const visibleInstanceUsers = computed(() => instanceUsers.value.slice(0, 4))
const hiddenUserCount = computed(() =>
Math.max(0, instanceUsers.value.length - visibleInstanceUsers.value.length),
)
onServerPrefetch(() => suspense())
const inviter = computed(
() =>
invite.value?.managers.find((manager) => manager.type === 'user') ?? invite.value?.managers[0],
)
const inviterName = computed(() => inviter.value?.name ?? formatMessage(messages.unknownInviter))
const inviterAvatar = computed(() => {
if (!inviter.value) return null
return inviter.value.type === 'user' ? inviter.value.avatar : inviter.value.icon
})
function acceptInvite() {
if (!invite.value) return
openInAppModal.value?.show({
instance: {
inviteId: inviteId.value,
name: invite.value.instance_name,
icon: invite.value.instance_icon,
inviterName: inviterName.value,
inviterAvatar: inviterAvatar.value,
},
})
}
</script>
<style scoped>
.invite-avatar {
box-shadow:
0 0 0 8px rgb(255 255 255 / 5%),
0 24px 48px rgb(0 0 0 / 3%),
0 10px 18px rgb(0 0 0 / 3%),
0 5px 8px rgb(0 0 0 / 4%),
0 2px 4px rgb(0 0 0 / 4%);
}
</style>
@@ -7,6 +7,7 @@ export function setupModrinthClientProvider(auth: Awaited<ReturnType<typeof useA
const client = createModrinthClient(auth, {
apiBaseUrl: config.public.apiBaseUrl.replace('/v2/', '/'),
archonBaseUrl: config.public.pyroBaseUrl.replace('/v2/', '/'),
sharedInstancesBaseUrl: config.public.sharedInstancesBaseUrl,
rateLimitKey: config.rateLimitKey,
})
provideModrinthClient(client)
@@ -25,6 +25,8 @@ export interface ServerModrinthClientOptions {
export function useServerModrinthClient(options?: ServerModrinthClientOptions): NuxtModrinthClient {
const config = useRuntimeConfig(options?.event)
const apiBaseUrl = (config.apiBaseUrl || config.public.apiBaseUrl).replace('/v2/', '/')
const sharedInstancesBaseUrl =
config.sharedInstancesBaseUrl || config.public.sharedInstancesBaseUrl
const features = []
@@ -39,6 +41,7 @@ export function useServerModrinthClient(options?: ServerModrinthClientOptions):
const clientConfig: NuxtClientConfig = {
labrinthBaseUrl: apiBaseUrl,
sharedInstancesBaseUrl,
rateLimitKey: config.rateLimitKey || getRateLimitKeyFromSecretsStore,
features,
}
+2
View File
@@ -43,6 +43,7 @@
"BASE_URL": "https://api.modrinth.com/v2/",
"BROWSER_BASE_URL": "https://api.modrinth.com/v2/",
"PYRO_BASE_URL": "https://archon.modrinth.com/",
"SHARED_INSTANCES_API_BASE_URL": "https://shared-instances.modrinth.com",
"STRIPE_PUBLISHABLE_KEY": "pk_live_51JbFxJJygY5LJFfKLVVldb10HlLt24p421OWRsTOWc5sXYFOnFUXWieSc6HD3PHo25ktx8db1WcHr36XGFvZFVUz00V9ixrCs5"
},
"env": {
@@ -58,6 +59,7 @@
"BASE_URL": "https://staging-api.modrinth.com/v2/",
"BROWSER_BASE_URL": "https://staging-api.modrinth.com/v2/",
"PYRO_BASE_URL": "https://staging-archon.modrinth.com/",
"SHARED_INSTANCES_API_BASE_URL": "https://staging-shared-instances.modrinth.com",
"STRIPE_PUBLISHABLE_KEY": "pk_test_51JbFxJJygY5LJFfKV50mnXzz3YLvBVe2Gd1jn7ljWAkaBlRz3VQdxN9mXcPSrFbSqxwAb0svte9yhnsmm7qHfcWn00R611Ce7b"
},
"secrets_store_secrets": [