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
@@ -15,12 +15,12 @@
</PageHeaderBadgeItem>
<PageHeaderBadgeItem
v-else
:icon="UnknownIcon"
:tooltip="sharedInstanceTooltip"
aria-label="Shared instance information"
class="!border-blue !bg-highlight-blue !text-blue"
>
Shared
<UnknownIcon class="block size-4 shrink-0 text-current" aria-hidden="true" />
</PageHeaderBadgeItem>
</template>
@@ -6,6 +6,11 @@
:instance="instance"
@published="emit('published')"
/>
<InstanceAdmonitionsSharedInstanceUpdateAvailable
v-else-if="item.kind === 'shared-instance-update-available'"
:instance-name="instance.name"
@review="emit('review-update', $event)"
/>
<InstanceAdmonitionsSharedInstanceWrongAccount
v-else-if="item.kind === 'shared-instance-wrong-account'"
:expected-user-id="sharedInstanceExpectedUserId"
@@ -33,6 +38,7 @@ import type { GameInstance } from '@/helpers/types'
import InstanceAdmonitionsSharedInstanceStale from './instance-admonitions-shared-instance-stale.vue'
import InstanceAdmonitionsSharedInstanceUnavailable from './instance-admonitions-shared-instance-unavailable.vue'
import InstanceAdmonitionsSharedInstanceUpdateAvailable from './instance-admonitions-shared-instance-update-available.vue'
import InstanceAdmonitionsSharedInstanceWrongAccount from './instance-admonitions-shared-instance-wrong-account.vue'
import type { InstanceAdmonitionItem, SharedInstanceRole } from './types'
@@ -48,11 +54,13 @@ const props = defineProps<{
sharedInstanceExpectedUserId?: string | null
sharedInstanceRole?: SharedInstanceRole | null
sharedInstanceSignedOut?: boolean
sharedInstanceUpdateAvailable?: boolean
}>()
const emit = defineEmits<{
published: []
delete: []
'review-update': [event: MouseEvent]
}>()
const sharedInstanceWrongAccount = computed(() => props.sharedInstanceWrongAccount ?? false)
@@ -68,6 +76,14 @@ const showSharedInstancePublishAdmonition = computed(
props.instance.shared_instance?.role === 'owner' &&
props.instance.shared_instance.status === 'stale',
)
const showSharedInstanceUpdateAdmonition = computed(
() =>
!sharedInstanceWrongAccount.value &&
!displayedSharedInstanceUnavailableReason.value &&
props.instance.install_stage === 'installed' &&
props.sharedInstanceRole === 'member' &&
props.sharedInstanceUpdateAvailable === true,
)
const stackItems = computed<InstanceAdmonitionItem[]>(() => {
const items: InstanceAdmonitionItem[] = []
@@ -104,6 +120,15 @@ const stackItems = computed<InstanceAdmonitionItem[]>(() => {
})
}
if (showSharedInstanceUpdateAdmonition.value) {
items.push({
id: 'shared-instance-update-available',
type: 'info',
dismissible: false,
kind: 'shared-instance-update-available',
})
}
return items
})
@@ -21,6 +21,19 @@ export const instanceAdmonitionsMessages = defineMessages({
id: 'app.instance.admonitions.shared-instance.reviewing-button',
defaultMessage: 'Reviewing...',
},
sharedInstanceUpdateAvailableHeader: {
id: 'app.instance.admonitions.shared-instance.update-available-header',
defaultMessage: 'An update is available',
},
sharedInstanceUpdateAvailableBody: {
id: 'app.instance.admonitions.shared-instance.update-available-body',
defaultMessage:
'An update is required to play {name}. Please update to latest version to launch the game.',
},
sharedInstanceReviewUpdateButton: {
id: 'app.instance.admonitions.shared-instance.review-update-button',
defaultMessage: 'Review update',
},
sharedInstanceReviewHeader: {
id: 'app.instance.admonitions.shared-instance.review-header',
defaultMessage: 'Review changes',
@@ -0,0 +1,34 @@
<template>
<Admonition
type="info"
inline-actions
:header="formatMessage(messages.sharedInstanceUpdateAvailableHeader)"
>
{{ formatMessage(messages.sharedInstanceUpdateAvailableBody, { name: instanceName }) }}
<template #actions>
<ButtonStyled color="blue">
<button class="!h-10" @click="emit('review', $event)">
<DownloadIcon aria-hidden="true" />
{{ formatMessage(messages.sharedInstanceReviewUpdateButton) }}
</button>
</ButtonStyled>
</template>
</Admonition>
</template>
<script setup lang="ts">
import { DownloadIcon } from '@modrinth/assets'
import { Admonition, ButtonStyled, useVIntl } from '@modrinth/ui'
import { instanceAdmonitionsMessages as messages } from './instance-admonitions-messages'
defineProps<{
instanceName: string
}>()
const emit = defineEmits<{
review: [event: MouseEvent]
}>()
const { formatMessage } = useVIntl()
</script>
@@ -2,6 +2,7 @@ import type { StackedAdmonitionItem } from '@modrinth/ui'
export type InstanceAdmonitionKind =
| 'shared-instance-stale'
| 'shared-instance-update-available'
| 'shared-instance-unavailable'
| 'shared-instance-wrong-account'
@@ -109,9 +109,13 @@ watch(selectedReleaseChannel, async (channel, previousChannel) => {
})
async function resetIcon() {
icon.value = undefined
await edit_icon(instance.value.id, null).catch(handleError)
trackEvent('InstanceRemoveIcon')
try {
await edit_icon(instance.value.id, null)
icon.value = undefined
trackEvent('InstanceRemoveIcon')
} catch (error) {
handleError(error)
}
}
async function setIcon() {
@@ -127,10 +131,13 @@ async function setIcon() {
if (!value) return
icon.value = value
await edit_icon(instance.value.id, icon.value).catch(handleError)
trackEvent('InstanceSetIcon')
try {
await edit_icon(instance.value.id, value)
icon.value = value
trackEvent('InstanceSetIcon')
} catch (error) {
handleError(error)
}
}
const editInstanceObject = computed(() => ({
@@ -1,31 +1,156 @@
<template>
<div>
<div class="flex flex-col gap-8">
<section class="flex flex-col gap-4">
<div class="flex flex-col gap-1">
<h3 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.activeInvitesTitle) }}
</h3>
<p class="m-0 text-secondary">
{{ formatMessage(messages.activeInvitesDescription) }}
</p>
</div>
<Table :columns="inviteColumns" :data="activeInvites" row-key="id" table-min-width="36rem">
<template #empty-state>
<div class="flex h-40 items-center justify-center text-secondary">
{{ formatMessage(messages.noActiveInvites) }}
</div>
</template>
<template #cell-id="{ row }">
<CopyCode
:text="`${config.siteUrl}/share/${encodeURIComponent(row.id)}`"
:display-text="`/${row.id}`"
/>
</template>
<template #cell-uses="{ row }">
<span class="font-medium text-primary">{{ row.uses }}</span>
<span> / {{ row.maxUses }}</span>
</template>
<template #cell-expiration="{ row }">
<span v-tooltip="formatDateTime(row.expiration)" class="whitespace-nowrap">
{{ formatRelativeTime(row.expiration) }}
</span>
</template>
<template #cell-actions="{ row }">
<div class="flex justify-end">
<ButtonStyled circular type="transparent">
<button
v-tooltip="formatMessage(messages.revokeInvite)"
:aria-label="
formatMessage(messages.revokeInviteWithCode, {
code: row.id,
})
"
class="text-secondary hover:!filter-none hover:text-red focus-visible:!filter-none"
@click="revokeInviteModal?.show(row.id)"
>
<XIcon aria-hidden="true" />
</button>
</ButtonStyled>
</div>
</template>
</Table>
</section>
<SharedInstanceInstallationSettingsControls
can-unpublish
:busy="isBusy"
:unpublishing="unpublishing"
:unpublish="unpublishSharedInstance"
/>
<ConfirmRevokeSharedInstanceInviteModal ref="revokeInviteModal" @revoke="revokeInvite" />
</div>
</template>
<script setup lang="ts">
import { injectNotificationManager } from '@modrinth/ui'
import { XIcon } from '@modrinth/assets'
import {
ButtonStyled,
CopyCode,
defineMessages,
Table,
type TableColumn,
useFormatDateTime,
useRelativeTime,
useVIntl,
} from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import { computed, ref } from 'vue'
import ConfirmRevokeSharedInstanceInviteModal from '@/components/ui/shared-instances/ConfirmRevokeSharedInstanceInviteModal.vue'
import SharedInstanceInstallationSettingsControls from '@/components/ui/shared-instances/SharedInstanceInstallationSettingsControls.vue'
import { unpublish_shared_instance } from '@/helpers/instance'
import { config } from '@/config'
import { type SharedInstanceInvite, unpublish_shared_instance } from '@/helpers/instance'
import { useSharedInstanceErrors } from '@/helpers/shared-instance-errors'
import { injectInstanceSettings } from '@/providers/instance-settings'
const { instance, offline, onUnlinked } = injectInstanceSettings()
const { handleError } = injectNotificationManager()
const { notifySharedInstanceError } = useSharedInstanceErrors()
const { formatMessage } = useVIntl()
const queryClient = useQueryClient()
const unpublishing = ref(false)
const revokeInviteModal = ref<InstanceType<typeof ConfirmRevokeSharedInstanceInviteModal>>()
const formatRelativeTime = useRelativeTime()
const formatDateTime = useFormatDateTime({ dateStyle: 'medium', timeStyle: 'short' })
const isBusy = computed(
() => instance.value.install_stage !== 'installed' || unpublishing.value || !!offline,
)
type InviteTableColumn = 'id' | 'uses' | 'expiration' | 'actions'
const inviteColumns = computed<TableColumn<InviteTableColumn>[]>(() => [
{
key: 'id',
label: formatMessage(messages.inviteCodeLabel),
width: 'clamp(11rem, 34%, 19rem)',
},
{
key: 'uses',
label: formatMessage(messages.usesLabel),
width: 'clamp(7rem, 18%, 10rem)',
},
{
key: 'expiration',
label: formatMessage(messages.expiresLabel),
width: 'clamp(9rem, 28%, 14rem)',
},
{
key: 'actions',
label: formatMessage(messages.actionsLabel),
align: 'right',
width: '5.5rem',
},
])
const now = Date.now()
// TODO: Use actual endpoint
const activeInvites = ref<SharedInstanceInvite[]>([
{
id: 'wqHPxNagZr',
expiration: new Date(now + 6 * 24 * 60 * 60 * 1000).toISOString(),
maxUses: 10,
uses: 0,
},
{
id: 'GbRGfY7hbs',
expiration: new Date(now + 3 * 24 * 60 * 60 * 1000).toISOString(),
maxUses: 10,
uses: 2,
},
{
id: 'k9mvD2QxLc',
expiration: new Date(now + 90 * 60 * 1000).toISOString(),
maxUses: 5,
uses: 4,
},
])
function revokeInvite(inviteId: string) {
activeInvites.value = activeInvites.value.filter((invite) => invite.id !== inviteId)
}
async function unpublishSharedInstance() {
unpublishing.value = true
try {
@@ -34,9 +159,48 @@ async function unpublishSharedInstance() {
await queryClient.invalidateQueries({ queryKey: ['linkedModpackInfo', instance.value.id] })
onUnlinked()
} catch (error) {
handleError(error)
notifySharedInstanceError(error)
} finally {
unpublishing.value = false
}
}
const messages = defineMessages({
activeInvitesTitle: {
id: 'instance.settings.sharing.active-invites.title',
defaultMessage: 'Active invites',
},
activeInvitesDescription: {
id: 'instance.settings.sharing.active-invites.description',
defaultMessage: 'Anyone with one of these invite links can join while it remains active.',
},
inviteCodeLabel: {
id: 'instance.settings.sharing.active-invites.code',
defaultMessage: 'Invite link',
},
usesLabel: {
id: 'instance.settings.sharing.active-invites.uses',
defaultMessage: 'Uses',
},
expiresLabel: {
id: 'instance.settings.sharing.active-invites.expires',
defaultMessage: 'Expires',
},
actionsLabel: {
id: 'instance.settings.sharing.active-invites.actions',
defaultMessage: 'Actions',
},
noActiveInvites: {
id: 'instance.settings.sharing.active-invites.empty',
defaultMessage: 'There are no active invites.',
},
revokeInvite: {
id: 'instance.settings.sharing.active-invites.revoke',
defaultMessage: 'Revoke invite',
},
revokeInviteWithCode: {
id: 'instance.settings.sharing.active-invites.revoke-with-code',
defaultMessage: 'Revoke invite {code}',
},
})
</script>
@@ -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)
}
}
+5
View File
@@ -102,6 +102,7 @@ export interface SharedInstanceUpdateDiff {
}
export const SHARED_INSTANCE_UNAVAILABLE_ERROR_CODE = 'shared_instance_unavailable'
export const SHARED_INSTANCES_API_ERROR_CODE = 'shared_instances_api_error'
export type SharedInstanceUnavailableReason = 'deleted' | 'access_revoked' | 'quarantined'
@@ -113,6 +114,10 @@ export function isSharedInstanceUnavailableError(error: unknown) {
return getSharedInstanceUnavailableReason(error) !== null
}
export function isSharedInstancesApiError(error: unknown) {
return isRecord(error) && error.code === SHARED_INSTANCES_API_ERROR_CODE
}
export function getSharedInstanceUnavailableReason(
error: unknown,
): SharedInstanceUnavailableReason | null {
+20
View File
@@ -365,6 +365,13 @@ export interface SharedInstanceInviteLink {
maxUses: number
}
export interface SharedInstanceInvite {
id: string
expiration: string
maxUses: number
uses: number
}
export async function can_current_user_use_shared_instances(): Promise<boolean> {
return await invoke('plugin:instance|instance_share_can_current_user_use')
}
@@ -394,6 +401,19 @@ export async function create_shared_instance_invite_link(
})
}
export async function get_shared_instance_invites(
instanceId: string,
): Promise<SharedInstanceInvite[]> {
return await invoke('plugin:instance|instance_share_get_invites', { instanceId })
}
export async function revoke_shared_instance_invite(
instanceId: string,
inviteId: string,
): Promise<void> {
return await invoke('plugin:instance|instance_share_revoke_invite', { instanceId, inviteId })
}
export async function remove_shared_instance_users(
instanceId: string,
userIds: string[],
@@ -1,6 +1,10 @@
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { getErrorMessage, type SharedInstanceUnavailableReason } from '@/helpers/install'
import {
getErrorMessage,
isSharedInstancesApiError,
type SharedInstanceUnavailableReason,
} from '@/helpers/install'
export const sharedInstanceErrorMessages = defineMessages({
unavailableTitle: {
@@ -39,6 +43,14 @@ export const sharedInstanceErrorMessages = defineMessages({
id: 'instance.shared-instance.error.title',
defaultMessage: 'Something has gone wrong',
},
networkErrorTitle: {
id: 'instance.shared-instance.network-error.title',
defaultMessage: 'Network error',
},
networkErrorText: {
id: 'instance.shared-instance.network-error.text',
defaultMessage: 'Unable to connect to shared instances API',
},
})
export function sharedInstanceUnavailableTextMessage(
@@ -82,7 +94,20 @@ export function useSharedInstanceErrors() {
})
}
function notifySharedInstanceConnectionError() {
addNotification({
type: 'error',
title: formatMessage(sharedInstanceErrorMessages.networkErrorTitle),
text: formatMessage(sharedInstanceErrorMessages.networkErrorText),
})
}
function notifySharedInstanceError(error: unknown) {
if (isSharedInstancesApiError(error)) {
notifySharedInstanceConnectionError()
return
}
addNotification({
type: 'error',
title: formatMessage(sharedInstanceErrorMessages.errorTitle),
@@ -92,6 +117,7 @@ export function useSharedInstanceErrors() {
return {
formatSharedInstanceUnavailable,
notifySharedInstanceConnectionError,
notifySharedInstanceError,
notifySharedInstanceUnavailable,
}
@@ -374,9 +374,18 @@
"app.instance.admonitions.shared-instance.review-header": {
"message": "Review changes"
},
"app.instance.admonitions.shared-instance.review-update-button": {
"message": "Review update"
},
"app.instance.admonitions.shared-instance.reviewing-button": {
"message": "Reviewing..."
},
"app.instance.admonitions.shared-instance.update-available-body": {
"message": "An update is required to play {name}. Please update to latest version to launch the game."
},
"app.instance.admonitions.shared-instance.update-available-header": {
"message": "An update is available"
},
"app.instance.confirm-delete.admonition-body": {
"message": "All data for your instance will be permanently deleted, including your worlds, configs, and all installed content."
},
@@ -443,6 +452,9 @@
"app.instance.share.invite-modal.heading": {
"message": "Share {name}"
},
"app.instance.share.invite-modal.user-limit-reached": {
"message": "This instance has reached the {limit}-user limit."
},
"app.instance.share.locked.empty-description-prefix": {
"message": "You need to sign in as"
},
@@ -461,6 +473,12 @@
"app.instance.share.locked.wrong-account-heading": {
"message": "Wrong account"
},
"app.instance.share.members.empty": {
"message": "No users have joined yet"
},
"app.instance.share.members.no-filter-results": {
"message": "No users match your filters."
},
"app.instance.share.remove-user-modal.effect-access": {
"message": "They will no longer receive updates for this shared instance"
},
@@ -491,6 +509,12 @@
"app.instance.share.sign-in.button": {
"message": "Sign in"
},
"app.instance.share.unable-to-connect.description": {
"message": "The shared instances service is not accessible at the moment, please try again later"
},
"app.instance.share.unable-to-connect.heading": {
"message": "Unable to connect"
},
"app.instance.share.unlink.body": {
"message": "You must unlink this modpack to share your instance"
},
@@ -650,6 +674,9 @@
"app.modal.install-to-play.invite-warning": {
"message": "This invite was created by another Modrinth user, not Modrinth. Only accept invites from people you trust."
},
"app.modal.install-to-play.invite-warning-with-creator": {
"message": "This invite was created by <creator>{username}</creator>, not Modrinth. Only accept invites from people you trust."
},
"app.modal.install-to-play.mod-count": {
"message": "{count, plural, one {# mod} other {# mods}}"
},
@@ -1376,6 +1403,45 @@
"instance.server-modal.resource-pack": {
"message": "Resource pack"
},
"instance.settings.sharing.active-invites.actions": {
"message": "Actions"
},
"instance.settings.sharing.active-invites.code": {
"message": "Invite link"
},
"instance.settings.sharing.active-invites.description": {
"message": "Anyone with one of these invite links can join while it remains active."
},
"instance.settings.sharing.active-invites.empty": {
"message": "There are no active invites."
},
"instance.settings.sharing.active-invites.expires": {
"message": "Expires"
},
"instance.settings.sharing.active-invites.revoke": {
"message": "Revoke invite"
},
"instance.settings.sharing.active-invites.revoke-with-code": {
"message": "Revoke invite {code}"
},
"instance.settings.sharing.active-invites.title": {
"message": "Active invites"
},
"instance.settings.sharing.active-invites.uses": {
"message": "Uses"
},
"instance.settings.sharing.revoke-invite.admonition-body": {
"message": "The invite link <monospace>{code}</monospace> will stop working immediately. People who already joined will keep access."
},
"instance.settings.sharing.revoke-invite.admonition-header": {
"message": "This action cannot be undone"
},
"instance.settings.sharing.revoke-invite.confirm": {
"message": "Revoke invite"
},
"instance.settings.sharing.revoke-invite.header": {
"message": "Revoke invite"
},
"instance.settings.tabs.general": {
"message": "General"
},
@@ -1577,6 +1643,12 @@
"instance.shared-instance.error.title": {
"message": "Something has gone wrong"
},
"instance.shared-instance.network-error.text": {
"message": "Unable to connect to shared instances API"
},
"instance.shared-instance.network-error.title": {
"message": "Network error"
},
"instance.shared-instance.owner-tooltip": {
"message": "This instance's content is being shared to other users."
},
+51 -5
View File
@@ -16,6 +16,8 @@
<UpdateToPlayModal ref="updateToPlayModal" :instance="instance" />
<SharedInstanceUpdateModal
ref="sharedInstanceUpdateModal"
@accepted="hideAcceptedSharedInstanceUpdate"
@complete="handleSharedInstanceUpdateComplete"
@shared-instance-unavailable="handleSharedInstanceUnavailable"
@report="(event) => reportSharedInstance(event, true)"
/>
@@ -62,8 +64,10 @@
:shared-instance-expected-user-id="sharedInstanceExpectedUserId"
:shared-instance-role="instance.shared_instance?.role"
:shared-instance-signed-out="sharedInstanceSignedOut"
:shared-instance-update-available="showSharedInstanceUpdateAdmonition"
@published="fetchInstance"
@delete="requestInstanceDeletion"
@review-update="reviewSharedInstanceUpdate"
/>
</div>
<div :class="['p-6 pt-4', { 'min-h-0 flex-1 overflow-y-auto': isFixedRender }]">
@@ -223,6 +227,7 @@ const sharedInstanceUpdateModal = ref<InstanceType<typeof SharedInstanceUpdateMo
const sharedInstanceReportModal = ref<InstanceType<typeof SharedInstanceInstallModal>>()
const deleteConfirmModal = ref<InstanceType<typeof ConfirmDeleteInstanceModal>>()
const selectedInstanceToDelete = ref<GameInstance | null>(null)
const hiddenSharedInstanceUpdateKey = ref<string | null>(null)
const { notifySharedInstanceError, notifySharedInstanceUnavailable } = useSharedInstanceErrors()
@@ -253,8 +258,19 @@ const {
signedOut: sharedInstanceSignedOut,
unavailableManager: sharedInstanceUnavailableManager,
unavailableReason: sharedInstanceUnavailableReason,
updatePreview: sharedInstanceUpdatePreview,
wrongAccount: sharedInstanceWrongAccount,
} = sharedInstanceState
const sharedInstanceUpdateKey = computed(() => {
const instanceId = instance.value?.id
const latestVersion = sharedInstanceUpdatePreview.value?.latestVersion
return instanceId && latestVersion !== undefined ? `${instanceId}:${latestVersion}` : null
})
const showSharedInstanceUpdateAdmonition = computed(
() =>
sharedInstanceUpdatePreview.value?.updateAvailable === true &&
sharedInstanceUpdateKey.value !== hiddenSharedInstanceUpdateKey.value,
)
watch(
() => router.currentRoute.value,
@@ -530,6 +546,37 @@ async function handleSharedInstanceUnavailable(
setSharedInstanceUnavailable(reason)
}
function reviewSharedInstanceUpdate(event: MouseEvent) {
const currentInstance = instance.value
const preview = sharedInstanceUpdatePreview.value
if (
!currentInstance ||
currentInstance.shared_instance?.role !== 'member' ||
!preview?.updateAvailable
) {
return
}
sharedInstanceUpdateModal.value?.show(
currentInstance,
preview,
async () => {
await fetchInstance()
},
event,
)
}
function hideAcceptedSharedInstanceUpdate() {
hiddenSharedInstanceUpdateKey.value = sharedInstanceUpdateKey.value
}
function handleSharedInstanceUpdateComplete(successful: boolean) {
if (!successful && hiddenSharedInstanceUpdateKey.value === sharedInstanceUpdateKey.value) {
hiddenSharedInstanceUpdateKey.value = null
}
}
const startInstance = async (context: string) => {
if (!instance.value || instance.value.quarantined) return
if (checkingSharedInstanceLaunch.value || loading.value || playing.value) return
@@ -540,17 +587,16 @@ const startInstance = async (context: string) => {
!!instance.value.shared_instance && !sharedInstanceActionsLocked.value && !offline.value
if (canCheckSharedInstanceUpdate) {
let preview: Awaited<ReturnType<typeof refreshSharedInstanceUpdatePreview>>
let preview: Awaited<ReturnType<typeof refreshSharedInstanceUpdatePreview>> = null
checkingSharedInstanceLaunch.value = true
try {
preview = await refreshSharedInstanceUpdatePreview()
} catch (error) {
if (isSharedInstanceUnavailableError(error)) {
await handleSharedInstanceUnavailable(getSharedInstanceUnavailableReason(error))
} else {
notifySharedInstanceError(error)
return
}
return
notifySharedInstanceError(error)
} finally {
checkingSharedInstanceLaunch.value = false
}
@@ -657,7 +703,7 @@ async function reportSharedInstance(event?: MouseEvent, closeUpdateModal = false
if (closeUpdateModal) sharedInstanceUpdateModal.value?.hide()
sharedInstanceReportModal.value?.showReport(preview, event)
} catch (error) {
handleError(error as Error)
notifySharedInstanceError(error)
}
}
@@ -9,9 +9,19 @@
:link="inviteLink.link.value"
:link-expires-at="inviteLink.details.value?.expiresAt"
:link-max-uses="inviteLink.details.value?.maxUses"
:link-max-uses-limit="remainingUserSlots"
:update-invite-link="inviteLink.update"
:user-profile-link="userProfileLink"
:can-invite="!members.exclusiveMutationPending.value && !inviteLink.pending.value"
:can-invite="
hasRemainingUserSlots &&
!members.exclusiveMutationPending.value &&
!inviteLink.pending.value
"
:invite-disabled-message="
hasRemainingUserSlots
? undefined
: formatMessage(messages.userLimitReached, { limit: SHARED_INSTANCE_USER_LIMIT })
"
@invite="invitePlayer"
@cancel="cancelInvite"
/>
@@ -37,10 +47,19 @@
@state-change="publishState = $event"
/>
<SharedInstanceShareEmptyState
v-if="unableToConnect"
:heading="formatMessage(messages.unableToConnectHeading)"
:description="formatMessage(messages.unableToConnectDescription)"
/>
<div v-else-if="membersTableLoading" class="h-64" aria-hidden="true" />
<SharedInstanceMembersTable
v-if="members.rows.value.length > 0"
v-else-if="showMembersTable"
:rows="members.rows.value"
:actions-locked="sharedInstanceActionsLocked"
:invite-disabled="!hasRemainingUserSlots"
:invite-pending="inviteLink.pending.value"
:push-update-disabled="
instance.install_stage !== 'installed' || publishState !== 'idle' || !!offline
@@ -107,7 +126,7 @@
<ButtonStyled color="brand"
><button
class="!h-10"
:disabled="inviteLink.pending.value"
:disabled="inviteLink.pending.value || !hasRemainingUserSlots"
@click="showInvitePlayers($event)"
>
<SpinnerIcon
@@ -137,16 +156,17 @@ import {
type InvitePlayersUser,
useVIntl,
} from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, ref, toRef, watch } from 'vue'
import ModrinthAccountRequiredModal from '@/components/ui/modal/ModrinthAccountRequiredModal.vue'
import SharedInstancePublishModal from '@/components/ui/shared-instances/SharedInstancePublishModal.vue'
import {
getSharedInstanceUnavailableReason,
isSharedInstancesApiError,
isSharedInstanceUnavailableError,
} from '@/helpers/install'
import { edit } from '@/helpers/instance'
import { can_current_user_use_shared_instances, edit } from '@/helpers/instance'
import type { ModrinthAuthFlow } from '@/helpers/mr_auth.ts'
import {
sharedInstanceErrorMessages,
@@ -159,7 +179,7 @@ import { injectSharedInstanceState } from '../use-shared-instance-state'
import SharedInstanceMembersTable from './shared-instance-members-table.vue'
import SharedInstanceRemoveMemberModal from './shared-instance-remove-member-modal.vue'
import SharedInstanceShareEmptyState from './shared-instance-share-empty-state.vue'
import type { ShareRow } from './shared-instance-share-types'
import { SHARED_INSTANCE_USER_LIMIT, type ShareRow } from './shared-instance-share-types'
import { useSharedInstanceInviteCandidates } from './use-shared-instance-invite-candidates'
import { useSharedInstanceInviteLink } from './use-shared-instance-invite-link'
import { useSharedInstanceMembers } from './use-shared-instance-members'
@@ -182,6 +202,7 @@ const actionsLocked = sharedInstanceState.shareActionsLocked
const sharedInstanceActionsLocked = actionsLocked
const currentUserId = computed(() => auth.user.value?.id ?? null)
const isSignedIn = computed(() => !!auth.session_token.value)
const sharedInstancesApiUnavailable = ref(false)
const accountRequiredModal = ref<InstanceType<typeof ModrinthAccountRequiredModal>>()
const invitePlayersModal = ref<InstanceType<typeof InvitePlayersModal>>()
const unlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
@@ -198,10 +219,22 @@ function notifyOperationError(error: unknown) {
sharedInstanceState.unavailableManager.value,
)
} else {
if (isSharedInstancesApiError(error)) sharedInstancesApiUnavailable.value = true
notifySharedInstanceError(error)
}
}
const eligibilityQuery = useQuery({
queryKey: computed(() => ['shared-instance-eligibility', currentUserId.value]),
queryFn: can_current_user_use_shared_instances,
enabled: () => isSignedIn.value && !!currentUserId.value,
retry: false,
staleTime: Infinity,
refetchOnMount: 'always',
refetchOnWindowFocus: false,
refetchOnReconnect: false,
})
const members = useSharedInstanceMembers({
instance,
currentUserId,
@@ -209,6 +242,10 @@ const members = useSharedInstanceMembers({
actionsLocked,
onError: notifyOperationError,
})
const remainingUserSlots = computed(() =>
Math.max(0, SHARED_INSTANCE_USER_LIMIT - members.rows.value.length),
)
const hasRemainingUserSlots = computed(() => remainingUserSlots.value > 0)
const {
inviteFriends,
search: searchInviteUsers,
@@ -221,6 +258,7 @@ const {
})
const inviteLink = useSharedInstanceInviteLink(
computed(() => props.instance.id),
remainingUserSlots,
notifyOperationError,
)
@@ -239,6 +277,29 @@ const lockedActionButton = computed(() =>
const sharedInstanceUnavailableReason = sharedInstanceState.unavailableReason
const sharedInstanceUnavailable = computed(() => !!sharedInstanceUnavailableReason.value)
const sharedInstanceUnavailableManager = sharedInstanceState.unavailableManager
const unableToConnect = computed(
() =>
sharedInstancesApiUnavailable.value ||
isSharedInstancesApiError(eligibilityQuery.error.value) ||
isSharedInstancesApiError(members.query.error.value),
)
const membersTableLoading = computed(
() =>
members.rows.value.length === 0 &&
!!props.instance.shared_instance &&
(members.query.data.value === undefined || members.query.isFetching.value) &&
!sharedInstanceUnavailable.value &&
!sharedInstanceActionsLocked.value,
)
const showMembersTable = computed(
() =>
members.rows.value.length > 0 ||
(!!props.instance.shared_instance &&
members.query.data.value !== undefined &&
!members.query.isFetching.value &&
!sharedInstanceUnavailable.value &&
!sharedInstanceActionsLocked.value),
)
const requiresUnlink = computed(
() =>
props.instance.link?.type === 'imported_modpack' &&
@@ -253,6 +314,15 @@ const importedModpackBackupTip = computed(() =>
const messages = defineMessages({
signInButton: { id: 'app.instance.share.sign-in.button', defaultMessage: 'Sign in' },
unableToConnectHeading: {
id: 'app.instance.share.unable-to-connect.heading',
defaultMessage: 'Unable to connect',
},
unableToConnectDescription: {
id: 'app.instance.share.unable-to-connect.description',
defaultMessage:
'The shared instances service is not accessible at the moment, please try again later',
},
noFriendsInvitedHeading: {
id: 'app.instance.share.empty.heading',
defaultMessage: 'No friends invited',
@@ -265,6 +335,10 @@ const messages = defineMessages({
id: 'app.instance.share.empty.invite-friends-button',
defaultMessage: 'Invite friends',
},
userLimitReached: {
id: 'app.instance.share.invite-modal.user-limit-reached',
defaultMessage: 'This instance has reached the {limit}-user limit.',
},
shareModalHeader: {
id: 'app.instance.share.invite-modal.heading',
defaultMessage: 'Share {name}',
@@ -304,7 +378,7 @@ const messages = defineMessages({
})
function invitePlayer(payload: InvitePlayersInvitePayload) {
if (actionsLocked.value) return
if (actionsLocked.value || !hasRemainingUserSlots.value) return
if (payload.source === 'search') void requestFriend(payload.user)
members.invite(payload.user)
}
@@ -315,6 +389,7 @@ function cancelInvite(user: InvitePlayersUser) {
async function showInvitePlayers(event?: MouseEvent) {
if (actionsLocked.value) return
if (!isSignedIn.value) return signInToShare(event)
if (!hasRemainingUserSlots.value) return
if (requiresUnlink.value) return unlinkModal.value?.show()
if (await inviteLink.ensure()) invitePlayersModal.value?.show(event)
}
@@ -353,6 +428,20 @@ function signInToShare(event?: MouseEvent) {
void accountRequiredModal.value?.show(event)
}
watch(
[eligibilityQuery.error, members.query.error],
(errors) => {
for (const error of errors) {
if (isSharedInstancesApiError(error)) notifyOperationError(error)
}
},
{ immediate: true },
)
watch([eligibilityQuery.data, members.query.data], ([eligibility, memberRows]) => {
if (eligibility !== undefined && memberRows !== undefined) {
sharedInstancesApiUnavailable.value = false
}
})
watch(
() => props.instance.id,
() => {
@@ -25,7 +25,7 @@
<ButtonStyled color="brand">
<button
class="flex !h-10 shrink-0 items-center gap-2"
:disabled="invitePending"
:disabled="invitePending || inviteDisabled"
@click="emit('invite', $event)"
>
<SpinnerIcon v-if="invitePending" class="animate-spin" aria-hidden="true" />
@@ -35,7 +35,7 @@
</ButtonStyled>
</template>
</div>
<div class="flex flex-wrap items-center gap-1.5">
<div v-if="hasMultipleMethods" class="flex flex-wrap items-center gap-1.5">
<FilterIcon class="size-5 shrink-0 text-secondary" aria-hidden="true" />
<button
:class="filterClass(methodFilter === 'all')"
@@ -67,7 +67,9 @@
>
<template #empty-state
><div class="flex h-64 items-center justify-center text-secondary">
No users match your filters.
{{
formatMessage(rows.length === 0 ? messages.noUsersJoined : messages.noUsersMatchFilters)
}}
</div></template
>
<template #cell-username="{ row }">
@@ -157,7 +159,7 @@ import {
useRelativeTime,
useVIntl,
} from '@modrinth/ui'
import { computed, ref } from 'vue'
import { computed, ref, watch } from 'vue'
import {
type MethodFilter,
@@ -170,6 +172,7 @@ import {
const props = defineProps<{
rows: ShareRow[]
actionsLocked?: boolean
inviteDisabled?: boolean
invitePending?: boolean
pushUpdateDisabled?: boolean
pushUpdatePending?: boolean
@@ -191,6 +194,7 @@ const methodFilterOptions: Array<{ id: ShareMethod; label: string }> = [
{ id: 'direct', label: methodLabels.direct },
{ id: 'link', label: methodLabels.link },
]
const hasMultipleMethods = computed(() => new Set(props.rows.map((row) => row.method)).size > 1)
const columns = computed<TableColumn<ShareTableColumn>[]>(() => {
const result: TableColumn<ShareTableColumn>[] = [
{
@@ -286,11 +290,23 @@ function filterClass(active: boolean) {
]
}
watch(hasMultipleMethods, (multiple) => {
if (!multiple) methodFilter.value = 'all'
})
const messages = defineMessages({
pushUpdate: {
id: 'app.instance.admonitions.shared-instance.publish-button',
defaultMessage: 'Push update',
},
noUsersJoined: {
id: 'app.instance.share.members.empty',
defaultMessage: 'No users have joined yet',
},
noUsersMatchFilters: {
id: 'app.instance.share.members.no-filter-results',
defaultMessage: 'No users match your filters.',
},
})
function userProfileLink(username: string) {
return !username || username.includes('@') ? undefined : `/user/${encodeURIComponent(username)}`
@@ -2,6 +2,8 @@ export type ShareMethod = 'direct' | 'link'
export type MethodFilter = ShareMethod | 'all'
export type ShareTableColumn = 'username' | 'lastPlayed' | 'joined' | 'method' | 'actions'
export const SHARED_INSTANCE_USER_LIMIT = 50
export type ShareRow = {
id: string
username: string
@@ -5,8 +5,11 @@ import { config } from '@/config'
import { toError } from '@/helpers/errors'
import { create_shared_instance_invite_link } from '@/helpers/instance'
const DEFAULT_INVITE_LINK_MAX_USES = 10
export function useSharedInstanceInviteLink(
instanceId: Ref<string>,
maxInviteUses: Ref<number>,
onError: (error: unknown) => void,
) {
const details = ref<Awaited<ReturnType<typeof create_shared_instance_invite_link>>>()
@@ -20,10 +23,12 @@ export function useSharedInstanceInviteLink(
async function ensure() {
if (details.value) return true
if (pending.value) return false
const maxUses = Math.min(DEFAULT_INVITE_LINK_MAX_USES, Math.floor(maxInviteUses.value))
if (maxUses <= 0) return false
pending.value = true
try {
details.value = await create_shared_instance_invite_link(instanceId.value)
details.value = await create_shared_instance_invite_link(instanceId.value, { maxUses })
return true
} catch (error) {
onError(error)
@@ -35,6 +40,8 @@ export function useSharedInstanceInviteLink(
async function update(settings: InviteLinkSettings) {
if (!details.value) return
const maxInviteLinkUses = Math.floor(maxInviteUses.value)
if (maxInviteLinkUses <= 0) return
pending.value = true
try {
@@ -44,7 +51,7 @@ export function useSharedInstanceInviteLink(
)
details.value = await create_shared_instance_invite_link(instanceId.value, {
maxAgeSeconds,
maxUses: settings.maxUses,
maxUses: Math.min(settings.maxUses, maxInviteLinkUses),
replaceInviteId: details.value.inviteId,
})
} catch (error) {
@@ -12,7 +12,11 @@ import {
} from '@/helpers/instance'
import type { GameInstance } from '@/helpers/types'
import { normalizeInviteKey, type ShareRow } from './shared-instance-share-types'
import {
normalizeInviteKey,
SHARED_INSTANCE_USER_LIMIT,
type ShareRow,
} from './shared-instance-share-types'
type MembersQueryKey = readonly ['sharedInstanceUsers', string]
@@ -54,6 +58,7 @@ export function useSharedInstanceMembers(options: {
queryFn: ({ queryKey }) => fetchRows(queryKey),
enabled: () =>
options.isSignedIn.value && !!options.instance.value.id && !options.actionsLocked.value,
retry: false,
staleTime: Infinity,
refetchOnMount: 'always',
refetchOnReconnect: false,
@@ -139,6 +144,7 @@ export function useSharedInstanceMembers(options: {
if (
options.actionsLocked.value ||
exclusiveMutationPending.value ||
rows.value.length >= SHARED_INSTANCE_USER_LIMIT ||
invitingUserIds.has(normalizedId) ||
find(user.id, user.username)
) {