mirror of
https://github.com/modrinth/code.git
synced 2026-07-31 13:16:38 +00:00
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:
Generated
+4
-3
@@ -4806,7 +4806,7 @@ dependencies = [
|
||||
"libc",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"socket2 0.5.10",
|
||||
"socket2 0.6.5",
|
||||
"system-configuration",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
@@ -8016,7 +8016,7 @@ dependencies = [
|
||||
"quinn-udp",
|
||||
"rustc-hash",
|
||||
"rustls 0.23.32",
|
||||
"socket2 0.5.10",
|
||||
"socket2 0.6.5",
|
||||
"thiserror 2.0.17",
|
||||
"tokio",
|
||||
"tracing",
|
||||
@@ -8053,7 +8053,7 @@ dependencies = [
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"socket2 0.5.10",
|
||||
"socket2 0.6.5",
|
||||
"tracing",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
@@ -11020,6 +11020,7 @@ dependencies = [
|
||||
"heck 0.5.0",
|
||||
"hickory-resolver 0.25.2",
|
||||
"httpdate",
|
||||
"image",
|
||||
"indicatif",
|
||||
"itertools 0.14.0",
|
||||
"modrinth-content-management",
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
|
||||
+13
@@ -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',
|
||||
|
||||
+34
@@ -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>
|
||||
|
||||
+82
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+46
-1
@@ -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:
|
||||
|
||||
+13
-2
@@ -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[]) {
|
||||
|
||||
+53
-18
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
) {
|
||||
|
||||
@@ -227,6 +227,8 @@ fn main() {
|
||||
"instance_share_get_users",
|
||||
"instance_share_invite_users",
|
||||
"instance_share_create_invite_link",
|
||||
"instance_share_get_invites",
|
||||
"instance_share_revoke_invite",
|
||||
"instance_share_remove_users",
|
||||
"instance_share_get_publish_preview",
|
||||
"instance_share_publish",
|
||||
|
||||
@@ -56,6 +56,8 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
|
||||
instance_share_get_users,
|
||||
instance_share_invite_users,
|
||||
instance_share_create_invite_link,
|
||||
instance_share_get_invites,
|
||||
instance_share_revoke_invite,
|
||||
instance_share_remove_users,
|
||||
instance_share_get_publish_preview,
|
||||
instance_share_publish,
|
||||
@@ -825,6 +827,27 @@ pub async fn instance_share_create_invite_link(
|
||||
.await?)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn instance_share_get_invites(
|
||||
instance_id: &str,
|
||||
) -> Result<Vec<theseus::instance::SharedInstanceInvite>> {
|
||||
Ok(theseus::instance::get_shared_instance_invites(instance_id).await?)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn instance_share_revoke_invite(
|
||||
instance_id: &str,
|
||||
invite_id: String,
|
||||
) -> Result<()> {
|
||||
Ok(
|
||||
theseus::instance::revoke_shared_instance_invite(
|
||||
instance_id,
|
||||
invite_id,
|
||||
)
|
||||
.await?,
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn instance_share_remove_users(
|
||||
instance_id: &str,
|
||||
|
||||
+13
-2
@@ -91,14 +91,25 @@ macro_rules! impl_serialize {
|
||||
theseus::ErrorKind::SharedInstanceUnavailable(reason) => Some(reason),
|
||||
_ => None,
|
||||
};
|
||||
let code = match theseus_error.raw.as_ref() {
|
||||
theseus::ErrorKind::SharedInstanceUnavailable(_) => {
|
||||
Some("shared_instance_unavailable")
|
||||
}
|
||||
theseus::ErrorKind::SharedInstancesApiError(_) => {
|
||||
Some("shared_instances_api_error")
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let mut state = serializer.serialize_struct(
|
||||
"Theseus",
|
||||
if unavailable_reason.is_some() { 4 } else { 2 },
|
||||
2 + usize::from(code.is_some()) + usize::from(unavailable_reason.is_some()),
|
||||
)?;
|
||||
state.serialize_field("field_name", "Theseus")?;
|
||||
state.serialize_field("message", &theseus_error.to_string())?;
|
||||
if let Some(code) = code {
|
||||
state.serialize_field("code", code)?;
|
||||
}
|
||||
if let Some(reason) = unavailable_reason {
|
||||
state.serialize_field("code", "shared_instance_unavailable")?;
|
||||
state.serialize_field("reason", reason)?;
|
||||
}
|
||||
state.end()
|
||||
|
||||
@@ -604,13 +604,18 @@ async function loadSharedInstanceVersionContent(
|
||||
dependency.project_id ? [dependency.project_id] : [],
|
||||
)
|
||||
const projectIds = [
|
||||
...new Set([...versions.map((version) => version.project_id), ...dependencyProjectIds]),
|
||||
...new Set([
|
||||
...versions.map((version) => version.project_id),
|
||||
...dependencyProjectIds,
|
||||
...(modpackVersion ? [modpackVersion.project_id] : []),
|
||||
]),
|
||||
]
|
||||
const projects = projectIds.length
|
||||
? await client.labrinth.projects_v2.getMultiple(projectIds)
|
||||
: []
|
||||
const versionsById = new Map(versions.map((version) => [version.id, version]))
|
||||
const projectsById = new Map(projects.map((project) => [project.id, project]))
|
||||
const modpackProject = modpackVersion ? projectsById.get(modpackVersion.project_id) : undefined
|
||||
|
||||
const directContent: ContentItem[] = [...new Set(directVersionIds)].flatMap((versionId) => {
|
||||
const version = versionsById.get(versionId)
|
||||
@@ -633,13 +638,19 @@ async function loadSharedInstanceVersionContent(
|
||||
const fileName =
|
||||
primaryFile?.filename ?? dependency.file_name ?? project?.title ?? version?.name ?? 'Unknown'
|
||||
|
||||
return sharedInstanceContentItem(
|
||||
const item = sharedInstanceContentItem(
|
||||
version,
|
||||
project,
|
||||
fileName,
|
||||
dependency.project_id ?? fileName,
|
||||
!project && !version,
|
||||
)
|
||||
return modpackProject
|
||||
? {
|
||||
...item,
|
||||
source: { project: modpackProject },
|
||||
}
|
||||
: item
|
||||
})
|
||||
|
||||
const externalContent: ContentItem[] = instanceVersion.external_files.map((file, index) => ({
|
||||
|
||||
@@ -64,11 +64,18 @@
|
||||
</template>
|
||||
</div>
|
||||
<div v-else-if="report.item_type === 'shared-instance'" class="item-info">
|
||||
<div class="backed-svg" :class="{ raised: raised }">
|
||||
<Avatar
|
||||
v-if="report.shared_instance"
|
||||
:src="report.shared_instance.icon"
|
||||
size="xs"
|
||||
no-shadow
|
||||
:raised="raised"
|
||||
/>
|
||||
<div v-else class="backed-svg" :class="{ raised: raised }">
|
||||
<BoxesIcon />
|
||||
</div>
|
||||
<div class="stacked">
|
||||
<span class="title">Shared instance</span>
|
||||
<span class="title">{{ report.shared_instance?.name ?? 'Shared instance' }}</span>
|
||||
<span>
|
||||
Version {{ report.shared_instance_version_id ?? 'unknown' }} ·
|
||||
<CopyCode :text="report.item_id" />
|
||||
@@ -89,8 +96,7 @@
|
||||
<ThreadSummary
|
||||
v-if="thread"
|
||||
:thread="thread"
|
||||
class="thread-summary"
|
||||
:raised="raised"
|
||||
class="thread-summary !bg-surface-2"
|
||||
:link="`/${moderation ? 'moderation' : 'dashboard'}/report/${report.id}`"
|
||||
:auth="auth"
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="pb-20">
|
||||
<section>
|
||||
<Breadcrumbs
|
||||
v-if="breadcrumbsStack"
|
||||
@@ -111,6 +111,16 @@ const { data: project } = useQuery({
|
||||
enabled: computed(() => !!projectId.value),
|
||||
})
|
||||
|
||||
const sharedInstanceId = computed(() =>
|
||||
rawReport.value?.item_type === 'shared-instance' ? rawReport.value.item_id : null,
|
||||
)
|
||||
|
||||
const { data: sharedInstance } = useQuery({
|
||||
queryKey: computed(() => ['shared-instance', sharedInstanceId.value]),
|
||||
queryFn: () => client.sharedinstances.instances_v1.get(sharedInstanceId.value),
|
||||
enabled: computed(() => !!sharedInstanceId.value),
|
||||
})
|
||||
|
||||
// Assemble the full report object
|
||||
const report = computed(() => {
|
||||
if (!rawReport.value) return null
|
||||
@@ -118,6 +128,7 @@ const report = computed(() => {
|
||||
...rawReport.value,
|
||||
project: project.value ?? null,
|
||||
version: version.value ?? null,
|
||||
shared_instance: sharedInstance.value ?? null,
|
||||
reporterUser: (users.value || []).find((user) => user.id === rawReport.value.reporter),
|
||||
user:
|
||||
rawReport.value.item_type === 'user'
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
:moderation="moderation"
|
||||
raised
|
||||
:auth="auth"
|
||||
class="universal-card recessed"
|
||||
class="card-shadow mb-4 rounded-2xl border border-solid border-surface-4 bg-surface-3 p-4"
|
||||
/>
|
||||
<p v-if="filteredReports.length === 0">You don't have any active reports.</p>
|
||||
</template>
|
||||
@@ -51,7 +51,7 @@ const MAX_REPORTS = 1500
|
||||
|
||||
const { data: rawReportsData } = useQuery({
|
||||
queryKey: ['reports', MAX_REPORTS],
|
||||
queryFn: () => client.labrinth.reports_v3.list({ count: MAX_REPORTS }),
|
||||
queryFn: () => client.labrinth.reports_v3.list({ count: MAX_REPORTS, all: false }),
|
||||
placeholderData: [],
|
||||
})
|
||||
|
||||
@@ -67,6 +67,13 @@ const versionReports = computed(() =>
|
||||
const versionIds = computed(() => [
|
||||
...new Set(versionReports.value.map((report) => report.item_id)),
|
||||
])
|
||||
const sharedInstanceIds = computed(() => [
|
||||
...new Set(
|
||||
rawReports.value
|
||||
.filter((report) => report.item_type === 'shared-instance')
|
||||
.map((report) => report.item_id),
|
||||
),
|
||||
])
|
||||
const userIds = computed(() => [...new Set(reporterUsers.value.concat(reportedUsers.value))])
|
||||
const threadIds = computed(() => [
|
||||
...new Set(
|
||||
@@ -94,6 +101,21 @@ const { data: versions } = useQuery({
|
||||
placeholderData: [],
|
||||
})
|
||||
|
||||
const { data: sharedInstances } = useQuery({
|
||||
queryKey: computed(() => ['shared-instances', sharedInstanceIds.value]),
|
||||
queryFn: async () => {
|
||||
const results = await Promise.allSettled(
|
||||
sharedInstanceIds.value.map(async (id) => ({
|
||||
id,
|
||||
instance: await client.sharedinstances.instances_v1.get(id),
|
||||
})),
|
||||
)
|
||||
return results.flatMap((result) => (result.status === 'fulfilled' ? [result.value] : []))
|
||||
},
|
||||
enabled: computed(() => sharedInstanceIds.value.length > 0),
|
||||
placeholderData: [],
|
||||
})
|
||||
|
||||
const { data: threads } = useQuery({
|
||||
queryKey: computed(() => ['threads', threadIds.value]),
|
||||
queryFn: () =>
|
||||
@@ -123,6 +145,9 @@ const { data: projects } = useQuery({
|
||||
const userMap = computed(() => new Map(users.value.map((u) => [u.id, u])))
|
||||
const versionMap = computed(() => new Map(versions.value.map((v) => [v.id, v])))
|
||||
const projectMap = computed(() => new Map(projects.value.map((p) => [p.id, p])))
|
||||
const sharedInstanceMap = computed(
|
||||
() => new Map(sharedInstances.value.map(({ id, instance }) => [id, instance])),
|
||||
)
|
||||
const threadMap = computed(() => new Map(threads.value.map((t) => [t.id, t])))
|
||||
|
||||
const reports = computed(() =>
|
||||
@@ -136,6 +161,8 @@ const reports = computed(() =>
|
||||
} else if (report.item_type === 'version') {
|
||||
enrichedReport.version = versionMap.value.get(report.item_id)
|
||||
enrichedReport.project = projectMap.value.get(enrichedReport.version?.project_id)
|
||||
} else if (report.item_type === 'shared-instance') {
|
||||
enrichedReport.shared_instance = sharedInstanceMap.value.get(report.item_id)
|
||||
}
|
||||
if (report.thread_id) {
|
||||
const thread = threadMap.value.get(report.thread_id)
|
||||
@@ -149,7 +176,7 @@ const reports = computed(() =>
|
||||
const filteredReports = computed(() =>
|
||||
reports.value?.filter(
|
||||
(x) =>
|
||||
(props.moderation || x.reporterUser?.id === props.auth.user.id) &&
|
||||
(props.moderation || x.reporter === props.auth.user.id) &&
|
||||
(viewMode.value === 'open' ? x.open : !x.open) &&
|
||||
(reasonFilter.value === 'All' || reasonFilter.value === x.report_type),
|
||||
),
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<template>
|
||||
<div>
|
||||
<section class="universal-card">
|
||||
<h2 class="text-2xl">{{ formatMessage(messages.reportsTitle) }}</h2>
|
||||
<ReportsList :auth="auth" />
|
||||
</section>
|
||||
<div class="flex flex-col gap-4 pb-20 lg:pl-4 lg:pt-1.5">
|
||||
<h2 class="m-0 text-xl font-semibold text-contrast md:text-2xl">
|
||||
{{ formatMessage(messages.reportsTitle) }}
|
||||
</h2>
|
||||
<ReportsList :auth="auth" />
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
|
||||
@@ -235,9 +235,12 @@ const { data: allReports } = await useLazyAsyncData('new-moderation-reports', as
|
||||
let reports: Labrinth.Reports.v3.Report[]
|
||||
let hasMoreReports = true
|
||||
while (hasMoreReports) {
|
||||
reports = (await useBaseFetch(`report?count=${REPORT_ENDPOINT_COUNT}&offset=${currentOffset}`, {
|
||||
apiVersion: 3,
|
||||
})) as Labrinth.Reports.v3.Report[]
|
||||
reports = (await useBaseFetch(
|
||||
`report?count=${REPORT_ENDPOINT_COUNT}&offset=${currentOffset}&all=true`,
|
||||
{
|
||||
apiVersion: 3,
|
||||
},
|
||||
)) as Labrinth.Reports.v3.Report[]
|
||||
|
||||
hasMoreReports = reports.length > 0
|
||||
if (!hasMoreReports) {
|
||||
|
||||
@@ -4,6 +4,7 @@ use crate::database::models::ids::*;
|
||||
use crate::database::models::notifications_template_item::{
|
||||
NotificationTemplate, get_or_set_cached_dynamic_html,
|
||||
};
|
||||
use crate::database::models::report_item::DBReport;
|
||||
use crate::database::models::{
|
||||
DBOrganization, DBProject, DBUser, DatabaseError,
|
||||
};
|
||||
@@ -11,10 +12,12 @@ use crate::env::ENV;
|
||||
use crate::models::v3::notifications::NotificationBody;
|
||||
use crate::routes::ApiError;
|
||||
use crate::util::error::Context;
|
||||
use crate::util::http::HTTP_CLIENT;
|
||||
use ariadne::ids::base62_impl::to_base62;
|
||||
use futures::TryFutureExt;
|
||||
use lettre::Message;
|
||||
use lettre::message::{Mailbox, MultiPart, SinglePart};
|
||||
use serde::Deserialize;
|
||||
use sqlx::query;
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
@@ -312,6 +315,70 @@ enum EmailTemplate {
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SharedInstance {
|
||||
name: String,
|
||||
}
|
||||
|
||||
async fn resolve_report_title(
|
||||
exec: &mut PgTransaction<'_>,
|
||||
report_id: DBReportId,
|
||||
title: String,
|
||||
) -> Result<String, ApiError> {
|
||||
if title != "unknown" {
|
||||
return Ok(title);
|
||||
}
|
||||
|
||||
let Some(report) = DBReport::get(report_id, &mut *exec).await? else {
|
||||
return Ok(title);
|
||||
};
|
||||
let Some(shared_instance_id) = report.shared_instance_id else {
|
||||
return Ok(title);
|
||||
};
|
||||
|
||||
let instance_id = to_base62(shared_instance_id.0 as u64);
|
||||
let with_version = |name: String| match report.shared_instance_version_id {
|
||||
Some(version) => format!("{name} (version {version})"),
|
||||
None => name,
|
||||
};
|
||||
let fallback = with_version(format!("shared instance {instance_id}"));
|
||||
let response = HTTP_CLIENT
|
||||
.get(format!(
|
||||
"{}/v1/instances/{instance_id}",
|
||||
ENV.SHARED_INSTANCES_URL
|
||||
))
|
||||
.bearer_auth(&ENV.SHARED_INSTANCES_KEY)
|
||||
.send()
|
||||
.await
|
||||
.and_then(reqwest::Response::error_for_status);
|
||||
|
||||
let response = match response {
|
||||
Ok(response) => response,
|
||||
Err(error) => {
|
||||
warn!(
|
||||
%error,
|
||||
report_id = report_id.0,
|
||||
%instance_id,
|
||||
"Failed to fetch shared instance name for report email"
|
||||
);
|
||||
return Ok(fallback);
|
||||
}
|
||||
};
|
||||
|
||||
match response.json::<SharedInstance>().await {
|
||||
Ok(instance) => Ok(with_version(instance.name)),
|
||||
Err(error) => {
|
||||
warn!(
|
||||
%error,
|
||||
report_id = report_id.0,
|
||||
%instance_id,
|
||||
"Failed to parse shared instance name for report email"
|
||||
);
|
||||
Ok(fallback)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn collect_template_variables(
|
||||
exec: &mut PgTransaction<'_>,
|
||||
redis: &RedisPool,
|
||||
@@ -359,7 +426,15 @@ async fn collect_template_variables(
|
||||
.await?;
|
||||
|
||||
map.insert(REPORT_ID, to_base62(report_id.0));
|
||||
map.insert(REPORT_TITLE, result.title);
|
||||
map.insert(
|
||||
REPORT_TITLE,
|
||||
resolve_report_title(
|
||||
exec,
|
||||
DBReportId(report_id.0 as i64),
|
||||
result.title,
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
map.insert(REPORT_DATE, date_human_readable(result.created));
|
||||
Ok(EmailTemplate::Static(map))
|
||||
}
|
||||
@@ -380,7 +455,15 @@ async fn collect_template_variables(
|
||||
.fetch_one(&mut *exec)
|
||||
.await?;
|
||||
|
||||
map.insert(REPORT_TITLE, result.title);
|
||||
map.insert(
|
||||
REPORT_TITLE,
|
||||
resolve_report_title(
|
||||
exec,
|
||||
DBReportId(report_id.0 as i64),
|
||||
result.title,
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
map.insert(NEWREPORT_ID, to_base62(report_id.0));
|
||||
Ok(EmailTemplate::Static(map))
|
||||
}
|
||||
|
||||
@@ -60,16 +60,13 @@ pub async fn report_create(
|
||||
pub struct ReportsRequestOptions {
|
||||
#[serde(default = "default_count")]
|
||||
count: u16,
|
||||
#[serde(default = "default_all")]
|
||||
#[serde(default)]
|
||||
all: bool,
|
||||
}
|
||||
|
||||
fn default_count() -> u16 {
|
||||
100
|
||||
}
|
||||
fn default_all() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Get open reports for the current user.
|
||||
#[utoipa::path(
|
||||
|
||||
@@ -320,16 +320,13 @@ pub struct ReportsRequestOptions {
|
||||
pub count: u16,
|
||||
#[serde(default)]
|
||||
pub offset: u32,
|
||||
#[serde(default = "default_all")]
|
||||
#[serde(default)]
|
||||
pub all: bool,
|
||||
}
|
||||
|
||||
fn default_count() -> u16 {
|
||||
100
|
||||
}
|
||||
fn default_all() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
tag = "reports",
|
||||
|
||||
@@ -47,6 +47,7 @@ governor = { workspace = true }
|
||||
heck = { workspace = true }
|
||||
hickory-resolver = { workspace = true }
|
||||
httpdate = { workspace = true }
|
||||
image = { workspace = true, features = ["gif", "jpeg", "png", "webp"] }
|
||||
indicatif = { workspace = true, optional = true }
|
||||
itertools = { workspace = true }
|
||||
modrinth-content-management = { workspace = true }
|
||||
|
||||
@@ -4,6 +4,7 @@ mod content;
|
||||
mod content_set_diff;
|
||||
mod export_mrpack;
|
||||
mod get;
|
||||
mod icon;
|
||||
mod install;
|
||||
mod lifecycle;
|
||||
mod paths;
|
||||
@@ -21,9 +22,13 @@ pub use self::export_mrpack::{
|
||||
create_mrpack_json, export_mrpack, get_pack_export_candidates,
|
||||
};
|
||||
pub use self::get::{get, get_many, list};
|
||||
pub use self::icon::edit_icon;
|
||||
pub(crate) use self::icon::{
|
||||
cache_icon, cache_icon_from_path, migrate_legacy_icons,
|
||||
};
|
||||
pub use self::install::get_optimal_jre_key;
|
||||
pub(crate) use self::lifecycle::create;
|
||||
pub use self::lifecycle::{edit, edit_icon, remove};
|
||||
pub use self::lifecycle::{edit, remove};
|
||||
pub use self::paths::{get_full_path, get_mod_full_path};
|
||||
pub use self::projects::{
|
||||
InstallProjectWithDependenciesRequest, add_project_from_path,
|
||||
@@ -42,17 +47,19 @@ pub(crate) use self::shared::{
|
||||
};
|
||||
pub use self::shared::{
|
||||
SharedInstanceExternalFilePreview, SharedInstanceInstallPreview,
|
||||
SharedInstanceInviteInstallPreview, SharedInstanceInviteLink,
|
||||
SharedInstanceJoinType, SharedInstancePublishPreview,
|
||||
SharedInstanceUpdateDiff, SharedInstanceUpdateDiffType,
|
||||
SharedInstanceUpdatePreview, SharedInstanceUser, SharedInstanceUsers,
|
||||
SharedInstanceInvite, SharedInstanceInviteInstallPreview,
|
||||
SharedInstanceInviteLink, SharedInstanceJoinType,
|
||||
SharedInstancePublishPreview, SharedInstanceUpdateDiff,
|
||||
SharedInstanceUpdateDiffType, SharedInstanceUpdatePreview,
|
||||
SharedInstanceUser, SharedInstanceUsers,
|
||||
accept_pending_shared_instance_invite,
|
||||
accept_shared_instance_invite_for_install,
|
||||
can_active_user_use_shared_instances, create_shared_instance_invite_link,
|
||||
decline_pending_shared_instance_invite,
|
||||
get_shared_instance_install_preview, get_shared_instance_publish_preview,
|
||||
get_shared_instance_update_preview, get_shared_instance_users,
|
||||
install_shared_instance, invite_shared_instance_users,
|
||||
publish_shared_instance, remove_shared_instance_users,
|
||||
get_shared_instance_install_preview, get_shared_instance_invites,
|
||||
get_shared_instance_publish_preview, get_shared_instance_update_preview,
|
||||
get_shared_instance_users, install_shared_instance,
|
||||
invite_shared_instance_users, publish_shared_instance,
|
||||
remove_shared_instance_users, revoke_shared_instance_invite,
|
||||
unlink_shared_instance, unpublish_shared_instance, update_shared_instance,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
use crate::event::InstancePayloadType;
|
||||
use crate::event::emit::emit_instance;
|
||||
use crate::state::instances::adapters::sqlite::instance_rows;
|
||||
use crate::state::{EditInstance, State};
|
||||
use crate::util::fetch::{sha1_async, write};
|
||||
use crate::util::io;
|
||||
use bytes::Bytes;
|
||||
use std::fs::File as StdFile;
|
||||
use std::io::{BufRead, BufReader, Cursor, Seek};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const INSTANCE_ICON_MAX_BYTES: usize = 4 * 1024 * 1024;
|
||||
const INSTANCE_ICON_MAX_DIMENSION: u32 = 512;
|
||||
const INSTANCE_ICON_MAX_SOURCE_DIMENSION: u32 = 8_192;
|
||||
const INSTANCE_ICON_MAX_DECODE_BYTES: u64 = 64 * 1024 * 1024;
|
||||
|
||||
enum LegacyIconAction {
|
||||
Keep,
|
||||
Normalize,
|
||||
Remove,
|
||||
}
|
||||
|
||||
pub async fn edit_icon(
|
||||
instance_id: &str,
|
||||
icon_path: Option<&Path>,
|
||||
) -> crate::Result<()> {
|
||||
let state = State::get().await?;
|
||||
let icon_path = if let Some(icon_path) = icon_path {
|
||||
Some(
|
||||
cache_icon_from_path(icon_path, &state)
|
||||
.await?
|
||||
.to_string_lossy()
|
||||
.to_string(),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
apply_instance_icon(instance_id, icon_path, &state).await
|
||||
}
|
||||
|
||||
pub(crate) async fn cache_icon(
|
||||
bytes: Bytes,
|
||||
state: &State,
|
||||
) -> crate::Result<PathBuf> {
|
||||
let bytes = tokio::task::spawn_blocking(move || {
|
||||
if looks_like_svg(&bytes) {
|
||||
return Err(svg_not_supported_error());
|
||||
}
|
||||
|
||||
normalize_raster(Cursor::new(bytes))
|
||||
})
|
||||
.await??;
|
||||
|
||||
write_cached_icon(bytes, state).await
|
||||
}
|
||||
|
||||
pub(crate) async fn cache_icon_from_path(
|
||||
icon_path: &Path,
|
||||
state: &State,
|
||||
) -> crate::Result<PathBuf> {
|
||||
let icon_path = icon_path.to_path_buf();
|
||||
let bytes = tokio::task::spawn_blocking(move || {
|
||||
let file = StdFile::open(&icon_path).map_err(|error| {
|
||||
crate::ErrorKind::InputError(format!(
|
||||
"Could not open instance icon {}: {error}",
|
||||
icon_path.display()
|
||||
))
|
||||
})?;
|
||||
let mut reader = BufReader::new(file);
|
||||
let looks_like_svg = {
|
||||
let bytes = reader.fill_buf().map_err(|error| {
|
||||
crate::ErrorKind::InputError(format!(
|
||||
"Could not inspect instance icon {}: {error}",
|
||||
icon_path.display()
|
||||
))
|
||||
})?;
|
||||
looks_like_svg(bytes)
|
||||
};
|
||||
if has_svg_extension(&icon_path) || looks_like_svg {
|
||||
return Err(svg_not_supported_error());
|
||||
}
|
||||
|
||||
normalize_raster(reader)
|
||||
})
|
||||
.await??;
|
||||
|
||||
write_cached_icon(bytes, state).await
|
||||
}
|
||||
|
||||
pub(crate) async fn migrate_legacy_icons() -> crate::Result<()> {
|
||||
let state = State::get().await?;
|
||||
let instances = instance_rows::list_instances(&state.pool).await?;
|
||||
|
||||
for instance in instances {
|
||||
let Some(icon_path) = instance.icon_path.as_deref() else {
|
||||
continue;
|
||||
};
|
||||
let action = match inspect_legacy_icon(Path::new(icon_path)) {
|
||||
Ok(action) => action,
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
instance_id = instance.id,
|
||||
icon_path,
|
||||
error = %error,
|
||||
"Failed to inspect legacy instance icon"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
match action {
|
||||
LegacyIconAction::Keep => {}
|
||||
LegacyIconAction::Normalize => {
|
||||
if let Err(error) =
|
||||
edit_icon(&instance.id, Some(Path::new(icon_path))).await
|
||||
{
|
||||
tracing::warn!(
|
||||
instance_id = instance.id,
|
||||
icon_path,
|
||||
error = %error,
|
||||
"Failed to normalize legacy instance icon"
|
||||
);
|
||||
}
|
||||
}
|
||||
LegacyIconAction::Remove => {
|
||||
if let Err(error) =
|
||||
apply_instance_icon(&instance.id, None, &state).await
|
||||
{
|
||||
tracing::warn!(
|
||||
instance_id = instance.id,
|
||||
icon_path,
|
||||
error = %error,
|
||||
"Failed to remove legacy SVG instance icon"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn apply_instance_icon(
|
||||
instance_id: &str,
|
||||
icon_path: Option<String>,
|
||||
state: &State,
|
||||
) -> crate::Result<()> {
|
||||
let instance =
|
||||
instance_rows::get_instance_display_info(instance_id, &state.pool)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError("Unknown instance".to_string())
|
||||
})?;
|
||||
crate::state::edit_instance(
|
||||
instance_id,
|
||||
EditInstance {
|
||||
icon_path: Some(icon_path.clone()),
|
||||
..EditInstance::default()
|
||||
},
|
||||
&state.pool,
|
||||
)
|
||||
.await?;
|
||||
|
||||
if let Err(error) = super::shared::sync_shared_instance_icon(
|
||||
instance_id,
|
||||
icon_path.as_deref(),
|
||||
state,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
instance_id,
|
||||
error = %error,
|
||||
"Failed to sync shared instance icon"
|
||||
);
|
||||
}
|
||||
|
||||
emit_instance(&instance.id, InstancePayloadType::Edited).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn write_cached_icon(
|
||||
bytes: Bytes,
|
||||
state: &State,
|
||||
) -> crate::Result<PathBuf> {
|
||||
if bytes.len() >= INSTANCE_ICON_MAX_BYTES {
|
||||
return Err(icon_too_large_error());
|
||||
}
|
||||
|
||||
let hash = sha1_async(bytes.clone()).await?;
|
||||
let path = state
|
||||
.directories
|
||||
.caches_dir()
|
||||
.join("icons")
|
||||
.join(format!("{hash}.png"));
|
||||
write(&path, &bytes, &state.io_semaphore).await?;
|
||||
|
||||
Ok(io::canonicalize(path)?)
|
||||
}
|
||||
|
||||
fn normalize_raster<R>(reader: R) -> crate::Result<Bytes>
|
||||
where
|
||||
R: BufRead + Seek,
|
||||
{
|
||||
let mut reader = image::ImageReader::new(reader)
|
||||
.with_guessed_format()
|
||||
.map_err(|error| {
|
||||
crate::ErrorKind::InputError(format!(
|
||||
"Could not identify instance icon format: {error}"
|
||||
))
|
||||
})?;
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_image_width = Some(INSTANCE_ICON_MAX_SOURCE_DIMENSION);
|
||||
limits.max_image_height = Some(INSTANCE_ICON_MAX_SOURCE_DIMENSION);
|
||||
limits.max_alloc = Some(INSTANCE_ICON_MAX_DECODE_BYTES);
|
||||
reader.limits(limits);
|
||||
|
||||
let image = reader.decode().map_err(|error| {
|
||||
crate::ErrorKind::InputError(format!(
|
||||
"Could not decode instance icon: {error}"
|
||||
))
|
||||
})?;
|
||||
let image = if image.width() > INSTANCE_ICON_MAX_DIMENSION
|
||||
|| image.height() > INSTANCE_ICON_MAX_DIMENSION
|
||||
{
|
||||
image.resize(
|
||||
INSTANCE_ICON_MAX_DIMENSION,
|
||||
INSTANCE_ICON_MAX_DIMENSION,
|
||||
image::imageops::FilterType::Lanczos3,
|
||||
)
|
||||
} else {
|
||||
image
|
||||
};
|
||||
let mut normalized = Cursor::new(Vec::new());
|
||||
image::DynamicImage::ImageRgba8(image.to_rgba8())
|
||||
.write_to(&mut normalized, image::ImageFormat::Png)
|
||||
.map_err(|error| {
|
||||
crate::ErrorKind::InputError(format!(
|
||||
"Could not encode instance icon as PNG: {error}"
|
||||
))
|
||||
})?;
|
||||
|
||||
validate_normalized_icon(normalized.into_inner())
|
||||
}
|
||||
|
||||
fn inspect_legacy_icon(icon_path: &Path) -> crate::Result<LegacyIconAction> {
|
||||
let metadata = std::fs::metadata(icon_path).map_err(|error| {
|
||||
crate::ErrorKind::InputError(format!(
|
||||
"Could not inspect instance icon {}: {error}",
|
||||
icon_path.display()
|
||||
))
|
||||
})?;
|
||||
let file = StdFile::open(icon_path).map_err(|error| {
|
||||
crate::ErrorKind::InputError(format!(
|
||||
"Could not open instance icon {}: {error}",
|
||||
icon_path.display()
|
||||
))
|
||||
})?;
|
||||
let mut reader = BufReader::new(file);
|
||||
let bytes = reader.fill_buf().map_err(|error| {
|
||||
crate::ErrorKind::InputError(format!(
|
||||
"Could not inspect instance icon {}: {error}",
|
||||
icon_path.display()
|
||||
))
|
||||
})?;
|
||||
|
||||
if has_svg_extension(icon_path) || looks_like_svg(bytes) {
|
||||
return Ok(LegacyIconAction::Remove);
|
||||
}
|
||||
|
||||
if metadata.len() < INSTANCE_ICON_MAX_BYTES as u64
|
||||
&& image::guess_format(bytes).ok() == Some(image::ImageFormat::Png)
|
||||
{
|
||||
return Ok(LegacyIconAction::Keep);
|
||||
}
|
||||
|
||||
Ok(LegacyIconAction::Normalize)
|
||||
}
|
||||
|
||||
fn validate_normalized_icon(normalized: Vec<u8>) -> crate::Result<Bytes> {
|
||||
if normalized.len() >= INSTANCE_ICON_MAX_BYTES {
|
||||
return Err(icon_too_large_error());
|
||||
}
|
||||
|
||||
Ok(Bytes::from(normalized))
|
||||
}
|
||||
|
||||
fn looks_like_svg(bytes: &[u8]) -> bool {
|
||||
if image::guess_format(bytes).is_ok() {
|
||||
return false;
|
||||
}
|
||||
|
||||
bytes[..bytes.len().min(1_024)]
|
||||
.windows(4)
|
||||
.any(|window| window.eq_ignore_ascii_case(b"<svg"))
|
||||
}
|
||||
|
||||
fn has_svg_extension(path: &Path) -> bool {
|
||||
path.extension()
|
||||
.and_then(|extension| extension.to_str())
|
||||
.is_some_and(|extension| extension.eq_ignore_ascii_case("svg"))
|
||||
}
|
||||
|
||||
fn icon_too_large_error() -> crate::Error {
|
||||
crate::ErrorKind::InputError(format!(
|
||||
"Instance icons must be smaller than {INSTANCE_ICON_MAX_BYTES} bytes"
|
||||
))
|
||||
.into()
|
||||
}
|
||||
|
||||
fn svg_not_supported_error() -> crate::Error {
|
||||
crate::ErrorKind::InputError(
|
||||
"SVG instance icons are not supported".to_string(),
|
||||
)
|
||||
.into()
|
||||
}
|
||||
@@ -5,8 +5,6 @@ use crate::state::{
|
||||
CreateInstance, EditInstance, InstanceLink, InstanceMetadata, ModLoader,
|
||||
State,
|
||||
};
|
||||
use crate::util::io;
|
||||
use std::path::Path;
|
||||
|
||||
#[tracing::instrument]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -73,60 +71,6 @@ pub async fn edit(
|
||||
Ok(instance)
|
||||
}
|
||||
|
||||
pub async fn edit_icon(
|
||||
instance_id: &str,
|
||||
icon_path: Option<&Path>,
|
||||
) -> crate::Result<()> {
|
||||
let state = State::get().await?;
|
||||
let instance =
|
||||
instance_rows::get_instance_display_info(instance_id, &state.pool)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError("Unknown instance".to_string())
|
||||
})?;
|
||||
let icon_path = if let Some(icon) = icon_path {
|
||||
let bytes = io::read(icon).await?;
|
||||
let file = crate::util::fetch::write_cached_icon(
|
||||
&icon.to_string_lossy(),
|
||||
&state.directories.caches_dir(),
|
||||
bytes::Bytes::from(bytes),
|
||||
&state.io_semaphore,
|
||||
)
|
||||
.await?;
|
||||
Some(file.to_string_lossy().to_string())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
crate::state::edit_instance(
|
||||
instance_id,
|
||||
EditInstance {
|
||||
icon_path: Some(icon_path.clone()),
|
||||
..EditInstance::default()
|
||||
},
|
||||
&state.pool,
|
||||
)
|
||||
.await?;
|
||||
|
||||
if let Err(error) = super::shared::sync_shared_instance_icon(
|
||||
instance_id,
|
||||
icon_path.as_deref(),
|
||||
&state,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
instance_id,
|
||||
error = %error,
|
||||
"Failed to sync shared instance icon"
|
||||
);
|
||||
}
|
||||
|
||||
emit_instance(&instance.id, InstancePayloadType::Edited).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn remove(instance_id: &str) -> crate::Result<()> {
|
||||
let state = State::get().await?;
|
||||
|
||||
@@ -73,6 +73,14 @@ pub(super) struct CreateInstanceInviteResponse {
|
||||
pub(super) id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub(super) struct InstanceInviteResponse {
|
||||
pub(super) id: String,
|
||||
pub(super) expiration: DateTime<Utc>,
|
||||
pub(super) max_uses: i32,
|
||||
pub(super) uses: i32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub(super) struct BlacklistStatusResponse {
|
||||
pub(super) blacklisted: bool,
|
||||
@@ -502,6 +510,20 @@ pub(super) async fn delete_remote_invite(
|
||||
.await
|
||||
}
|
||||
|
||||
pub(super) async fn get_remote_invites(
|
||||
shared_instance_id: &str,
|
||||
state: &State,
|
||||
) -> crate::Result<Vec<InstanceInviteResponse>> {
|
||||
request_json(
|
||||
"get_instance_invites",
|
||||
Method::GET,
|
||||
&format!("/instances/{shared_instance_id}/invites"),
|
||||
None,
|
||||
state,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(super) async fn get_shared_instance_invite_info(
|
||||
invite_id: &str,
|
||||
state: &State,
|
||||
@@ -691,9 +713,17 @@ where
|
||||
let body = match response.text().await {
|
||||
Ok(body) => body,
|
||||
Err(error) if strip_response_url => {
|
||||
return Err(error.without_url().into());
|
||||
return Err(crate::ErrorKind::SharedInstancesApiError(
|
||||
error.without_url().to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
Err(error) => {
|
||||
return Err(crate::ErrorKind::SharedInstancesApiError(
|
||||
error.to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
serde_json::from_str::<T>(&body).map_err(|error| {
|
||||
tracing::warn!(
|
||||
@@ -707,7 +737,7 @@ where
|
||||
error_column = error.column(),
|
||||
"Shared instances API returned an invalid JSON response"
|
||||
);
|
||||
crate::ErrorKind::OtherError(format!(
|
||||
crate::ErrorKind::SharedInstancesApiError(format!(
|
||||
"Shared instances API request {operation} {method} {log_path} returned invalid JSON with status {status}"
|
||||
))
|
||||
.into()
|
||||
@@ -848,7 +878,12 @@ pub(super) async fn send_bytes_request_to_url(
|
||||
.header(reqwest::header::CONTENT_TYPE, "application/octet-stream")
|
||||
.body(body)
|
||||
.send()
|
||||
.await?;
|
||||
.await
|
||||
.map_err(|error| {
|
||||
crate::ErrorKind::SharedInstancesApiError(
|
||||
error.without_url().to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
if response.status().is_success() {
|
||||
let request_id = response_request_id(&response);
|
||||
@@ -938,9 +973,17 @@ async fn send_request_with_auth_and_log_path(
|
||||
let response = match request.send().await {
|
||||
Ok(response) => response,
|
||||
Err(error) if path != log_path => {
|
||||
return Err(error.without_url().into());
|
||||
return Err(crate::ErrorKind::SharedInstancesApiError(
|
||||
error.without_url().to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
Err(error) => {
|
||||
return Err(crate::ErrorKind::SharedInstancesApiError(
|
||||
error.to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
if response.status().is_success() {
|
||||
let request_id = response_request_id(&response);
|
||||
@@ -973,10 +1016,14 @@ pub(super) async fn shared_instances_request_error<T>(
|
||||
request_id = request_id.as_deref().unwrap_or("none"),
|
||||
"Shared instances API request failed"
|
||||
);
|
||||
Err(crate::ErrorKind::OtherError(format!(
|
||||
let message = format!(
|
||||
"Shared instances API request {operation} {method} {path} failed with status {status}"
|
||||
))
|
||||
.into())
|
||||
);
|
||||
if status.is_server_error() {
|
||||
return Err(crate::ErrorKind::SharedInstancesApiError(message).into());
|
||||
}
|
||||
|
||||
Err(crate::ErrorKind::OtherError(message).into())
|
||||
}
|
||||
|
||||
pub(super) fn response_request_id(
|
||||
|
||||
@@ -244,13 +244,7 @@ pub(crate) async fn check_shared_instance_availability_before_launch(
|
||||
let availability =
|
||||
match get_remote_instance_access(&attachment.id, state).await {
|
||||
Ok(availability) => availability,
|
||||
Err(error)
|
||||
if matches!(
|
||||
error.raw.as_ref(),
|
||||
crate::ErrorKind::NoCredentialsError
|
||||
| crate::ErrorKind::FetchError(_)
|
||||
) =>
|
||||
{
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
instance_id,
|
||||
shared_instance_id = %attachment.id,
|
||||
@@ -259,7 +253,6 @@ pub(crate) async fn check_shared_instance_availability_before_launch(
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
|
||||
if let SharedInstanceRemoteResponse::Unavailable(reason) = availability {
|
||||
|
||||
@@ -124,6 +124,44 @@ pub async fn create_shared_instance_invite_link(
|
||||
})
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn get_shared_instance_invites(
|
||||
instance_id: &str,
|
||||
) -> crate::Result<Vec<SharedInstanceInvite>> {
|
||||
let state = State::get().await?;
|
||||
let Some(attachment) = shared_attachment(instance_id, &state).await? else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
ensure_owner(&attachment)?;
|
||||
|
||||
Ok(get_remote_invites(&attachment.id, &state)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|invite| SharedInstanceInvite {
|
||||
id: invite.id,
|
||||
expiration: invite.expiration,
|
||||
max_uses: invite.max_uses,
|
||||
uses: invite.uses,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(invite_id))]
|
||||
pub async fn revoke_shared_instance_invite(
|
||||
instance_id: &str,
|
||||
invite_id: String,
|
||||
) -> crate::Result<()> {
|
||||
let state = State::get().await?;
|
||||
let _shared_instance_lock = state.lock_shared_instance(instance_id).await;
|
||||
let Some(attachment) = shared_attachment(instance_id, &state).await? else {
|
||||
return Ok(());
|
||||
};
|
||||
ensure_owner(&attachment)?;
|
||||
|
||||
delete_remote_invite(&attachment.id, &invite_id, &state).await?;
|
||||
emit_instance(instance_id, InstancePayloadType::Edited).await
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn remove_shared_instance_users(
|
||||
instance_id: &str,
|
||||
|
||||
@@ -110,8 +110,9 @@ pub use self::install::{
|
||||
};
|
||||
pub use self::invites::{
|
||||
accept_pending_shared_instance_invite, create_shared_instance_invite_link,
|
||||
decline_pending_shared_instance_invite, get_shared_instance_users,
|
||||
invite_shared_instance_users, remove_shared_instance_users,
|
||||
decline_pending_shared_instance_invite, get_shared_instance_invites,
|
||||
get_shared_instance_users, invite_shared_instance_users,
|
||||
remove_shared_instance_users, revoke_shared_instance_invite,
|
||||
};
|
||||
pub use self::publish::{
|
||||
get_shared_instance_publish_preview, publish_shared_instance,
|
||||
@@ -119,10 +120,11 @@ pub use self::publish::{
|
||||
};
|
||||
pub use self::types::{
|
||||
SharedInstanceExternalFilePreview, SharedInstanceInstallPreview,
|
||||
SharedInstanceInviteInstallPreview, SharedInstanceInviteLink,
|
||||
SharedInstanceJoinType, SharedInstancePublishPreview,
|
||||
SharedInstanceUpdateDiff, SharedInstanceUpdateDiffType,
|
||||
SharedInstanceUpdatePreview, SharedInstanceUser, SharedInstanceUsers,
|
||||
SharedInstanceInvite, SharedInstanceInviteInstallPreview,
|
||||
SharedInstanceInviteLink, SharedInstanceJoinType,
|
||||
SharedInstancePublishPreview, SharedInstanceUpdateDiff,
|
||||
SharedInstanceUpdateDiffType, SharedInstanceUpdatePreview,
|
||||
SharedInstanceUser, SharedInstanceUsers,
|
||||
};
|
||||
|
||||
pub async fn can_active_user_use_shared_instances() -> crate::Result<bool> {
|
||||
|
||||
@@ -116,6 +116,15 @@ pub struct SharedInstanceInviteLink {
|
||||
pub max_uses: i32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SharedInstanceInvite {
|
||||
pub id: String,
|
||||
pub expiration: DateTime<Utc>,
|
||||
pub max_uses: i32,
|
||||
pub uses: i32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SharedInstanceInviteInstallPreview {
|
||||
|
||||
@@ -7,10 +7,7 @@ use crate::{
|
||||
install::{InstallPhaseDetails, InstallProgressReporter},
|
||||
prelude::ModLoader,
|
||||
state::{AppliedContentSetPatch, EditInstance, InstanceInstallStage},
|
||||
util::{
|
||||
fetch::{fetch, write_cached_icon},
|
||||
io,
|
||||
},
|
||||
util::{fetch::fetch, io},
|
||||
};
|
||||
|
||||
use super::{finish_import, recache_icon};
|
||||
@@ -90,18 +87,8 @@ pub async fn import_curseforge(
|
||||
&state.pool,
|
||||
)
|
||||
.await?;
|
||||
let filename = thumbnail_url.rsplit('/').next_back();
|
||||
if let Some(filename) = filename {
|
||||
icon = Some(
|
||||
write_cached_icon(
|
||||
filename,
|
||||
&state.directories.caches_dir(),
|
||||
icon_bytes,
|
||||
&state.io_semaphore,
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
}
|
||||
icon =
|
||||
Some(crate::api::instance::cache_icon(icon_bytes, &state).await?);
|
||||
}
|
||||
|
||||
// base mod loader is always None for vanilla
|
||||
|
||||
@@ -346,19 +346,10 @@ pub async fn recache_icon(
|
||||
) -> crate::Result<Option<PathBuf>> {
|
||||
let state = crate::State::get().await?;
|
||||
|
||||
let bytes = tokio::fs::read(&icon_path).await;
|
||||
if let Ok(bytes) = bytes {
|
||||
let bytes = bytes::Bytes::from(bytes);
|
||||
let cache_dir = &state.directories.caches_dir();
|
||||
let semaphore = &state.io_semaphore;
|
||||
if tokio::fs::try_exists(&icon_path).await.unwrap_or(false) {
|
||||
Ok(Some(
|
||||
fetch::write_cached_icon(
|
||||
&icon_path.to_string_lossy(),
|
||||
cache_dir,
|
||||
bytes,
|
||||
semaphore,
|
||||
)
|
||||
.await?,
|
||||
crate::api::instance::cache_icon_from_path(&icon_path, &state)
|
||||
.await?,
|
||||
))
|
||||
} else {
|
||||
// could not find icon (for instance, prism default icon, etc)
|
||||
|
||||
@@ -10,7 +10,7 @@ use crate::state::{
|
||||
};
|
||||
use crate::util::fetch::{
|
||||
DownloadMeta, DownloadReason, FetchProgressFn, fetch,
|
||||
fetch_advanced_with_progress, sha1_file_async, write_cached_icon,
|
||||
fetch_advanced_with_progress, sha1_file_async,
|
||||
};
|
||||
use path_util::SafeRelativeUtf8UnixPathBuf;
|
||||
use reqwest::Method;
|
||||
@@ -415,21 +415,7 @@ pub(crate) async fn generate_pack_from_version_id_with_reporter(
|
||||
)
|
||||
.await?;
|
||||
|
||||
let filename = icon_url.rsplit('/').next();
|
||||
|
||||
if let Some(filename) = filename {
|
||||
Some(
|
||||
write_cached_icon(
|
||||
filename,
|
||||
&state.directories.caches_dir(),
|
||||
icon_bytes,
|
||||
&state.io_semaphore,
|
||||
)
|
||||
.await?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
Some(crate::api::instance::cache_icon(icon_bytes, &state).await?)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
|
||||
@@ -124,6 +124,9 @@ pub enum ErrorKind {
|
||||
#[error("Shared instance unavailable: {0}")]
|
||||
SharedInstanceUnavailable(SharedInstanceUnavailableReason),
|
||||
|
||||
#[error("Shared instances API request failed: {0}")]
|
||||
SharedInstancesApiError(String),
|
||||
|
||||
#[error("Join handle error: {0}")]
|
||||
JoinError(#[from] tokio::task::JoinError),
|
||||
|
||||
|
||||
@@ -1433,6 +1433,7 @@ fn install_error_code(
|
||||
ErrorKind::SharedInstanceUnavailable(_) => {
|
||||
"shared_instance_unavailable"
|
||||
}
|
||||
ErrorKind::SharedInstancesApiError(_) => "shared_instances_api_error",
|
||||
ErrorKind::InputError(_) => match phase {
|
||||
PreparingInstance | Finalizing => "instance_error",
|
||||
ResolvingPack | DownloadingPackFile | ReadingPackManifest => {
|
||||
|
||||
@@ -8,7 +8,7 @@ use crate::state::{
|
||||
InstanceInstallStage, LauncherFeatureVersion, ModLoader, ReleaseChannel,
|
||||
State,
|
||||
};
|
||||
use crate::util::fetch::{self, write_cached_icon};
|
||||
use crate::util::fetch;
|
||||
use crate::util::io;
|
||||
use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -174,10 +174,8 @@ async fn resolve_icon_path(
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let (bytes, file_name) = if icon.starts_with("https://")
|
||||
|| icon.starts_with("http://")
|
||||
{
|
||||
let fetched = fetch::fetch(
|
||||
let file = if icon.starts_with("https://") || icon.starts_with("http://") {
|
||||
let bytes = fetch::fetch(
|
||||
icon,
|
||||
None,
|
||||
None,
|
||||
@@ -186,21 +184,15 @@ async fn resolve_icon_path(
|
||||
&state.pool,
|
||||
)
|
||||
.await?;
|
||||
let name = icon.rsplit('/').next().unwrap_or("icon").to_string();
|
||||
(fetched, name)
|
||||
crate::api::instance::cache_icon(bytes, state).await?
|
||||
} else {
|
||||
let data = io::read(state.directories.caches_dir().join(icon)).await?;
|
||||
(bytes::Bytes::from(data), icon.to_string())
|
||||
crate::api::instance::cache_icon_from_path(
|
||||
&state.directories.caches_dir().join(icon),
|
||||
state,
|
||||
)
|
||||
.await?
|
||||
};
|
||||
|
||||
let file = write_cached_icon(
|
||||
&file_name,
|
||||
&state.directories.caches_dir(),
|
||||
bytes,
|
||||
&state.io_semaphore,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Some(file.to_string_lossy().to_string()))
|
||||
}
|
||||
|
||||
|
||||
@@ -150,6 +150,10 @@ impl State {
|
||||
)
|
||||
.await;
|
||||
|
||||
if let Err(e) = crate::api::instance::migrate_legacy_icons().await {
|
||||
tracing::error!("Error migrating legacy instance icons: {e}");
|
||||
}
|
||||
|
||||
let res = tokio::try_join!(
|
||||
state.discord_rpc.clear_to_default(true),
|
||||
instances::refresh_all_instances(),
|
||||
|
||||
@@ -14,10 +14,9 @@ use reqwest::Method;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::ffi::OsStr;
|
||||
use std::future::Future;
|
||||
use std::num::NonZeroU32;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::Path;
|
||||
use std::pin::Pin;
|
||||
use std::sync::{Arc, LazyLock};
|
||||
use std::time::{self, Duration, Instant, SystemTime};
|
||||
@@ -907,33 +906,6 @@ pub async fn copy(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Writes a icon to the cache and returns the absolute path of the icon within the cache directory
|
||||
#[tracing::instrument(skip(bytes, semaphore))]
|
||||
pub async fn write_cached_icon(
|
||||
icon_path: &str,
|
||||
cache_dir: &Path,
|
||||
bytes: Bytes,
|
||||
semaphore: &IoSemaphore,
|
||||
) -> crate::Result<PathBuf> {
|
||||
let hash = sha1_async(bytes.clone()).await?;
|
||||
let path = cache_dir
|
||||
.join("icons")
|
||||
.join(cached_icon_file_name(icon_path, &hash));
|
||||
|
||||
write(&path, &bytes, semaphore).await?;
|
||||
|
||||
let path = io::canonicalize(path)?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
fn cached_icon_file_name(icon_path: &str, hash: &str) -> String {
|
||||
let path = icon_path.split(['?', '#']).next().unwrap_or(icon_path);
|
||||
match Path::new(path).extension().and_then(OsStr::to_str) {
|
||||
Some(extension) => format!("{hash}.{extension}"),
|
||||
None => hash.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn sha1_async(bytes: Bytes) -> crate::Result<String> {
|
||||
let hash = tokio::task::spawn_blocking(move || {
|
||||
sha1_smol::Sha1::from(bytes).hexdigest()
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
:title="formatMessage(copiedMessage)"
|
||||
@click="copyText"
|
||||
>
|
||||
<span>{{ text }}</span>
|
||||
<span>{{ displayText ?? text }}</span>
|
||||
<CheckIcon v-if="copied" />
|
||||
<ClipboardCopyIcon v-else />
|
||||
</button>
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CheckIcon, ClipboardCopyIcon } from '@modrinth/assets'
|
||||
import { ref } from 'vue'
|
||||
import { onBeforeUnmount, ref } from 'vue'
|
||||
|
||||
import { defineMessage, useVIntl } from '../../composables/i18n'
|
||||
|
||||
@@ -22,12 +22,22 @@ const copiedMessage = defineMessage({
|
||||
})
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const props = defineProps<{ text: string }>()
|
||||
const props = defineProps<{
|
||||
text: string
|
||||
displayText?: string
|
||||
}>()
|
||||
|
||||
const copied = ref(false)
|
||||
let copiedResetTimeout: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
async function copyText() {
|
||||
await navigator.clipboard.writeText(props.text)
|
||||
copied.value = true
|
||||
clearTimeout(copiedResetTimeout)
|
||||
copiedResetTimeout = setTimeout(() => {
|
||||
copied.value = false
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => clearTimeout(copiedResetTimeout))
|
||||
</script>
|
||||
|
||||
@@ -142,6 +142,7 @@ const props = withDefaults(
|
||||
min?: number
|
||||
max?: number
|
||||
step?: number
|
||||
clamp?: boolean
|
||||
disabled?: boolean
|
||||
readonly?: boolean
|
||||
error?: boolean
|
||||
@@ -159,6 +160,7 @@ const props = withDefaults(
|
||||
type: 'text',
|
||||
size: 'standard',
|
||||
variant: 'filled',
|
||||
clamp: false,
|
||||
disabled: false,
|
||||
readonly: false,
|
||||
error: false,
|
||||
@@ -189,12 +191,22 @@ defineExpose({
|
||||
|
||||
function onInput(event: Event) {
|
||||
const target = event.target as HTMLInputElement | HTMLTextAreaElement
|
||||
model.value =
|
||||
props.type === 'number' && !props.multiline
|
||||
? target.value === ''
|
||||
? undefined
|
||||
: Number(target.value)
|
||||
: target.value
|
||||
if (props.type !== 'number' || props.multiline) {
|
||||
model.value = target.value
|
||||
return
|
||||
}
|
||||
if (target.value === '') {
|
||||
model.value = undefined
|
||||
return
|
||||
}
|
||||
|
||||
let value = Number(target.value)
|
||||
if (props.clamp) {
|
||||
if (props.min !== undefined) value = Math.max(props.min, value)
|
||||
if (props.max !== undefined) value = Math.min(props.max, value)
|
||||
target.value = String(value)
|
||||
}
|
||||
model.value = value
|
||||
}
|
||||
|
||||
function clear() {
|
||||
|
||||
@@ -135,6 +135,7 @@
|
||||
ref="inviteLinkEditor"
|
||||
:link-expires-at="linkExpiresAt"
|
||||
:link-max-uses="linkMaxUses"
|
||||
:link-max-uses-limit="linkMaxUsesLimit"
|
||||
:update-invite-link="updateInviteLink"
|
||||
/>
|
||||
</template>
|
||||
@@ -169,6 +170,7 @@ const props = withDefaults(
|
||||
link?: string
|
||||
linkExpiresAt?: string | Date | null
|
||||
linkMaxUses?: number
|
||||
linkMaxUsesLimit?: number
|
||||
updateInviteLink?: (settings: InviteLinkSettings) => Promise<void>
|
||||
friendsLabel?: string
|
||||
searchPlaceholder?: string
|
||||
@@ -188,6 +190,7 @@ const props = withDefaults(
|
||||
suggestions: () => [],
|
||||
canInvite: true,
|
||||
linkMaxUses: 10,
|
||||
linkMaxUsesLimit: 2147483647,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
+252
-28
@@ -1,19 +1,62 @@
|
||||
<template>
|
||||
<NewModal ref="modal" :header="formatMessage(messages.title)" max-width="30rem">
|
||||
<NewModal ref="modal" :header="formatMessage(messages.title)" width="420px" max-width="420px">
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="font-semibold text-contrast">{{ formatMessage(messages.expiryLabel) }}</span>
|
||||
<DatePicker
|
||||
v-model="expiry"
|
||||
<Combobox
|
||||
:model-value="selectedExpiryPreset"
|
||||
:options="expiryDropdownOptions"
|
||||
:display-value="expiryPickerLabel"
|
||||
:disabled="saving"
|
||||
:min-date="minimumExpiry"
|
||||
:max-date="maximumExpiry"
|
||||
date-format="Y-m-d H:i"
|
||||
alt-format="F j, Y at h:i K"
|
||||
enable-time
|
||||
wrapper-class="w-full"
|
||||
input-class="w-full"
|
||||
/>
|
||||
:dropdown-min-width="customExpiryOpen ? '20rem' : undefined"
|
||||
:dropdown-class="customExpiryOpen ? 'bg-transparent border-0 -mt-1 pb-2 shadow-none' : ''"
|
||||
@open="handleExpiryPickerOpen"
|
||||
@close="handleExpiryPickerClose"
|
||||
@select="selectExpiryPreset"
|
||||
>
|
||||
<template #dropdown-footer>
|
||||
<div
|
||||
v-if="customExpiryOpen"
|
||||
class="flex flex-col rounded-2xl border border-solid border-surface-5 bg-surface-3 p-1"
|
||||
>
|
||||
<DatePicker
|
||||
v-model="customExpiry"
|
||||
:min-date="minimumExpiry"
|
||||
:max-date="maximumExpiry"
|
||||
:default-view-date="customExpiry || minimumExpiry"
|
||||
date-format="Y-m-d H:i"
|
||||
enable-time
|
||||
calendar-only
|
||||
wrapper-class="w-full"
|
||||
calendar-class="!border-none"
|
||||
/>
|
||||
<div class="flex justify-end gap-2 p-3 pt-1">
|
||||
<ButtonStyled type="outlined">
|
||||
<button type="button" @click="cancelCustomExpiry">
|
||||
{{ formatMessage(messages.cancel) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button
|
||||
type="button"
|
||||
:disabled="!canApplyCustomExpiry"
|
||||
@click="applyCustomExpiry"
|
||||
>
|
||||
{{ formatMessage(messages.apply) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="flex w-full cursor-pointer items-center border-0 border-t border-solid border-surface-5 bg-transparent px-4 py-3 text-left text-base font-semibold leading-tight text-primary transition-colors hover:bg-surface-5"
|
||||
@click.stop="openCustomExpiry"
|
||||
>
|
||||
{{ formatMessage(messages.customExpiry) }}
|
||||
</button>
|
||||
</template>
|
||||
</Combobox>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="font-semibold text-contrast">{{ formatMessage(messages.maxUsesLabel) }}</span>
|
||||
@@ -21,9 +64,10 @@
|
||||
v-model="maxUses"
|
||||
type="number"
|
||||
:min="1"
|
||||
:max="2147483647"
|
||||
:max="maximumUses"
|
||||
:step="1"
|
||||
:disabled="saving"
|
||||
:disabled="saving || maximumUses === 0"
|
||||
clamp
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -49,29 +93,58 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { SaveIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { useFormatDateTime } from '../../../composables'
|
||||
import { defineMessages, useVIntl } from '../../../composables/i18n'
|
||||
import { injectNotificationManager } from '../../../providers'
|
||||
import ButtonStyled from '../../base/ButtonStyled.vue'
|
||||
import Combobox, { type ComboboxOption } from '../../base/Combobox.vue'
|
||||
import DatePicker from '../../base/DatePicker.vue'
|
||||
import StyledInput from '../../base/StyledInput.vue'
|
||||
import NewModal from '../../modal/NewModal.vue'
|
||||
import type { InviteLinkSettings } from './types'
|
||||
|
||||
const props = defineProps<{
|
||||
linkExpiresAt?: string | Date | null
|
||||
linkMaxUses: number
|
||||
updateInviteLink?: (settings: InviteLinkSettings) => Promise<void>
|
||||
}>()
|
||||
const EXPIRY_PRESET_DURATIONS = {
|
||||
one_hour: 3_600_000,
|
||||
six_hours: 6 * 3_600_000,
|
||||
twelve_hours: 12 * 3_600_000,
|
||||
one_day: 86_400_000,
|
||||
three_days: 3 * 86_400_000,
|
||||
seven_days: 7 * 86_400_000,
|
||||
} as const
|
||||
const MINIMUM_EXPIRY_DURATION = EXPIRY_PRESET_DURATIONS.one_hour
|
||||
const MAXIMUM_EXPIRY_DURATION = EXPIRY_PRESET_DURATIONS.seven_days
|
||||
const EXPIRY_PRESET_MATCH_TOLERANCE = 2 * 60_000
|
||||
|
||||
type ExpiryPreset = keyof typeof EXPIRY_PRESET_DURATIONS
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
linkExpiresAt?: string | Date | null
|
||||
linkMaxUses: number
|
||||
linkMaxUsesLimit?: number
|
||||
updateInviteLink?: (settings: InviteLinkSettings) => Promise<void>
|
||||
}>(),
|
||||
{
|
||||
linkMaxUsesLimit: 2147483647,
|
||||
},
|
||||
)
|
||||
const { formatMessage } = useVIntl()
|
||||
const notificationManager = injectNotificationManager(null)
|
||||
const modal = ref<InstanceType<typeof NewModal> | null>(null)
|
||||
const expiry = ref('')
|
||||
const expiryMode = ref<'preset' | 'custom'>('preset')
|
||||
const expiryPreset = ref<ExpiryPreset>('seven_days')
|
||||
const expiryReferenceTime = ref(Date.now())
|
||||
const customExpiry = ref('')
|
||||
const customExpiryOpen = ref(false)
|
||||
const maxUses = ref<number>()
|
||||
const minimumExpiry = ref(new Date())
|
||||
const maximumExpiry = ref(new Date())
|
||||
const saving = ref(false)
|
||||
const maximumUses = computed(() => Math.max(0, Math.floor(props.linkMaxUsesLimit)))
|
||||
const formatExpiryDate = useFormatDateTime({ dateStyle: 'medium', timeStyle: 'short' })
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
@@ -86,10 +159,46 @@ const messages = defineMessages({
|
||||
id: 'sharing.invite-players-modal.max-uses-label',
|
||||
defaultMessage: 'Maximum uses',
|
||||
},
|
||||
inOneHour: {
|
||||
id: 'sharing.invite-players-modal.expiry-in-one-hour',
|
||||
defaultMessage: 'In 1 hour',
|
||||
},
|
||||
inSixHours: {
|
||||
id: 'sharing.invite-players-modal.expiry-in-six-hours',
|
||||
defaultMessage: 'In 6 hours',
|
||||
},
|
||||
inTwelveHours: {
|
||||
id: 'sharing.invite-players-modal.expiry-in-twelve-hours',
|
||||
defaultMessage: 'In 12 hours',
|
||||
},
|
||||
inOneDay: {
|
||||
id: 'sharing.invite-players-modal.expiry-in-one-day',
|
||||
defaultMessage: 'In 1 day',
|
||||
},
|
||||
inThreeDays: {
|
||||
id: 'sharing.invite-players-modal.expiry-in-three-days',
|
||||
defaultMessage: 'In 3 days',
|
||||
},
|
||||
inSevenDays: {
|
||||
id: 'sharing.invite-players-modal.expiry-in-seven-days',
|
||||
defaultMessage: 'In 7 days',
|
||||
},
|
||||
customExpiry: {
|
||||
id: 'sharing.invite-players-modal.custom-expiry',
|
||||
defaultMessage: 'Custom...',
|
||||
},
|
||||
customExpiryValue: {
|
||||
id: 'sharing.invite-players-modal.custom-expiry-value',
|
||||
defaultMessage: 'Custom: {date}',
|
||||
},
|
||||
cancel: {
|
||||
id: 'sharing.invite-players-modal.cancel-button',
|
||||
defaultMessage: 'Cancel',
|
||||
},
|
||||
apply: {
|
||||
id: 'sharing.invite-players-modal.apply-button',
|
||||
defaultMessage: 'Apply',
|
||||
},
|
||||
save: {
|
||||
id: 'sharing.invite-players-modal.save-button',
|
||||
defaultMessage: 'Save',
|
||||
@@ -100,6 +209,35 @@ const messages = defineMessages({
|
||||
},
|
||||
})
|
||||
|
||||
const expiryOptions = computed<ComboboxOption<ExpiryPreset>[]>(() => [
|
||||
{ value: 'one_hour', label: formatMessage(messages.inOneHour) },
|
||||
{ value: 'six_hours', label: formatMessage(messages.inSixHours) },
|
||||
{ value: 'twelve_hours', label: formatMessage(messages.inTwelveHours) },
|
||||
{ value: 'one_day', label: formatMessage(messages.inOneDay) },
|
||||
{ value: 'three_days', label: formatMessage(messages.inThreeDays) },
|
||||
{ value: 'seven_days', label: formatMessage(messages.inSevenDays) },
|
||||
])
|
||||
const expiryDropdownOptions = computed(() => (customExpiryOpen.value ? [] : expiryOptions.value))
|
||||
const selectedExpiryPreset = computed(() =>
|
||||
expiryMode.value === 'preset' ? expiryPreset.value : undefined,
|
||||
)
|
||||
const expiryPickerLabel = computed(() => {
|
||||
if (expiryMode.value === 'preset') {
|
||||
return (
|
||||
expiryOptions.value.find((option) => option.value === expiryPreset.value)?.label ??
|
||||
formatMessage(messages.inSevenDays)
|
||||
)
|
||||
}
|
||||
|
||||
const date = parseLocalDate(expiry.value)
|
||||
return date
|
||||
? formatMessage(messages.customExpiryValue, { date: formatExpiryDate(date) })
|
||||
: formatMessage(messages.customExpiry)
|
||||
})
|
||||
const canApplyCustomExpiry = computed(() => {
|
||||
const date = parseLocalDate(customExpiry.value)
|
||||
return !!date && date >= minimumExpiry.value && date <= maximumExpiry.value
|
||||
})
|
||||
const canSave = computed(() => {
|
||||
const date = parseLocalDate(expiry.value)
|
||||
return (
|
||||
@@ -109,7 +247,7 @@ const canSave = computed(() => {
|
||||
date <= maximumExpiry.value &&
|
||||
Number.isInteger(maxUses.value ?? 0) &&
|
||||
(maxUses.value ?? 0) > 0 &&
|
||||
(maxUses.value ?? 0) <= 2147483647
|
||||
(maxUses.value ?? 0) <= maximumUses.value
|
||||
)
|
||||
})
|
||||
|
||||
@@ -126,13 +264,48 @@ function parseLocalDate(value: string) {
|
||||
return Number.isNaN(date.getTime()) ? null : date
|
||||
}
|
||||
|
||||
function roundDownToMinute(timestamp: number) {
|
||||
const date = new Date(timestamp)
|
||||
date.setSeconds(0, 0)
|
||||
return date
|
||||
}
|
||||
|
||||
function roundUpToMinute(timestamp: number) {
|
||||
const date = roundDownToMinute(timestamp)
|
||||
if (date.getTime() < timestamp) date.setMinutes(date.getMinutes() + 1)
|
||||
return date
|
||||
}
|
||||
|
||||
function expiryForPreset(preset: ExpiryPreset) {
|
||||
const expiryTimestamp = expiryReferenceTime.value + EXPIRY_PRESET_DURATIONS[preset]
|
||||
const date = roundDownToMinute(expiryTimestamp)
|
||||
if (date < minimumExpiry.value) return minimumExpiry.value
|
||||
if (date > maximumExpiry.value) return maximumExpiry.value
|
||||
return date
|
||||
}
|
||||
|
||||
function matchingExpiryPreset(date: Date) {
|
||||
const duration = date.getTime() - expiryReferenceTime.value
|
||||
let closestPreset: ExpiryPreset | null = null
|
||||
let closestDifference = Number.POSITIVE_INFINITY
|
||||
|
||||
for (const [preset, presetDuration] of Object.entries(EXPIRY_PRESET_DURATIONS) as Array<
|
||||
[ExpiryPreset, number]
|
||||
>) {
|
||||
const difference = Math.abs(duration - presetDuration)
|
||||
if (difference < closestDifference) {
|
||||
closestPreset = preset
|
||||
closestDifference = difference
|
||||
}
|
||||
}
|
||||
|
||||
return closestDifference <= EXPIRY_PRESET_MATCH_TOLERANCE ? closestPreset : null
|
||||
}
|
||||
|
||||
function show() {
|
||||
const now = new Date()
|
||||
minimumExpiry.value = new Date(now.getTime() + 3_600_000)
|
||||
minimumExpiry.value.setSeconds(0, 0)
|
||||
minimumExpiry.value.setMinutes(minimumExpiry.value.getMinutes() + 1)
|
||||
maximumExpiry.value = new Date(now.getTime() + 7 * 86_400_000)
|
||||
maximumExpiry.value.setSeconds(0, 0)
|
||||
expiryReferenceTime.value = Date.now()
|
||||
minimumExpiry.value = roundUpToMinute(expiryReferenceTime.value + MINIMUM_EXPIRY_DURATION)
|
||||
maximumExpiry.value = roundDownToMinute(expiryReferenceTime.value + MAXIMUM_EXPIRY_DURATION)
|
||||
const currentExpiry = props.linkExpiresAt ? new Date(props.linkExpiresAt) : maximumExpiry.value
|
||||
const date =
|
||||
Number.isNaN(currentExpiry.getTime()) || currentExpiry < minimumExpiry.value
|
||||
@@ -141,16 +314,63 @@ function show() {
|
||||
? maximumExpiry.value
|
||||
: currentExpiry
|
||||
expiry.value = formatLocalDate(date)
|
||||
maxUses.value = props.linkMaxUses
|
||||
const matchingPreset = matchingExpiryPreset(date)
|
||||
expiryMode.value = matchingPreset ? 'preset' : 'custom'
|
||||
if (matchingPreset) expiryPreset.value = matchingPreset
|
||||
customExpiry.value = expiry.value
|
||||
customExpiryOpen.value = false
|
||||
maxUses.value = Math.min(props.linkMaxUses, maximumUses.value)
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
function selectExpiryPreset(option: ComboboxOption<ExpiryPreset>) {
|
||||
expiryMode.value = 'preset'
|
||||
expiryPreset.value = option.value
|
||||
expiry.value = formatLocalDate(expiryForPreset(option.value))
|
||||
}
|
||||
|
||||
function handleExpiryPickerOpen() {
|
||||
customExpiryOpen.value = false
|
||||
}
|
||||
|
||||
function handleExpiryPickerClose() {
|
||||
customExpiryOpen.value = false
|
||||
customExpiry.value = expiry.value
|
||||
}
|
||||
|
||||
function openCustomExpiry() {
|
||||
customExpiry.value = expiry.value
|
||||
customExpiryOpen.value = true
|
||||
}
|
||||
|
||||
function cancelCustomExpiry() {
|
||||
customExpiry.value = expiry.value
|
||||
customExpiryOpen.value = false
|
||||
}
|
||||
|
||||
function closeExpiryPicker(event: Event) {
|
||||
const target = event.target
|
||||
if (!(target instanceof HTMLElement)) return
|
||||
target
|
||||
.closest('[role="listbox"], [role="menu"]')
|
||||
?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
|
||||
}
|
||||
|
||||
function applyCustomExpiry(event: MouseEvent) {
|
||||
const date = parseLocalDate(customExpiry.value)
|
||||
if (!canApplyCustomExpiry.value || !date) return
|
||||
expiryMode.value = 'custom'
|
||||
expiry.value = formatLocalDate(date)
|
||||
closeExpiryPicker(event)
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const date = parseLocalDate(expiry.value)
|
||||
if (!canSave.value || !date || !props.updateInviteLink) return
|
||||
const clampedMaxUses = Math.min(maxUses.value ?? 1, maximumUses.value)
|
||||
saving.value = true
|
||||
try {
|
||||
await props.updateInviteLink({ expiresAt: date, maxUses: maxUses.value ?? 1 })
|
||||
await props.updateInviteLink({ expiresAt: date, maxUses: clampedMaxUses })
|
||||
modal.value?.hide()
|
||||
} catch (error) {
|
||||
notificationManager?.addNotification({
|
||||
@@ -163,5 +383,9 @@ async function save() {
|
||||
}
|
||||
}
|
||||
|
||||
watch([maxUses, maximumUses], ([uses, limit]) => {
|
||||
if (uses !== undefined && uses > limit) maxUses.value = limit
|
||||
})
|
||||
|
||||
defineExpose({ show })
|
||||
</script>
|
||||
|
||||
@@ -29,6 +29,7 @@ import type {
|
||||
ContentCardProject,
|
||||
ContentCardVersion,
|
||||
ContentOwner,
|
||||
ContentSource,
|
||||
} from '../types'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
@@ -46,6 +47,7 @@ interface Props {
|
||||
version?: ContentCardVersion
|
||||
versionLink?: string | RouteLocationRaw
|
||||
owner?: ContentOwner
|
||||
source?: ContentSource
|
||||
enabled?: boolean
|
||||
installing?: boolean
|
||||
hasUpdate?: boolean
|
||||
@@ -68,6 +70,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
version: undefined,
|
||||
versionLink: undefined,
|
||||
owner: undefined,
|
||||
source: undefined,
|
||||
enabled: undefined,
|
||||
installing: false,
|
||||
hasUpdate: false,
|
||||
@@ -196,8 +199,32 @@ const deleteHovered = ref(false)
|
||||
</div>
|
||||
|
||||
<div class="flex min-w-0 items-center gap-1">
|
||||
<template v-if="source">
|
||||
<AutoLink
|
||||
:target="
|
||||
typeof source.link === 'string' && source.link.startsWith('http')
|
||||
? '_blank'
|
||||
: undefined
|
||||
"
|
||||
:to="source.link"
|
||||
class="flex min-w-0 items-center gap-1 !decoration-secondary"
|
||||
:class="{ 'hover:underline': source.link }"
|
||||
>
|
||||
<Avatar
|
||||
:src="source.project.icon_url"
|
||||
:alt="source.project.title"
|
||||
:tint-by="source.project.id"
|
||||
size="1.25rem"
|
||||
no-shadow
|
||||
class="shrink-0 rounded-md"
|
||||
/>
|
||||
<span class="truncate text-sm leading-5 text-secondary">
|
||||
{{ source.project.title }}
|
||||
</span>
|
||||
</AutoLink>
|
||||
</template>
|
||||
<AutoLink
|
||||
v-if="owner"
|
||||
v-else-if="owner"
|
||||
:target="
|
||||
typeof owner.link === 'string' && owner.link.startsWith('http')
|
||||
? '_blank'
|
||||
|
||||
@@ -264,6 +264,7 @@ function handleSort(column: ContentCardTableSortColumn) {
|
||||
:version="item.version"
|
||||
:version-link="item.versionLink"
|
||||
:owner="item.owner"
|
||||
:source="item.source"
|
||||
:enabled="item.enabled"
|
||||
:installing="item.installing"
|
||||
:has-update="item.hasUpdate"
|
||||
@@ -327,6 +328,7 @@ function handleSort(column: ContentCardTableSortColumn) {
|
||||
:version="item.version"
|
||||
:version-link="item.versionLink"
|
||||
:owner="item.owner"
|
||||
:source="item.source"
|
||||
:enabled="item.enabled"
|
||||
:installing="item.installing"
|
||||
:has-update="item.hasUpdate"
|
||||
|
||||
+16
-2
@@ -20,6 +20,7 @@ import type { Option as OverflowMenuOption } from '#ui/components/base/OverflowM
|
||||
import StyledInput from '#ui/components/base/StyledInput.vue'
|
||||
import NewModal from '#ui/components/modal/NewModal.vue'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { injectPageContext } from '#ui/providers/page-context'
|
||||
import {
|
||||
commonMessages,
|
||||
commonProjectTypeCategoryMessages,
|
||||
@@ -28,11 +29,12 @@ import {
|
||||
} from '#ui/utils/common-messages'
|
||||
|
||||
import { getClientWarningType, isClientOnlyEnvironment } from '../../composables/content-filtering'
|
||||
import type { ContentCardTableItem, ContentItem } from '../../types'
|
||||
import type { ContentCardProject, ContentCardTableItem, ContentItem } from '../../types'
|
||||
import ContentCardTable from '../ContentCardTable.vue'
|
||||
import ContentSelectionBar from '../ContentSelectionBar.vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const pageContext = injectPageContext(null)
|
||||
|
||||
interface Props {
|
||||
header?: string
|
||||
@@ -266,6 +268,12 @@ const tableItems = computed<ContentCardTableItem[]>(() =>
|
||||
: `https://modrinth.com/organization/${item.owner.id}`,
|
||||
}
|
||||
: undefined,
|
||||
source: item.source
|
||||
? {
|
||||
...item.source,
|
||||
link: item.source.link ?? sourceProjectLink(item.source.project),
|
||||
}
|
||||
: undefined,
|
||||
...(props.enableToggle ? { enabled: item.enabled } : {}),
|
||||
installing: item.installing === true,
|
||||
toggleDisabled: props.actionDisabled,
|
||||
@@ -293,7 +301,7 @@ const tableItems = computed<ContentCardTableItem[]>(() =>
|
||||
})),
|
||||
)
|
||||
const externalItemIds = computed(
|
||||
() => new Set(items.value.filter((item) => item.external).map((item) => item.id)),
|
||||
() => new Set(items.value.filter((item) => item.external && !item.source).map((item) => item.id)),
|
||||
)
|
||||
const externalSlicerUrls = computed(() => {
|
||||
const urls: Record<string, string> = {}
|
||||
@@ -335,6 +343,12 @@ function itemDisplayName(item: ContentItem) {
|
||||
return item.project?.title ?? item.file_name
|
||||
}
|
||||
|
||||
function sourceProjectLink(project: ContentCardProject) {
|
||||
const projectId = project.slug ?? project.id
|
||||
const url = `https://modrinth.com/modpack/${encodeURIComponent(projectId)}`
|
||||
return pageContext ? () => pageContext.openExternalUrl(url) : url
|
||||
}
|
||||
|
||||
function handleEnabledChange(id: string, value: boolean) {
|
||||
if (props.actionDisabled) return
|
||||
const item = items.value.find((item) => item.id === id)
|
||||
|
||||
@@ -21,6 +21,11 @@ export interface ContentOwner {
|
||||
link?: string | RouteLocationRaw | (() => void)
|
||||
}
|
||||
|
||||
export interface ContentSource {
|
||||
project: ContentCardProject
|
||||
link?: string | RouteLocationRaw | (() => void)
|
||||
}
|
||||
|
||||
export type ClientWarningType = 'retained' | 'depends' | 'environment'
|
||||
|
||||
export type ContentSourceKind =
|
||||
@@ -44,6 +49,7 @@ export interface ContentCardTableItem {
|
||||
version?: ContentCardVersion
|
||||
versionLink?: string | RouteLocationRaw
|
||||
owner?: ContentOwner
|
||||
source?: ContentSource
|
||||
enabled?: boolean
|
||||
disabled?: boolean
|
||||
disabledTooltip?: string | null
|
||||
|
||||
@@ -5240,6 +5240,9 @@
|
||||
"sharing.invite-players-modal.already-invited": {
|
||||
"defaultMessage": "This user has already been invited."
|
||||
},
|
||||
"sharing.invite-players-modal.apply-button": {
|
||||
"defaultMessage": "Apply"
|
||||
},
|
||||
"sharing.invite-players-modal.avatar-alt": {
|
||||
"defaultMessage": "{username}'s avatar"
|
||||
},
|
||||
@@ -5249,12 +5252,36 @@
|
||||
"sharing.invite-players-modal.cancel-button": {
|
||||
"defaultMessage": "Cancel"
|
||||
},
|
||||
"sharing.invite-players-modal.custom-expiry": {
|
||||
"defaultMessage": "Custom..."
|
||||
},
|
||||
"sharing.invite-players-modal.custom-expiry-value": {
|
||||
"defaultMessage": "Custom: {date}"
|
||||
},
|
||||
"sharing.invite-players-modal.edit-invite-link": {
|
||||
"defaultMessage": "Edit invite link."
|
||||
},
|
||||
"sharing.invite-players-modal.edit-invite-link-title": {
|
||||
"defaultMessage": "Edit invite link"
|
||||
},
|
||||
"sharing.invite-players-modal.expiry-in-one-day": {
|
||||
"defaultMessage": "In 1 day"
|
||||
},
|
||||
"sharing.invite-players-modal.expiry-in-one-hour": {
|
||||
"defaultMessage": "In 1 hour"
|
||||
},
|
||||
"sharing.invite-players-modal.expiry-in-seven-days": {
|
||||
"defaultMessage": "In 7 days"
|
||||
},
|
||||
"sharing.invite-players-modal.expiry-in-six-hours": {
|
||||
"defaultMessage": "In 6 hours"
|
||||
},
|
||||
"sharing.invite-players-modal.expiry-in-three-days": {
|
||||
"defaultMessage": "In 3 days"
|
||||
},
|
||||
"sharing.invite-players-modal.expiry-in-twelve-hours": {
|
||||
"defaultMessage": "In 12 hours"
|
||||
},
|
||||
"sharing.invite-players-modal.expiry-label": {
|
||||
"defaultMessage": "Expiry date"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user