mirror of
https://github.com/modrinth/code.git
synced 2026-08-27 10:04:52 +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:
+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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user