fix: 20 user cap

This commit is contained in:
Calum H. (IMB11)
2026-07-27 12:03:29 +01:00
parent 372e2bf47b
commit af9cb8a625
7 changed files with 70 additions and 19 deletions
@@ -9,9 +9,19 @@
:link="inviteLink.link.value" :link="inviteLink.link.value"
:link-expires-at="inviteLink.details.value?.expiresAt" :link-expires-at="inviteLink.details.value?.expiresAt"
:link-max-uses="inviteLink.details.value?.maxUses" :link-max-uses="inviteLink.details.value?.maxUses"
:link-max-uses-limit="remainingUserSlots"
:update-invite-link="inviteLink.update" :update-invite-link="inviteLink.update"
:user-profile-link="userProfileLink" :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" @invite="invitePlayer"
@cancel="cancelInvite" @cancel="cancelInvite"
/> />
@@ -41,6 +51,7 @@
v-if="members.rows.value.length > 0" v-if="members.rows.value.length > 0"
:rows="members.rows.value" :rows="members.rows.value"
:actions-locked="sharedInstanceActionsLocked" :actions-locked="sharedInstanceActionsLocked"
:invite-disabled="!hasRemainingUserSlots"
:invite-pending="inviteLink.pending.value" :invite-pending="inviteLink.pending.value"
:push-update-disabled=" :push-update-disabled="
instance.install_stage !== 'installed' || publishState !== 'idle' || !!offline instance.install_stage !== 'installed' || publishState !== 'idle' || !!offline
@@ -107,7 +118,7 @@
<ButtonStyled color="brand" <ButtonStyled color="brand"
><button ><button
class="!h-10" class="!h-10"
:disabled="inviteLink.pending.value" :disabled="inviteLink.pending.value || !hasRemainingUserSlots"
@click="showInvitePlayers($event)" @click="showInvitePlayers($event)"
> >
<SpinnerIcon <SpinnerIcon
@@ -160,7 +171,7 @@ import { injectSharedInstanceState } from '../use-shared-instance-state'
import SharedInstanceMembersTable from './shared-instance-members-table.vue' import SharedInstanceMembersTable from './shared-instance-members-table.vue'
import SharedInstanceRemoveMemberModal from './shared-instance-remove-member-modal.vue' import SharedInstanceRemoveMemberModal from './shared-instance-remove-member-modal.vue'
import SharedInstanceShareEmptyState from './shared-instance-share-empty-state.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 { useSharedInstanceInviteCandidates } from './use-shared-instance-invite-candidates'
import { useSharedInstanceInviteLink } from './use-shared-instance-invite-link' import { useSharedInstanceInviteLink } from './use-shared-instance-invite-link'
import { useSharedInstanceMembers } from './use-shared-instance-members' import { useSharedInstanceMembers } from './use-shared-instance-members'
@@ -210,6 +221,10 @@ const members = useSharedInstanceMembers({
actionsLocked, actionsLocked,
onError: notifyOperationError, onError: notifyOperationError,
}) })
const remainingUserSlots = computed(() =>
Math.max(0, SHARED_INSTANCE_USER_LIMIT - members.rows.value.length),
)
const hasRemainingUserSlots = computed(() => remainingUserSlots.value > 0)
const { const {
inviteFriends, inviteFriends,
search: searchInviteUsers, search: searchInviteUsers,
@@ -222,6 +237,7 @@ const {
}) })
const inviteLink = useSharedInstanceInviteLink( const inviteLink = useSharedInstanceInviteLink(
computed(() => props.instance.id), computed(() => props.instance.id),
remainingUserSlots,
notifyOperationError, notifyOperationError,
) )
@@ -266,6 +282,10 @@ const messages = defineMessages({
id: 'app.instance.share.empty.invite-friends-button', id: 'app.instance.share.empty.invite-friends-button',
defaultMessage: 'Invite friends', defaultMessage: 'Invite friends',
}, },
userLimitReached: {
id: 'app.instance.share.invite-modal.user-limit-reached',
defaultMessage: 'This instance has reached the {limit}-user limit.',
},
shareModalHeader: { shareModalHeader: {
id: 'app.instance.share.invite-modal.heading', id: 'app.instance.share.invite-modal.heading',
defaultMessage: 'Share {name}', defaultMessage: 'Share {name}',
@@ -305,7 +325,7 @@ const messages = defineMessages({
}) })
function invitePlayer(payload: InvitePlayersInvitePayload) { function invitePlayer(payload: InvitePlayersInvitePayload) {
if (actionsLocked.value) return if (actionsLocked.value || !hasRemainingUserSlots.value) return
if (payload.source === 'search') void requestFriend(payload.user) if (payload.source === 'search') void requestFriend(payload.user)
members.invite(payload.user) members.invite(payload.user)
} }
@@ -316,6 +336,7 @@ function cancelInvite(user: InvitePlayersUser) {
async function showInvitePlayers(event?: MouseEvent) { async function showInvitePlayers(event?: MouseEvent) {
if (actionsLocked.value) return if (actionsLocked.value) return
if (!isSignedIn.value) return signInToShare(event) if (!isSignedIn.value) return signInToShare(event)
if (!hasRemainingUserSlots.value) return
if (requiresUnlink.value) return unlinkModal.value?.show() if (requiresUnlink.value) return unlinkModal.value?.show()
if (await inviteLink.ensure()) invitePlayersModal.value?.show(event) if (await inviteLink.ensure()) invitePlayersModal.value?.show(event)
} }
@@ -25,7 +25,7 @@
<ButtonStyled color="brand"> <ButtonStyled color="brand">
<button <button
class="flex !h-10 shrink-0 items-center gap-2" class="flex !h-10 shrink-0 items-center gap-2"
:disabled="invitePending" :disabled="invitePending || inviteDisabled"
@click="emit('invite', $event)" @click="emit('invite', $event)"
> >
<SpinnerIcon v-if="invitePending" class="animate-spin" aria-hidden="true" /> <SpinnerIcon v-if="invitePending" class="animate-spin" aria-hidden="true" />
@@ -171,6 +171,7 @@ import {
const props = defineProps<{ const props = defineProps<{
rows: ShareRow[] rows: ShareRow[]
actionsLocked?: boolean actionsLocked?: boolean
inviteDisabled?: boolean
invitePending?: boolean invitePending?: boolean
pushUpdateDisabled?: boolean pushUpdateDisabled?: boolean
pushUpdatePending?: boolean pushUpdatePending?: boolean
@@ -2,6 +2,8 @@ export type ShareMethod = 'direct' | 'link'
export type MethodFilter = ShareMethod | 'all' export type MethodFilter = ShareMethod | 'all'
export type ShareTableColumn = 'username' | 'lastPlayed' | 'joined' | 'method' | 'actions' export type ShareTableColumn = 'username' | 'lastPlayed' | 'joined' | 'method' | 'actions'
export const SHARED_INSTANCE_USER_LIMIT = 20
export type ShareRow = { export type ShareRow = {
id: string id: string
username: string username: string
@@ -5,8 +5,11 @@ import { config } from '@/config'
import { toError } from '@/helpers/errors' import { toError } from '@/helpers/errors'
import { create_shared_instance_invite_link } from '@/helpers/instance' import { create_shared_instance_invite_link } from '@/helpers/instance'
const DEFAULT_INVITE_LINK_MAX_USES = 10
export function useSharedInstanceInviteLink( export function useSharedInstanceInviteLink(
instanceId: Ref<string>, instanceId: Ref<string>,
maxInviteUses: Ref<number>,
onError: (error: unknown) => void, onError: (error: unknown) => void,
) { ) {
const details = ref<Awaited<ReturnType<typeof create_shared_instance_invite_link>>>() const details = ref<Awaited<ReturnType<typeof create_shared_instance_invite_link>>>()
@@ -20,10 +23,12 @@ export function useSharedInstanceInviteLink(
async function ensure() { async function ensure() {
if (details.value) return true if (details.value) return true
if (pending.value) return false 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 pending.value = true
try { try {
details.value = await create_shared_instance_invite_link(instanceId.value) details.value = await create_shared_instance_invite_link(instanceId.value, { maxUses })
return true return true
} catch (error) { } catch (error) {
onError(error) onError(error)
@@ -35,6 +40,8 @@ export function useSharedInstanceInviteLink(
async function update(settings: InviteLinkSettings) { async function update(settings: InviteLinkSettings) {
if (!details.value) return if (!details.value) return
const maxInviteLinkUses = Math.floor(maxInviteUses.value)
if (maxInviteLinkUses <= 0) return
pending.value = true pending.value = true
try { try {
@@ -44,7 +51,7 @@ export function useSharedInstanceInviteLink(
) )
details.value = await create_shared_instance_invite_link(instanceId.value, { details.value = await create_shared_instance_invite_link(instanceId.value, {
maxAgeSeconds, maxAgeSeconds,
maxUses: settings.maxUses, maxUses: Math.min(settings.maxUses, maxInviteLinkUses),
replaceInviteId: details.value.inviteId, replaceInviteId: details.value.inviteId,
}) })
} catch (error) { } catch (error) {
@@ -12,7 +12,11 @@ import {
} from '@/helpers/instance' } from '@/helpers/instance'
import type { GameInstance } from '@/helpers/types' 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] type MembersQueryKey = readonly ['sharedInstanceUsers', string]
@@ -139,6 +143,7 @@ export function useSharedInstanceMembers(options: {
if ( if (
options.actionsLocked.value || options.actionsLocked.value ||
exclusiveMutationPending.value || exclusiveMutationPending.value ||
rows.value.length >= SHARED_INSTANCE_USER_LIMIT ||
invitingUserIds.has(normalizedId) || invitingUserIds.has(normalizedId) ||
find(user.id, user.username) find(user.id, user.username)
) { ) {
@@ -135,6 +135,7 @@
ref="inviteLinkEditor" ref="inviteLinkEditor"
:link-expires-at="linkExpiresAt" :link-expires-at="linkExpiresAt"
:link-max-uses="linkMaxUses" :link-max-uses="linkMaxUses"
:link-max-uses-limit="linkMaxUsesLimit"
:update-invite-link="updateInviteLink" :update-invite-link="updateInviteLink"
/> />
</template> </template>
@@ -169,6 +170,7 @@ const props = withDefaults(
link?: string link?: string
linkExpiresAt?: string | Date | null linkExpiresAt?: string | Date | null
linkMaxUses?: number linkMaxUses?: number
linkMaxUsesLimit?: number
updateInviteLink?: (settings: InviteLinkSettings) => Promise<void> updateInviteLink?: (settings: InviteLinkSettings) => Promise<void>
friendsLabel?: string friendsLabel?: string
searchPlaceholder?: string searchPlaceholder?: string
@@ -188,6 +190,7 @@ const props = withDefaults(
suggestions: () => [], suggestions: () => [],
canInvite: true, canInvite: true,
linkMaxUses: 10, linkMaxUses: 10,
linkMaxUsesLimit: 2147483647,
}, },
) )
@@ -21,9 +21,9 @@
v-model="maxUses" v-model="maxUses"
type="number" type="number"
:min="1" :min="1"
:max="2147483647" :max="maximumUses"
:step="1" :step="1"
:disabled="saving" :disabled="saving || maximumUses === 0"
/> />
</div> </div>
</div> </div>
@@ -49,7 +49,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { SaveIcon, SpinnerIcon, XIcon } from '@modrinth/assets' import { SaveIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
import { computed, ref } from 'vue' import { computed, ref, watch } from 'vue'
import { defineMessages, useVIntl } from '../../../composables/i18n' import { defineMessages, useVIntl } from '../../../composables/i18n'
import { injectNotificationManager } from '../../../providers' import { injectNotificationManager } from '../../../providers'
@@ -59,11 +59,17 @@ import StyledInput from '../../base/StyledInput.vue'
import NewModal from '../../modal/NewModal.vue' import NewModal from '../../modal/NewModal.vue'
import type { InviteLinkSettings } from './types' import type { InviteLinkSettings } from './types'
const props = defineProps<{ const props = withDefaults(
linkExpiresAt?: string | Date | null defineProps<{
linkMaxUses: number linkExpiresAt?: string | Date | null
updateInviteLink?: (settings: InviteLinkSettings) => Promise<void> linkMaxUses: number
}>() linkMaxUsesLimit?: number
updateInviteLink?: (settings: InviteLinkSettings) => Promise<void>
}>(),
{
linkMaxUsesLimit: 2147483647,
},
)
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()
const notificationManager = injectNotificationManager(null) const notificationManager = injectNotificationManager(null)
const modal = ref<InstanceType<typeof NewModal> | null>(null) const modal = ref<InstanceType<typeof NewModal> | null>(null)
@@ -72,6 +78,7 @@ const maxUses = ref<number>()
const minimumExpiry = ref(new Date()) const minimumExpiry = ref(new Date())
const maximumExpiry = ref(new Date()) const maximumExpiry = ref(new Date())
const saving = ref(false) const saving = ref(false)
const maximumUses = computed(() => Math.max(0, Math.floor(props.linkMaxUsesLimit)))
const messages = defineMessages({ const messages = defineMessages({
title: { title: {
@@ -109,7 +116,7 @@ const canSave = computed(() => {
date <= maximumExpiry.value && date <= maximumExpiry.value &&
Number.isInteger(maxUses.value ?? 0) && Number.isInteger(maxUses.value ?? 0) &&
(maxUses.value ?? 0) > 0 && (maxUses.value ?? 0) > 0 &&
(maxUses.value ?? 0) <= 2147483647 (maxUses.value ?? 0) <= maximumUses.value
) )
}) })
@@ -141,16 +148,17 @@ function show() {
? maximumExpiry.value ? maximumExpiry.value
: currentExpiry : currentExpiry
expiry.value = formatLocalDate(date) expiry.value = formatLocalDate(date)
maxUses.value = props.linkMaxUses maxUses.value = Math.min(props.linkMaxUses, maximumUses.value)
modal.value?.show() modal.value?.show()
} }
async function save() { async function save() {
const date = parseLocalDate(expiry.value) const date = parseLocalDate(expiry.value)
if (!canSave.value || !date || !props.updateInviteLink) return if (!canSave.value || !date || !props.updateInviteLink) return
const clampedMaxUses = Math.min(maxUses.value ?? 1, maximumUses.value)
saving.value = true saving.value = true
try { try {
await props.updateInviteLink({ expiresAt: date, maxUses: maxUses.value ?? 1 }) await props.updateInviteLink({ expiresAt: date, maxUses: clampedMaxUses })
modal.value?.hide() modal.value?.hide()
} catch (error) { } catch (error) {
notificationManager?.addNotification({ notificationManager?.addNotification({
@@ -163,5 +171,9 @@ async function save() {
} }
} }
watch([maxUses, maximumUses], ([uses, limit]) => {
if (uses !== undefined && uses > limit) maxUses.value = limit
})
defineExpose({ show }) defineExpose({ show })
</script> </script>