mirror of
https://github.com/modrinth/code.git
synced 2026-08-31 12:05:53 +00:00
fix: 20 user cap
This commit is contained in:
@@ -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,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+23
-11
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user