@@ -423,8 +423,8 @@ const messages = defineMessages({
defaultMessage: 'Install anyway',
},
dontInstall: {
- id: 'app.modal.install-to-play.dont-install',
- defaultMessage: 'Dont install',
+ id: 'app.modal.install-to-play.external-files-dont-install',
+ defaultMessage: "Don't install",
},
})
diff --git a/apps/app-frontend/src/components/ui/modal/InstanceSettingsModal.vue b/apps/app-frontend/src/components/ui/modal/InstanceSettingsModal.vue
index bedef146b..800cac656 100644
--- a/apps/app-frontend/src/components/ui/modal/InstanceSettingsModal.vue
+++ b/apps/app-frontend/src/components/ui/modal/InstanceSettingsModal.vue
@@ -6,6 +6,7 @@ import {
CoffeeIcon,
InfoIcon,
MonitorIcon,
+ UsersIcon,
WrenchIcon,
} from '@modrinth/assets'
import {
@@ -25,6 +26,7 @@ import GeneralSettings from '@/components/ui/instance_settings/GeneralSettings.v
import HooksSettings from '@/components/ui/instance_settings/HooksSettings.vue'
import InstallationSettings from '@/components/ui/instance_settings/InstallationSettings.vue'
import JavaSettings from '@/components/ui/instance_settings/JavaSettings.vue'
+import SharingSettings from '@/components/ui/instance_settings/SharingSettings.vue'
import WindowSettings from '@/components/ui/instance_settings/WindowSettings.vue'
import { get_project_v3 } from '@/helpers/cache'
import { get_linked_modpack_info } from '@/helpers/instance'
@@ -97,6 +99,15 @@ const tabs = computed(() => [
icon: WrenchIcon,
content: InstallationSettings,
},
+ {
+ name: defineMessage({
+ id: 'instance.settings.tabs.sharing',
+ defaultMessage: 'Sharing',
+ }),
+ icon: UsersIcon,
+ content: SharingSettings,
+ shown: props.instance.shared_instance?.role === 'owner' && !props.instance.quarantined,
+ },
{
name: defineMessage({
id: 'instance.settings.tabs.window',
diff --git a/apps/app-frontend/src/components/ui/modal/ModrinthAccountRequiredModal.vue b/apps/app-frontend/src/components/ui/modal/ModrinthAccountRequiredModal.vue
new file mode 100644
index 000000000..db5dbc880
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/modal/ModrinthAccountRequiredModal.vue
@@ -0,0 +1,265 @@
+
+
+
+
+
+ {{ formatMessage(messages.signInHeading) }}
+
+
+ {{ formatMessage(messages.description) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ formatMessage(messages.continueInBrowserHeading) }}
+
+
+ {{ formatMessage(messages.browserDescription) }}
+
+
+
+
+
+ {{ formatMessage(messages.waitingForBrowser) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/app-frontend/src/components/ui/modal/UpdateToPlayModal.vue b/apps/app-frontend/src/components/ui/modal/UpdateToPlayModal.vue
index 9c756e6d6..ba5cff8b2 100644
--- a/apps/app-frontend/src/components/ui/modal/UpdateToPlayModal.vue
+++ b/apps/app-frontend/src/components/ui/modal/UpdateToPlayModal.vue
@@ -8,6 +8,7 @@
:diffs="normalizedDiffs"
:version-date="versionDate"
:show-external-warnings="showExternalWarnings"
+ :external-warning-description="formatMessage(messages.externalWarningDescription)"
:confirm-label="formatMessage(commonMessages.updateButton)"
:confirm-icon="DownloadIcon"
:removed-label="formatMessage(messages.removed)"
@@ -76,10 +77,15 @@ const { formatMessage } = useVIntl()
const { startInstallingServer, stopInstallingServer } = injectServerInstall()
type UpdateCompleteCallback = () => void | Promise
-defineProps<{
+const { showExternalWarnings } = defineProps<{
showExternalWarnings?: boolean
}>()
+const emit = defineEmits<{
+ cancel: []
+ complete: []
+}>()
+
const diffModal = ref>()
const instance = ref(null)
const onUpdateComplete = ref(() => {})
@@ -87,16 +93,16 @@ const diffs = ref([])
const modpackVersionId = ref(null)
const modpackVersion = ref(null)
-const normalizedDiffs = computed(() =>
- diffs.value.map((diff) => ({
+const normalizedDiffs = computed(() => {
+ return diffs.value.map((diff) => ({
type: diff.type,
external: Boolean(diff.fileName && !diff.project),
projectName: diff.project?.title,
fileName: diff.fileName,
currentVersionName: diff.currentVersion?.version_number,
newVersionName: diff.newVersion?.version_number,
- })),
-)
+ }))
+})
const versionDate = computed(() =>
modpackVersion.value?.date_published
@@ -242,7 +248,6 @@ async function checkUpdateAvailable(inst: GameInstance): Promise {
diff --git a/apps/app-frontend/src/components/ui/shared-instances/SharedInstanceAlreadyInstalledModal.vue b/apps/app-frontend/src/components/ui/shared-instances/SharedInstanceAlreadyInstalledModal.vue
new file mode 100644
index 000000000..3e5922027
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/shared-instances/SharedInstanceAlreadyInstalledModal.vue
@@ -0,0 +1,100 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/app-frontend/src/components/ui/shared-instances/SharedInstanceInstallationSettingsControls.vue b/apps/app-frontend/src/components/ui/shared-instances/SharedInstanceInstallationSettingsControls.vue
new file mode 100644
index 000000000..d72f17090
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/shared-instances/SharedInstanceInstallationSettingsControls.vue
@@ -0,0 +1,195 @@
+
+
+
{{ formatMessage(messages.title) }}
+
+
+
+
+
+
{{ formatMessage(messages.unpublishDescription) }}
+
+
+
{{
+ formatMessage(messages.linkedTitle)
+ }}
+
+
+
+
+
+
{{ formatMessage(messages.unlinkDescription) }}
+
+
+
+ {{
+ formatMessage(messages.unpublishModalBody)
+ }}
+
+
+
+
+
+
+
{{
+ formatMessage(messages.unlinkModalBody)
+ }}
+
+
+
+
+
+
+
+
+
diff --git a/apps/app-frontend/src/components/ui/shared-instances/SharedInstancePublishModal.vue b/apps/app-frontend/src/components/ui/shared-instances/SharedInstancePublishModal.vue
new file mode 100644
index 000000000..05b9ba6e8
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/shared-instances/SharedInstancePublishModal.vue
@@ -0,0 +1,189 @@
+
+
+
+
+
+
+
+ {{ formatMessage(messages.configTitle) }}
+
+
+
+
+
+
+
+
+
diff --git a/apps/app-frontend/src/components/ui/shared-instances/SharedInstanceUpdateModal.vue b/apps/app-frontend/src/components/ui/shared-instances/SharedInstanceUpdateModal.vue
new file mode 100644
index 000000000..4767a6ff0
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/shared-instances/SharedInstanceUpdateModal.vue
@@ -0,0 +1,131 @@
+
+
+
+
+
diff --git a/apps/app-frontend/src/components/ui/shared-instances/shared-instance-install-modal/index.vue b/apps/app-frontend/src/components/ui/shared-instances/shared-instance-install-modal/index.vue
new file mode 100644
index 000000000..e4ebfd949
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/shared-instances/shared-instance-install-modal/index.vue
@@ -0,0 +1,533 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ formatMessage(messages.inviteWarning) }}
+
+
+
+
+
+
+ {{ formatMessage(messages.reportReason) }}
+
+
+
+
+
+ {{ formatMessage(messages.additionalContext) }}
+
+
+
+
+
+
+
+
+
+ {{ formatMessage(messages.unknownFilesDescription) }}
+
+
+
+
+
+ {{ formatMessage(messages.reviewedFiles) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/app-frontend/src/components/ui/shared-instances/shared-instance-install-modal/shared-instance-install-summary.vue b/apps/app-frontend/src/components/ui/shared-instances/shared-instance-install-modal/shared-instance-install-summary.vue
new file mode 100644
index 000000000..8db6f7abb
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/shared-instances/shared-instance-install-modal/shared-instance-install-summary.vue
@@ -0,0 +1,60 @@
+
+
+
+ {{
+ heading ?? formatMessage(messages.sharedInstance)
+ }}
+
+
+
+
+
+
+
+ {{ preview.name }}
+
+ {{ loaderDisplay }} {{ preview.gameVersion }}
+
+ · {{ formatMessage(messages.modCount, { count: preview.modCount }) }}
+
+
+
+
+
+
+
+
diff --git a/apps/app-frontend/src/components/ui/shared-instances/shared-instance-install-modal/use-shared-instance-preview-content.ts b/apps/app-frontend/src/components/ui/shared-instances/shared-instance-install-modal/use-shared-instance-preview-content.ts
new file mode 100644
index 000000000..671b4b189
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/shared-instances/shared-instance-install-modal/use-shared-instance-preview-content.ts
@@ -0,0 +1,135 @@
+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 type { SharedInstanceInstallPreview } from '@/helpers/install'
+
+type VersionDependency = Labrinth.Versions.v2.Dependency & { version_id?: string }
+
+export function useSharedInstancePreviewContent() {
+ async function load(preview: SharedInstanceInstallPreview): Promise {
+ return [
+ ...preview.externalFiles.map(externalFileContentItem),
+ ...(await modpackContentItems(preview)),
+ ...(await contentItemsFromVersionIds(
+ preview.contentVersionIds.filter((id) => id !== preview.modpackVersionId),
+ )),
+ ]
+ }
+
+ async function modpackContentItems(preview: SharedInstanceInstallPreview) {
+ if (!preview.modpackVersionId) return []
+ const version = await get_version(preview.modpackVersionId, 'must_revalidate')
+ return await contentItemsFromDependencies(version?.dependencies ?? [])
+ }
+
+ async function contentItemsFromDependencies(dependencies: Labrinth.Versions.v2.Dependency[]) {
+ const deps = dependencies as VersionDependency[]
+ const projectIds = unique(deps.map((dep) => dep.project_id).filter((id): id is string => !!id))
+ const versionIds = unique(deps.map((dep) => dep.version_id).filter((id): id is string => !!id))
+ const [projects, versions]: [Labrinth.Projects.v2.Project[], Labrinth.Versions.v2.Version[]] =
+ await Promise.all([
+ projectIds.length ? get_project_many(projectIds, 'must_revalidate') : [],
+ versionIds.length ? get_version_many(versionIds, 'must_revalidate') : [],
+ ])
+ const projectMap = new Map(projects.map((project) => [project.id, project]))
+ const versionMap = new Map(versions.map((version) => [version.id, version]))
+
+ return deps.map((dependency): ContentItem => {
+ const project = dependency.project_id ? projectMap.get(dependency.project_id) : null
+ const version = dependency.version_id ? versionMap.get(dependency.version_id) : null
+ const fileName =
+ version?.files?.[0]?.filename ?? dependency.file_name ?? project?.title ?? 'Unknown'
+ return contentItem(
+ version?.id ?? project?.id ?? fileName,
+ fileName,
+ project,
+ version,
+ !project && !version,
+ dependency.project_id ?? fileName,
+ dependency.file_name ?? fileName,
+ )
+ })
+ }
+
+ async function contentItemsFromVersionIds(versionIds: string[]) {
+ const versions: Labrinth.Versions.v2.Version[] = versionIds.length
+ ? await get_version_many(unique(versionIds), 'must_revalidate')
+ : []
+ const projectIds = unique(versions.map((version) => version.project_id).filter(Boolean))
+ const projects: Labrinth.Projects.v2.Project[] = projectIds.length
+ ? await get_project_many(projectIds, 'must_revalidate')
+ : []
+ const projectMap = new Map(projects.map((project) => [project.id, project]))
+ return versions.map((version): ContentItem => {
+ const project = projectMap.get(version.project_id)
+ const fileName = version.files?.[0]?.filename ?? project?.title ?? version.name ?? 'Unknown'
+ return contentItem(
+ version.id,
+ fileName,
+ project,
+ version,
+ false,
+ version.project_id,
+ version.name,
+ )
+ })
+ }
+
+ return { load }
+}
+
+function contentItem(
+ id: string,
+ fileName: string,
+ project?: Labrinth.Projects.v2.Project | null,
+ version?: Labrinth.Versions.v2.Version | null,
+ external = false,
+ fallbackProjectId = id,
+ fallbackTitle = fileName,
+ projectType = project?.project_type ?? 'mod',
+): ContentItem {
+ return {
+ id,
+ file_name: fileName,
+ project_type: projectType,
+ has_update: false,
+ update_version_id: null,
+ external,
+ project: {
+ id: project?.id ?? fallbackProjectId,
+ slug: project?.slug ?? fallbackProjectId,
+ title: project?.title ?? fallbackTitle,
+ icon_url: project?.icon_url ?? undefined,
+ },
+ ...(version
+ ? {
+ version: {
+ id: version.id,
+ file_name: fileName,
+ version_number: version.version_number ?? undefined,
+ date_published: version.date_published ?? undefined,
+ },
+ }
+ : {}),
+ }
+}
+
+function externalFileContentItem(
+ file: SharedInstanceInstallPreview['externalFiles'][number],
+): ContentItem {
+ return contentItem(
+ `external:${file.fileType}:${file.fileName}`,
+ file.fileName,
+ null,
+ null,
+ true,
+ file.fileName,
+ file.fileName,
+ file.fileType,
+ )
+}
+
+function unique(values: T[]) {
+ return Array.from(new Set(values))
+}
diff --git a/apps/app-frontend/src/components/ui/shared-instances/shared-instance-invite-handler/index.vue b/apps/app-frontend/src/components/ui/shared-instances/shared-instance-invite-handler/index.vue
new file mode 100644
index 000000000..2229707f4
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/shared-instances/shared-instance-invite-handler/index.vue
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
diff --git a/apps/app-frontend/src/components/ui/shared-instances/shared-instance-invite-handler/shared-instance-invite-parser.ts b/apps/app-frontend/src/components/ui/shared-instances/shared-instance-invite-handler/shared-instance-invite-parser.ts
new file mode 100644
index 000000000..60803ee4a
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/shared-instances/shared-instance-invite-handler/shared-instance-invite-parser.ts
@@ -0,0 +1,29 @@
+import type {
+ AppNotification,
+ SharedInstanceInvite,
+ SharedInstanceInviteNotificationBody,
+} from './shared-instance-invite-types'
+
+function optionalString(value: unknown) {
+ return typeof value === 'string' ? value : null
+}
+
+export function parseSharedInstanceInviteNotification(
+ notification: AppNotification,
+): SharedInstanceInvite | null {
+ if (notification.body?.type !== 'shared_instance_invite') return null
+
+ const body = notification.body as SharedInstanceInviteNotificationBody
+ const sharedInstanceId = optionalString(body.shared_instance_id)
+ const sharedInstanceName = optionalString(body.shared_instance_name)
+ if (!sharedInstanceId || !sharedInstanceName) return null
+
+ return {
+ sharedInstanceId,
+ sharedInstanceName,
+ invitedById: optionalString(body.invited_by),
+ invitedByUsername: optionalString(body.invited_by_username),
+ invitedByAvatarUrl: optionalString(body.invited_by_avatar_url),
+ instanceIconUrl: optionalString(body.shared_instance_icon),
+ }
+}
diff --git a/apps/app-frontend/src/components/ui/shared-instances/shared-instance-invite-handler/shared-instance-invite-types.ts b/apps/app-frontend/src/components/ui/shared-instances/shared-instance-invite-handler/shared-instance-invite-types.ts
new file mode 100644
index 000000000..0227b4542
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/shared-instances/shared-instance-invite-handler/shared-instance-invite-types.ts
@@ -0,0 +1,29 @@
+export type SharedInstanceInviteNotificationBody = {
+ type: 'shared_instance_invite'
+ shared_instance_id?: unknown
+ shared_instance_name?: unknown
+ invited_by?: unknown
+ invited_by_username?: unknown
+ invited_by_avatar_url?: unknown
+ shared_instance_icon?: unknown
+}
+
+export type AppNotification = {
+ id: string | number
+ read?: boolean
+ body?: { type?: unknown } & Record
+}
+
+export type SharedInstanceInvite = {
+ sharedInstanceId: string
+ sharedInstanceName: string
+ invitedById: string | null
+ invitedByUsername: string | null
+ invitedByAvatarUrl: string | null
+ instanceIconUrl: string | null
+}
+
+export type SharedInstanceInviteHandler = {
+ handleNotification(notification: AppNotification): Promise
+ installFromInviteId(inviteId: string): Promise
+}
diff --git a/apps/app-frontend/src/components/ui/shared-instances/shared-instance-invite-handler/use-shared-instance-invite-handler.ts b/apps/app-frontend/src/components/ui/shared-instances/shared-instance-invite-handler/use-shared-instance-invite-handler.ts
new file mode 100644
index 000000000..58f4c1480
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/shared-instances/shared-instance-invite-handler/use-shared-instance-invite-handler.ts
@@ -0,0 +1,252 @@
+import { ModrinthApiError } from '@modrinth/api-client'
+import {
+ injectAuth,
+ injectModrinthClient,
+ injectNotificationManager,
+ injectPopupNotificationManager,
+} from '@modrinth/ui'
+import { useQueryClient } from '@tanstack/vue-query'
+import { openUrl } from '@tauri-apps/plugin-opener'
+import { type Ref, watch } from 'vue'
+import { useRouter } from 'vue-router'
+
+import { config } from '@/config'
+import { get_user } from '@/helpers/cache'
+import { toError } from '@/helpers/errors'
+import {
+ install_accept_shared_instance_invite,
+ install_get_shared_instance_preview,
+ install_shared_instance,
+} from '@/helpers/install'
+import { list } from '@/helpers/instance'
+import { useTheming } from '@/store/state'
+
+import { parseSharedInstanceInviteNotification } from './shared-instance-invite-parser'
+import type { AppNotification, SharedInstanceInvite } from './shared-instance-invite-types'
+
+type InstallModal = {
+ show(
+ preview: Awaited>,
+ install: () => Promise,
+ ): void
+}
+
+type AccountRequiredModal = {
+ show(event?: MouseEvent): Promise
+}
+
+type AlreadyInstalledModal = {
+ show(instanceName: string): void
+}
+
+export function useSharedInstanceInviteHandler(
+ installModal: Ref,
+ alreadyInstalledModal: Ref,
+ accountRequiredModal: Ref,
+) {
+ const auth = injectAuth()
+ const client = injectModrinthClient()
+ const { handleError } = injectNotificationManager()
+ const { addPopupNotification } = injectPopupNotificationManager()
+ const queryClient = useQueryClient()
+ const router = useRouter()
+ const themeStore = useTheming()
+ const displayedNotifications = new Set()
+ let pendingAlreadyInstalled:
+ | {
+ instanceId: string
+ preview: Awaited>
+ install: () => Promise
+ onGoToInstance?: () => void | Promise
+ }
+ | undefined
+
+ async function markNotificationRead(notification: AppNotification) {
+ try {
+ await client.labrinth.notifications_v2.markAsRead(String(notification.id))
+ } catch (error) {
+ if (error instanceof ModrinthApiError && error.statusCode === 404) return
+ throw error
+ }
+ }
+
+ async function resolveInvite(invite: SharedInstanceInvite) {
+ const [invitedBy, sharedInstance] = await Promise.all([
+ !invite.invitedByUsername && invite.invitedById
+ ? get_user(invite.invitedById, 'bypass').catch(() => null)
+ : null,
+ client.sharedinstances.instances_v1.get(invite.sharedInstanceId).catch(() => null),
+ ])
+
+ return {
+ ...invite,
+ invitedByUsername: invite.invitedByUsername ?? invitedBy?.username ?? null,
+ invitedByAvatarUrl: invite.invitedByAvatarUrl ?? invitedBy?.avatar_url ?? null,
+ instanceIconUrl: sharedInstance ? sharedInstance.icon : invite.instanceIconUrl,
+ }
+ }
+
+ function showInstall(
+ preview: Awaited>,
+ install: () => Promise,
+ ) {
+ if (!installModal.value) throw new Error('Shared instance install modal is not available.')
+ installModal.value.show(preview, install)
+ }
+
+ async function showInstallOrAlreadyInstalled(
+ sharedInstanceId: string,
+ preview: Awaited>,
+ install: () => Promise,
+ onGoToInstance?: () => void | Promise,
+ ) {
+ const existingInstance = (await list()).find(
+ (instance) => instance.shared_instance?.id === sharedInstanceId,
+ )
+
+ if (!existingInstance || themeStore.getFeatureFlag('skip_non_essential_warnings')) {
+ showInstall(preview, install)
+ return
+ }
+
+ if (!alreadyInstalledModal.value) {
+ throw new Error('Shared instance already installed modal is not available.')
+ }
+
+ pendingAlreadyInstalled = {
+ instanceId: existingInstance.id,
+ preview,
+ install,
+ onGoToInstance,
+ }
+ alreadyInstalledModal.value.show(existingInstance.name)
+ }
+
+ function handleAlreadyInstalledCancel() {
+ pendingAlreadyInstalled = undefined
+ }
+
+ async function handleAlreadyInstalledGoToInstance() {
+ const pending = pendingAlreadyInstalled
+ pendingAlreadyInstalled = undefined
+ if (!pending) return
+
+ if (pending.onGoToInstance) {
+ try {
+ await pending.onGoToInstance()
+ } catch (error) {
+ handleError(toError(error))
+ }
+ }
+ await router.push(`/instance/${encodeURIComponent(pending.instanceId)}/`)
+ }
+
+ function handleAlreadyInstalledInstallAnyway() {
+ const pending = pendingAlreadyInstalled
+ pendingAlreadyInstalled = undefined
+ if (!pending) return
+ showInstall(pending.preview, pending.install)
+ }
+
+ async function acceptNotification(notification: AppNotification, invite: SharedInstanceInvite) {
+ try {
+ const preview = await install_get_shared_instance_preview(
+ invite.sharedInstanceId,
+ invite.sharedInstanceName,
+ )
+ if (invite.instanceIconUrl) preview.iconUrl = invite.instanceIconUrl
+
+ await showInstallOrAlreadyInstalled(
+ invite.sharedInstanceId,
+ preview,
+ async () => {
+ await install_shared_instance(
+ invite.sharedInstanceId,
+ invite.sharedInstanceName,
+ invite.invitedById,
+ null,
+ null,
+ invite.instanceIconUrl,
+ )
+ await markNotificationRead(notification)
+ await queryClient.invalidateQueries({ queryKey: ['instances'] })
+ },
+ () => markNotificationRead(notification),
+ )
+ } catch (error) {
+ handleError(toError(error))
+ }
+ }
+
+ async function handleNotification(notification: AppNotification) {
+ const parsedInvite = parseSharedInstanceInviteNotification(notification)
+ if (!parsedInvite) return false
+ if (displayedNotifications.has(notification.id)) return true
+
+ displayedNotifications.add(notification.id)
+ const invite = await resolveInvite(parsedInvite)
+ addPopupNotification({
+ title: invite.sharedInstanceName,
+ autoCloseMs: null,
+ toast: {
+ type: 'instance-invite',
+ actorName: invite.invitedByUsername,
+ actorAvatarUrl: invite.invitedByAvatarUrl ?? undefined,
+ entityName: invite.sharedInstanceName,
+ entityIconUrl: invite.instanceIconUrl ?? undefined,
+ onAccept: () => acceptNotification(notification, invite),
+ onDecline: () =>
+ markNotificationRead(notification).catch((error) => handleError(toError(error))),
+ onOpenActor: () => {
+ if (invite.invitedByUsername) {
+ openUrl(`${config.siteUrl}/user/${encodeURIComponent(invite.invitedByUsername)}`)
+ }
+ },
+ },
+ })
+ return true
+ }
+
+ async function requireAccount() {
+ if (!auth.isReady?.value) {
+ await new Promise((resolve) => {
+ const stop = watch(auth.isReady!, (ready) => {
+ if (ready) {
+ stop()
+ resolve()
+ }
+ })
+ })
+ }
+ if (auth.session_token.value) return true
+ return (await accountRequiredModal.value?.show()) ?? false
+ }
+
+ async function installFromInviteId(inviteId: string) {
+ 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'] })
+ })
+ } catch (error) {
+ handleError(toError(error))
+ }
+ }
+
+ return {
+ handleNotification,
+ installFromInviteId,
+ handleAlreadyInstalledCancel,
+ handleAlreadyInstalledGoToInstance,
+ handleAlreadyInstalledInstallAnyway,
+ }
+}
diff --git a/apps/app-frontend/src/components/ui/world/InstanceItem.vue b/apps/app-frontend/src/components/ui/world/InstanceItem.vue
index f76801f9c..d2c2388ae 100644
--- a/apps/app-frontend/src/components/ui/world/InstanceItem.vue
+++ b/apps/app-frontend/src/components/ui/world/InstanceItem.vue
@@ -80,6 +80,7 @@ const playing = ref(false)
const play = async (event: MouseEvent) => {
event?.stopPropagation()
+ if (props.instance.quarantined) return
loading.value = true
await run(props.instance.id)
.catch((err) => handleSevereError(err, { instanceId: props.instance.id }))
@@ -192,8 +193,14 @@ onUnmounted(() => {