qa: shared instances post release (#6893)

* fix: 20 user cap

* qa: invite modal dropdown + other + clamp limits

* fix: hide filters if just one type

* feat: update req banner

* feat: invite management frontend

* fix: moderation issues with shared instances

* feat: instance icon improvements

* fix: better offline handling

* fix: lint

* feat: show avatar in install to play modal

* feat: smaller qa points

* fix: fmt

* fix: fmt

* fix: yeet svg

* fix: 50 user limit
This commit is contained in:
Calum H.
2026-07-28 14:10:12 +00:00
committed by GitHub
parent 5d34e7902a
commit 82348fe40a
61 changed files with 1809 additions and 307 deletions
@@ -0,0 +1,82 @@
<template>
<NewModal ref="modal" :header="formatMessage(messages.header)" fade="danger" max-width="500px">
<Admonition type="critical" :header="formatMessage(messages.admonitionHeader)">
<IntlFormatted :message-id="messages.admonitionBody" :values="{ code: inviteCode }">
<template #monospace="{ children }">
<code class="font-mono"><component :is="() => children" /></code>
</template>
</IntlFormatted>
</Admonition>
<template #actions>
<div class="flex justify-end gap-2">
<ButtonStyled type="outlined">
<button @click="modal?.hide()">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="red">
<button @click="confirm">
<XIcon />
{{ formatMessage(messages.revokeButton) }}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>
<script setup lang="ts">
import { XIcon } from '@modrinth/assets'
import {
Admonition,
ButtonStyled,
commonMessages,
defineMessages,
IntlFormatted,
NewModal,
useVIntl,
} from '@modrinth/ui'
import { ref } from 'vue'
const { formatMessage } = useVIntl()
const modal = ref<InstanceType<typeof NewModal>>()
const inviteCode = ref('')
const emit = defineEmits<{
revoke: [inviteCode: string]
}>()
function show(code: string) {
inviteCode.value = code
modal.value?.show()
}
function confirm() {
modal.value?.hide()
emit('revoke', inviteCode.value)
}
const messages = defineMessages({
header: {
id: 'instance.settings.sharing.revoke-invite.header',
defaultMessage: 'Revoke invite',
},
admonitionHeader: {
id: 'instance.settings.sharing.revoke-invite.admonition-header',
defaultMessage: 'This action cannot be undone',
},
admonitionBody: {
id: 'instance.settings.sharing.revoke-invite.admonition-body',
defaultMessage:
'The invite link <monospace>{code}</monospace> will stop working immediately. People who already joined will keep access.',
},
revokeButton: {
id: 'instance.settings.sharing.revoke-invite.confirm',
defaultMessage: 'Revoke invite',
},
})
defineExpose({ show })
</script>
@@ -43,8 +43,9 @@ import type { GameInstance } from '@/helpers/types'
type UpdateCompleteCallback = () => void | Promise<void>
const emit = defineEmits<{
accepted: []
cancel: []
complete: []
complete: [successful: boolean]
report: [event?: MouseEvent]
sharedInstanceUnavailable: [reason: SharedInstanceUnavailableReason | null]
}>()
@@ -72,11 +73,14 @@ const diffs = computed<ContentDiffItem[]>(
)
async function update() {
let successful = false
emit('accepted')
try {
if (instance.value) {
const job = await install_update_shared_instance(instance.value.id)
await wait_for_install_job(job.job_id)
await onComplete.value()
successful = true
}
} catch (error) {
if (isSharedInstanceUnavailableError(error)) {
@@ -85,7 +89,7 @@ async function update() {
}
notifySharedInstanceError(error)
} finally {
emit('complete')
emit('complete', successful)
}
}
@@ -64,7 +64,29 @@
</div>
</Admonition>
<p v-else class="m-0 text-primary">
{{ formatMessage(messages.inviteWarning) }}
<IntlFormatted
v-if="creator"
:message-id="messages.inviteWarningWithCreator"
:values="{ username: creator.username }"
>
<template #creator="{ children }">
<AutoLink :to="creatorProfileLink" class="font-medium text-contrast hover:underline">
<Avatar
:src="creator.avatarUrl"
:alt="creator.username"
:tint-by="creator.username"
size="24px"
circle
no-shadow
class="mr-1 inline-block align-middle"
/>
<span><component :is="() => children" /></span>
</AutoLink>
</template>
</IntlFormatted>
<template v-else>
{{ formatMessage(messages.inviteWarning) }}
</template>
</p>
<SharedInstanceInstallSummary
:preview="preview"
@@ -226,6 +248,7 @@ import { BanIcon, DownloadIcon, ReportIcon, SendIcon, SpinnerIcon, XIcon } from
import {
Admonition,
AutoLink,
Avatar,
ButtonStyled,
Checkbox,
Combobox,
@@ -243,8 +266,10 @@ import {
useScrollIndicator,
useVIntl,
} from '@modrinth/ui'
import { openUrl } from '@tauri-apps/plugin-opener'
import { computed, nextTick, ref } from 'vue'
import { config } from '@/config'
import { hide_ads_window, show_ads_window } from '@/helpers/ads'
import { toError } from '@/helpers/errors'
import type { SharedInstanceInstallPreview } from '@/helpers/install'
@@ -258,11 +283,16 @@ type ExternalFileRow = {
id: string
name: string
}
type SharedInstanceCreator = {
username: string
avatarUrl: string | null
}
const modal = ref<InstanceType<typeof NewModal>>()
const contentModal = ref<InstanceType<typeof ModpackContentModal>>()
const externalFileTable = ref<HTMLElement | null>(null)
const preview = ref<SharedInstanceInstallPreview | null>(null)
const creator = ref<SharedInstanceCreator | null>(null)
const install = ref<() => void | Promise<void>>(() => {})
const reportMode = ref(false)
const reportOnly = ref(false)
@@ -302,6 +332,12 @@ const reportReasonOptions = computed<ComboboxOption<ReportReason>[]>(() => [
const canSubmitReport = computed(
() => Boolean(preview.value && additionalContext.value.trim()) && !submitLoading.value,
)
const creatorProfileLink = computed(() => {
const username = creator.value?.username
return username
? () => openUrl(`${config.siteUrl}/user/${encodeURIComponent(username)}`)
: undefined
})
async function accept() {
hide()
@@ -381,6 +417,7 @@ function handleCancel() {
}
function handleHide() {
resetReportState()
creator.value = null
show_ads_window()
}
function resetReportState() {
@@ -395,14 +432,17 @@ function resetReportState() {
function show(
previewValue: SharedInstanceInstallPreview,
installValue: () => void | Promise<void>,
creatorValue?: SharedInstanceCreator,
event?: MouseEvent,
) {
resetReportState()
creator.value = creatorValue ?? null
install.value = installValue
showPreview(previewValue, event)
}
function showReport(previewValue: SharedInstanceInstallPreview, event?: MouseEvent) {
resetReportState()
creator.value = null
reportMode.value = true
reportOnly.value = true
install.value = () => {}
@@ -437,6 +477,11 @@ const messages = defineMessages({
defaultMessage:
'This invite was created by another Modrinth user, not Modrinth. Only accept invites from people you trust.',
},
inviteWarningWithCreator: {
id: 'app.modal.install-to-play.invite-warning-with-creator',
defaultMessage:
'This invite was created by <creator>{username}</creator>, not Modrinth. Only accept invites from people you trust.',
},
reportDescription: {
id: 'app.modal.install-to-play.report-description',
defaultMessage:
@@ -1,7 +1,7 @@
import type { Labrinth } from '@modrinth/api-client'
import type { ContentItem } from '@modrinth/ui'
import { get_project_many, get_version, get_version_many } from '@/helpers/cache.js'
import { get_project, get_project_many, get_version, get_version_many } from '@/helpers/cache.js'
import type { SharedInstanceInstallPreview } from '@/helpers/install'
type VersionDependency = Labrinth.Versions.v2.Dependency & { version_id?: string }
@@ -20,7 +20,18 @@ export function useSharedInstancePreviewContent() {
async function modpackContentItems(preview: SharedInstanceInstallPreview) {
if (!preview.modpackVersionId) return []
const version = await get_version(preview.modpackVersionId, 'must_revalidate')
return await contentItemsFromDependencies(version?.dependencies ?? [])
if (!version) return []
const [project, contentItems] = await Promise.all([
get_project(version.project_id, 'must_revalidate'),
contentItemsFromDependencies(version.dependencies ?? []),
])
if (!project) return contentItems
return contentItems.map((item) => ({
...item,
source: { project },
}))
}
async function contentItemsFromDependencies(dependencies: Labrinth.Versions.v2.Dependency[]) {
@@ -17,6 +17,7 @@ import {
install_shared_instance,
} from '@/helpers/install'
import { list } from '@/helpers/instance'
import { useSharedInstanceErrors } from '@/helpers/shared-instance-errors'
import { useTheming } from '@/store/state'
import { parseSharedInstanceInviteNotification } from './shared-instance-invite-parser'
@@ -26,9 +27,15 @@ type InstallModal = {
show(
preview: Awaited<ReturnType<typeof install_get_shared_instance_preview>>,
install: () => Promise<void>,
creator?: SharedInstanceCreator,
): void
}
type SharedInstanceCreator = {
username: string
avatarUrl: string | null
}
type AccountRequiredModal = {
show(event?: MouseEvent): Promise<boolean>
}
@@ -45,6 +52,8 @@ export function useSharedInstanceInviteHandler(
const auth = injectAuth()
const client = injectModrinthClient()
const { handleError } = injectNotificationManager()
const { notifySharedInstanceConnectionError, notifySharedInstanceError } =
useSharedInstanceErrors()
const popupNotificationManager = injectPopupNotificationManager()
const queryClient = useQueryClient()
const router = useRouter()
@@ -58,6 +67,7 @@ export function useSharedInstanceInviteHandler(
instanceId: string
preview: Awaited<ReturnType<typeof install_get_shared_instance_preview>>
install: () => Promise<void>
creator?: SharedInstanceCreator
onGoToInstance?: () => void | Promise<void>
}
| undefined
@@ -73,10 +83,13 @@ export function useSharedInstanceInviteHandler(
async function resolveInvite(invite: SharedInstanceInvite) {
const [invitedBy, sharedInstance] = await Promise.all([
!invite.invitedByUsername && invite.invitedById
(!invite.invitedByUsername || !invite.invitedByAvatarUrl) && invite.invitedById
? get_user(invite.invitedById, 'bypass').catch(() => null)
: null,
client.sharedinstances.instances_v1.get(invite.sharedInstanceId).catch(() => null),
client.sharedinstances.instances_v1.get(invite.sharedInstanceId).catch(() => {
notifySharedInstanceConnectionError()
return null
}),
])
return {
@@ -90,15 +103,17 @@ export function useSharedInstanceInviteHandler(
function showInstall(
preview: Awaited<ReturnType<typeof install_get_shared_instance_preview>>,
install: () => Promise<void>,
creator?: SharedInstanceCreator,
) {
if (!installModal.value) throw new Error('Shared instance install modal is not available.')
installModal.value.show(preview, install)
installModal.value.show(preview, install, creator)
}
async function showInstallOrAlreadyInstalled(
sharedInstanceId: string,
preview: Awaited<ReturnType<typeof install_get_shared_instance_preview>>,
install: () => Promise<void>,
creator?: SharedInstanceCreator,
onGoToInstance?: () => void | Promise<void>,
) {
const existingInstance = (await list()).find(
@@ -106,7 +121,7 @@ export function useSharedInstanceInviteHandler(
)
if (!existingInstance || themeStore.getFeatureFlag('skip_non_essential_warnings')) {
showInstall(preview, install)
showInstall(preview, install, creator)
return
}
@@ -118,6 +133,7 @@ export function useSharedInstanceInviteHandler(
instanceId: existingInstance.id,
preview,
install,
creator,
onGoToInstance,
}
alreadyInstalledModal.value.show(existingInstance.name)
@@ -146,7 +162,7 @@ export function useSharedInstanceInviteHandler(
const pending = pendingAlreadyInstalled
pendingAlreadyInstalled = undefined
if (!pending) return
showInstall(pending.preview, pending.install)
showInstall(pending.preview, pending.install, pending.creator)
}
async function acceptNotification(notification: AppNotification, invite: SharedInstanceInvite) {
@@ -172,10 +188,16 @@ export function useSharedInstanceInviteHandler(
await markNotificationRead(notification)
await queryClient.invalidateQueries({ queryKey: ['instances'] })
},
invite.invitedByUsername
? {
username: invite.invitedByUsername,
avatarUrl: invite.invitedByAvatarUrl,
}
: undefined,
() => markNotificationRead(notification),
)
} catch (error) {
handleError(toError(error))
notifySharedInstanceError(error)
}
}
@@ -253,19 +275,32 @@ export function useSharedInstanceInviteHandler(
try {
if (!(await requireAccount())) return
const invite = await install_accept_shared_instance_invite(inviteId)
await showInstallOrAlreadyInstalled(invite.sharedInstanceId, invite.preview, async () => {
await install_shared_instance(
invite.sharedInstanceId,
invite.preview.name,
invite.managerId,
invite.serverManagerName,
invite.serverManagerIconUrl,
invite.instanceIconUrl,
)
await queryClient.invalidateQueries({ queryKey: ['instances'] })
})
const manager = invite.managerId
? await get_user(invite.managerId, 'bypass').catch(() => null)
: null
await showInstallOrAlreadyInstalled(
invite.sharedInstanceId,
invite.preview,
async () => {
await install_shared_instance(
invite.sharedInstanceId,
invite.preview.name,
invite.managerId,
invite.serverManagerName,
invite.serverManagerIconUrl,
invite.instanceIconUrl,
)
await queryClient.invalidateQueries({ queryKey: ['instances'] })
},
manager
? {
username: manager.username,
avatarUrl: manager.avatar_url ?? null,
}
: undefined,
)
} catch (error) {
handleError(toError(error))
notifySharedInstanceError(error)
}
}