@@ -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/ModrinthAccountRequiredModal.vue b/apps/app-frontend/src/components/ui/modal/ModrinthAccountRequiredModal.vue
new file mode 100644
index 0000000000..349ecc8445
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/modal/ModrinthAccountRequiredModal.vue
@@ -0,0 +1,152 @@
+
+
+
+
+
+ {{ formatMessage(messages.signInHeading) }}
+
+
+ {{ formatMessage(messages.description) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/app-frontend/src/components/ui/modal/UpdateToPlayModal.vue b/apps/app-frontend/src/components/ui/modal/UpdateToPlayModal.vue
index 9c756e6d6d..ba5cff8b2a 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/SharedInstanceInstallationSettingsControls.vue b/apps/app-frontend/src/components/ui/shared-instances/SharedInstanceInstallationSettingsControls.vue
new file mode 100644
index 0000000000..8e7ccff71b
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/shared-instances/SharedInstanceInstallationSettingsControls.vue
@@ -0,0 +1,181 @@
+
+
+
{{ 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/SharedInstanceUpdateModal.vue b/apps/app-frontend/src/components/ui/shared-instances/SharedInstanceUpdateModal.vue
new file mode 100644
index 0000000000..fcec9d23ef
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/shared-instances/SharedInstanceUpdateModal.vue
@@ -0,0 +1,129 @@
+
+
+
+
+
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 0000000000..5ef1179a61
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/shared-instances/shared-instance-install-modal/index.vue
@@ -0,0 +1,437 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ formatMessage(messages.trustWarningDescription) }}
+
+
+
+
+
+
+
+ {{ formatMessage(messages.reportReason) }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ 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 0000000000..745c63258b
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/shared-instances/shared-instance-install-modal/shared-instance-install-summary.vue
@@ -0,0 +1,62 @@
+
+
+
+ {{
+ 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 0000000000..671b4b1899
--- /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 0000000000..376771d52a
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/shared-instances/shared-instance-invite-handler/index.vue
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+
diff --git a/apps/app-frontend/src/components/ui/shared-instances/shared-instance-invite-handler/shared-instance-invite-link-modal.vue b/apps/app-frontend/src/components/ui/shared-instances/shared-instance-invite-handler/shared-instance-invite-link-modal.vue
new file mode 100644
index 0000000000..109eb09054
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/shared-instances/shared-instance-invite-handler/shared-instance-invite-link-modal.vue
@@ -0,0 +1,62 @@
+
+
+
+ Invite link
+
+
+ Enter a valid Modrinth shared-instance invite link.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 0000000000..95f0f704d2
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/shared-instances/shared-instance-invite-handler/shared-instance-invite-parser.ts
@@ -0,0 +1,42 @@
+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.instance_icon_url),
+ }
+}
+
+export function parseSharedInstanceInviteLink(value: string) {
+ const trimmedValue = value.trim()
+ if (!trimmedValue) return null
+
+ try {
+ const url = new URL(trimmedValue)
+ const match = /^\/share\/([^/]+)\/?$/.exec(url.pathname)
+ return match ? decodeURIComponent(match[1]) : null
+ } catch {
+ return null
+ }
+}
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 0000000000..10a29095ea
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/shared-instances/shared-instance-invite-handler/shared-instance-invite-types.ts
@@ -0,0 +1,30 @@
+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
+ instance_icon_url?: 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
+ showManualInviteLinkModal(event?: MouseEvent): void
+}
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 0000000000..43aca24f6d
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/shared-instances/shared-instance-invite-handler/use-shared-instance-invite-handler.ts
@@ -0,0 +1,160 @@
+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 { 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 { 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
+}
+
+export function useSharedInstanceInviteHandler(
+ installModal: Ref,
+ accountRequiredModal: Ref,
+) {
+ const auth = injectAuth()
+ const client = injectModrinthClient()
+ const { handleError } = injectNotificationManager()
+ const { addPopupNotification } = injectPopupNotificationManager()
+ const queryClient = useQueryClient()
+ const displayedNotifications = new Set()
+
+ async function markNotificationRead(notification: AppNotification) {
+ await client.labrinth.notifications_v2.markAsRead(String(notification.id))
+ }
+
+ async function resolveInvite(invite: SharedInstanceInvite) {
+ const invitedBy =
+ !invite.invitedByUsername && invite.invitedById
+ ? await get_user(invite.invitedById, 'bypass').catch(() => null)
+ : null
+
+ return {
+ ...invite,
+ invitedByUsername: invite.invitedByUsername ?? invitedBy?.username ?? null,
+ invitedByAvatarUrl: invite.invitedByAvatarUrl ?? invitedBy?.avatar_url ?? null,
+ }
+ }
+
+ 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 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
+
+ showInstall(preview, async () => {
+ await install_shared_instance(
+ invite.sharedInstanceId,
+ invite.sharedInstanceName,
+ invite.invitedById,
+ null,
+ null,
+ invite.instanceIconUrl,
+ )
+ await markNotificationRead(notification)
+ await queryClient.invalidateQueries({ queryKey: ['instances'] })
+ })
+ } 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)
+ showInstall(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 }
+}
diff --git a/apps/app-frontend/src/composables/browse/install-job-notifications.ts b/apps/app-frontend/src/composables/browse/install-job-notifications.ts
index 8d7eaa832d..513c6a90ea 100644
--- a/apps/app-frontend/src/composables/browse/install-job-notifications.ts
+++ b/apps/app-frontend/src/composables/browse/install-job-notifications.ts
@@ -53,6 +53,10 @@ const messages = defineMessages({
id: 'app.action-bar.install.unknown-instance',
defaultMessage: 'Unknown instance',
},
+ updatingSharedContent: {
+ id: 'app.action-bar.install.updating-shared-content',
+ defaultMessage: 'Updating shared content',
+ },
})
const phaseMessages = defineMessages({
@@ -268,6 +272,9 @@ export async function useInstallJobNotifications(opts: {
version: job.details.major_version,
})
}
+ if (job.kind === 'update_shared_instance' && job.phase === 'downloading_content') {
+ return formatMessage(messages.updatingSharedContent)
+ }
return formatMessage(phaseMessages[job.phase])
}
diff --git a/apps/app-frontend/src/composables/instances/use-managed-content-policy.ts b/apps/app-frontend/src/composables/instances/use-managed-content-policy.ts
new file mode 100644
index 0000000000..f0fb3f930c
--- /dev/null
+++ b/apps/app-frontend/src/composables/instances/use-managed-content-policy.ts
@@ -0,0 +1,119 @@
+import { type ContentActionWarning, type ContentItem, defineMessages, useVIntl } from '@modrinth/ui'
+import { computed, type Ref } from 'vue'
+
+import type { GameInstance } from '@/helpers/types'
+
+const managedSourceKinds = new Set(['shared_instance', 'modrinth_modpack', 'imported_modpack'])
+
+export function useManagedContentPolicy(instance: Ref) {
+ const { formatMessage } = useVIntl()
+ const isManagedModpack = computed(() => instance.value.shared_instance?.role === 'member')
+ const canUnpublish = computed(() => instance.value.shared_instance?.role === 'owner')
+ const canUnlink = computed(() => instance.value.shared_instance?.role === 'member')
+ const managedModpackWarning = computed(() => ({
+ admonitionHeader: formatMessage(messages.warningHeader),
+ changeVersionBody: formatMessage(messages.changeVersionBody),
+ unlinkBody: formatMessage(messages.unlinkBody),
+ }))
+
+ function isManagedContent(item: ContentItem) {
+ return isManagedModpack.value && !!item.source_kind && managedSourceKinds.has(item.source_kind)
+ }
+
+ function canMutateContent(item: ContentItem) {
+ return !isManagedContent(item)
+ }
+
+ function canUpdateContent(item: ContentItem) {
+ return (
+ canMutateContent(item) && !!item.file_path && !!item.has_update && !!item.update_version_id
+ )
+ }
+
+ function deleteWarning(items: ContentItem[]): ContentActionWarning | null {
+ if (!items.some(isManagedContent)) return null
+ return {
+ admonitionHeader: formatMessage(messages.warningHeader),
+ admonitionBody: formatMessage(
+ items.length === 1 ? messages.deleteSingleBody : messages.deleteBulkBody,
+ ),
+ actionLabel: formatMessage(
+ items.length === 1 ? messages.deleteButton : messages.deleteManyButton,
+ { count: items.length },
+ ),
+ }
+ }
+
+ function disableWarning(items: ContentItem[]): ContentActionWarning | null {
+ if (!items.some(isManagedContent)) return null
+ return {
+ admonitionHeader: formatMessage(messages.warningHeader),
+ admonitionBody: formatMessage(
+ items.length === 1 ? messages.disableSingleBody : messages.disableBulkBody,
+ ),
+ actionLabel: formatMessage(
+ items.length === 1 ? messages.disableButton : messages.disableManyButton,
+ { count: items.length },
+ ),
+ }
+ }
+
+ return {
+ isManagedModpack,
+ canUnpublish,
+ canUnlink,
+ managedModpackWarning,
+ isManagedContent,
+ canMutateContent,
+ canUpdateContent,
+ deleteWarning,
+ disableWarning,
+ }
+}
+
+const messages = defineMessages({
+ warningHeader: {
+ id: 'content.shared-instance.warning-header',
+ defaultMessage: 'This is part of the shared instance',
+ },
+ changeVersionBody: {
+ id: 'content.shared-instance.change-version-body',
+ defaultMessage:
+ 'Changing the version only changes your local copy. Future shared instance updates may restore or change it again.',
+ },
+ unlinkBody: {
+ id: 'content.shared-instance.unlink-body',
+ defaultMessage:
+ 'Unlinking only changes your local copy. Future shared instance updates may restore or change it again.',
+ },
+ deleteSingleBody: {
+ id: 'content.shared-instance.delete-single-body',
+ defaultMessage:
+ 'Deleting it only changes your local copy. Future shared instance updates may restore or change it again.',
+ },
+ deleteBulkBody: {
+ id: 'content.shared-instance.delete-bulk-body',
+ defaultMessage:
+ 'Some selected projects are part of the shared instance. Deleting them only changes your local copy, and future shared instance updates may restore or change them again.',
+ },
+ deleteButton: { id: 'content.shared-instance.delete-button', defaultMessage: 'Delete anyway' },
+ deleteManyButton: {
+ id: 'content.shared-instance.delete-many-button',
+ defaultMessage: 'Delete {count, number} projects anyway',
+ },
+ disableSingleBody: {
+ id: 'content.shared-instance.disable-single-body',
+ defaultMessage:
+ 'Disabling it only changes your local copy. Future shared instance updates may re-enable, restore, or change it again.',
+ },
+ disableBulkBody: {
+ id: 'content.shared-instance.disable-bulk-body',
+ defaultMessage:
+ 'Some selected projects are part of the shared instance. Disabling them only changes your local copy, and future shared instance updates may re-enable, restore, or change them again.',
+ },
+ disableButton: { id: 'content.shared-instance.disable-button', defaultMessage: 'Disable anyway' },
+ disableManyButton: {
+ id: 'content.shared-instance.disable-many-button',
+ defaultMessage: 'Disable {count, number} projects anyway',
+ },
+})
diff --git a/apps/app-frontend/src/composables/use-friends.ts b/apps/app-frontend/src/composables/use-friends.ts
new file mode 100644
index 0000000000..0bec9cbb71
--- /dev/null
+++ b/apps/app-frontend/src/composables/use-friends.ts
@@ -0,0 +1,148 @@
+import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
+import { computed, type MaybeRefOrGetter, onUnmounted, toValue } from 'vue'
+
+import { toError } from '@/helpers/errors'
+import { friend_listener } from '@/helpers/events.js'
+import {
+ acceptCachedFriend,
+ add_friend,
+ createPendingFriend,
+ type FriendCacheUser,
+ friendsQueryKey,
+ type FriendWithUserData,
+ getFriendsWithUserData,
+ getFriendUserId,
+ matchesFriend,
+ remove_friend,
+ removeCachedFriend,
+ upsertCachedFriend,
+} from '@/helpers/friends'
+import type { ModrinthCredentials } from '@/helpers/mr_auth'
+
+type FriendsMutationContext = {
+ queryKey: ReturnType
+ previousFriends?: FriendWithUserData[]
+}
+
+type AddFriendMutationVariables = {
+ userId: string
+ user: FriendCacheUser
+ acceptExisting: boolean
+}
+
+type RemoveFriendMutationVariables = {
+ userId: string
+ user: FriendWithUserData
+}
+
+export function useFriends(options: {
+ currentUserId: MaybeRefOrGetter
+ getCredentials: () => ModrinthCredentials | null | Promise
+ enabled?: MaybeRefOrGetter
+ onError?: (error: Error) => void
+}) {
+ const queryClient = useQueryClient()
+ const queryKey = computed(() => friendsQueryKey(toValue(options.currentUserId)))
+ const query = useQuery({
+ queryKey,
+ queryFn: async () => getFriendsWithUserData(await options.getCredentials()),
+ enabled: () => !!toValue(options.currentUserId) && toValue(options.enabled ?? true),
+ staleTime: 30_000,
+ })
+ const friends = computed(() => query.data.value ?? [])
+
+ function restore(context?: FriendsMutationContext) {
+ if (!context) return
+ if (context.previousFriends === undefined) {
+ queryClient.removeQueries({ queryKey: context.queryKey, exact: true })
+ return
+ }
+ queryClient.setQueryData(context.queryKey, context.previousFriends)
+ }
+
+ const addMutation = useMutation({
+ mutationFn: ({ userId }: AddFriendMutationVariables) => add_friend(userId),
+ onMutate: async ({ user, acceptExisting }): Promise => {
+ const activeQueryKey = queryKey.value
+ await queryClient.cancelQueries({ queryKey: activeQueryKey })
+ const previousFriends = queryClient.getQueryData(activeQueryKey)
+ const currentUserId = toValue(options.currentUserId)
+ queryClient.setQueryData(activeQueryKey, (cachedFriends = []) =>
+ acceptExisting
+ ? acceptCachedFriend(cachedFriends, user.id, user.username, currentUserId)
+ : upsertCachedFriend(
+ cachedFriends,
+ createPendingFriend(user, currentUserId),
+ currentUserId,
+ ),
+ )
+ return { queryKey: activeQueryKey, previousFriends }
+ },
+ onError: (error, _variables, context) => {
+ restore(context)
+ options.onError?.(toError(error))
+ },
+ onSettled: (_data, _error, _variables, context) => {
+ void queryClient.invalidateQueries({ queryKey: context?.queryKey ?? queryKey.value })
+ },
+ })
+
+ const removeMutation = useMutation({
+ mutationFn: ({ userId }: RemoveFriendMutationVariables) => remove_friend(userId),
+ onMutate: async ({ user, userId }): Promise => {
+ const activeQueryKey = queryKey.value
+ await queryClient.cancelQueries({ queryKey: activeQueryKey })
+ const previousFriends = queryClient.getQueryData(activeQueryKey)
+ queryClient.setQueryData(activeQueryKey, (cachedFriends = []) =>
+ removeCachedFriend(cachedFriends, userId, user.username, toValue(options.currentUserId)),
+ )
+ return { queryKey: activeQueryKey, previousFriends }
+ },
+ onError: (error, _variables, context) => {
+ restore(context)
+ options.onError?.(toError(error))
+ },
+ onSettled: (_data, _error, _variables, context) => {
+ void queryClient.invalidateQueries({ queryKey: context?.queryKey ?? queryKey.value })
+ },
+ })
+
+ function requestFriend(user: FriendCacheUser, acceptExisting = false) {
+ addMutation.mutate({ userId: user.id, user, acceptExisting })
+ }
+
+ function acceptFriend(friend: FriendWithUserData) {
+ const userId = getFriendUserId(friend, toValue(options.currentUserId))
+ requestFriend({ id: userId, username: friend.username, avatarUrl: friend.avatar }, true)
+ }
+
+ function removeFriend(friend: FriendWithUserData) {
+ const userId = getFriendUserId(friend, toValue(options.currentUserId))
+ removeMutation.mutate({ userId, user: friend })
+ }
+
+ function findFriend(id: string, username: string) {
+ return friends.value.find((friend) =>
+ matchesFriend(friend, id, username, toValue(options.currentUserId)),
+ )
+ }
+
+ let unlisten: (() => void) | undefined
+ void friend_listener(() => {
+ void queryClient.invalidateQueries({ queryKey: queryKey.value })
+ }).then((listener) => {
+ unlisten = listener
+ })
+ onUnmounted(() => unlisten?.())
+
+ return {
+ query,
+ queryKey,
+ friends,
+ loading: computed(() => !!toValue(options.currentUserId) && query.isLoading.value),
+ requestFriend,
+ acceptFriend,
+ removeFriend,
+ findFriend,
+ }
+}
diff --git a/apps/app-frontend/src/composables/users/use-user-query.ts b/apps/app-frontend/src/composables/users/use-user-query.ts
new file mode 100644
index 0000000000..6834a6721e
--- /dev/null
+++ b/apps/app-frontend/src/composables/users/use-user-query.ts
@@ -0,0 +1,17 @@
+import { useQuery } from '@tanstack/vue-query'
+import { computed, type Ref } from 'vue'
+
+import { get_user } from '@/helpers/cache.js'
+
+export function useUserQuery(userId: Ref) {
+ return useQuery({
+ queryKey: computed(() => ['user', userId.value]),
+ queryFn: async ({ queryKey }) => {
+ const id = queryKey[1]
+ if (typeof id !== 'string') return null
+ return await get_user(id, 'bypass').catch(() => null)
+ },
+ enabled: () => !!userId.value,
+ staleTime: 30_000,
+ })
+}
diff --git a/apps/app-frontend/src/helpers/errors.ts b/apps/app-frontend/src/helpers/errors.ts
new file mode 100644
index 0000000000..ab45f8550c
--- /dev/null
+++ b/apps/app-frontend/src/helpers/errors.ts
@@ -0,0 +1,11 @@
+export function toError(error: unknown) {
+ if (error instanceof Error) return error
+ if (typeof error === 'string') return new Error(error)
+ if (error && typeof error === 'object') {
+ const record = error as Record
+ const message = record.message ?? record.error
+ if (typeof message === 'string') return new Error(message)
+ return new Error(JSON.stringify(error))
+ }
+ return new Error(String(error))
+}
diff --git a/apps/app-frontend/src/helpers/friends.ts b/apps/app-frontend/src/helpers/friends.ts
index 7fd27b5a88..bba32aad9b 100644
--- a/apps/app-frontend/src/helpers/friends.ts
+++ b/apps/app-frontend/src/helpers/friends.ts
@@ -6,6 +6,8 @@ import dayjs from 'dayjs'
import { get_user_many } from '@/helpers/cache'
import type { ModrinthCredentials } from '@/helpers/mr_auth'
+export const friendsQueryKey = (userId?: string | null) => ['friends', userId ?? null] as const
+
export type UserStatus = {
user_id: string
instance_name: string | null
@@ -46,6 +48,111 @@ export type FriendWithUserData = {
online: boolean
avatar: string
}
+
+export type FriendCacheUser = {
+ id: string
+ username: string
+ avatarUrl?: string | null
+}
+
+export async function getFriendsWithUserData(
+ credentials: ModrinthCredentials | null,
+): Promise {
+ if (!credentials) return []
+
+ const friendsList = await friends()
+ return await transformFriends(friendsList, credentials)
+}
+
+export function createPendingFriend(
+ user: FriendCacheUser,
+ currentUserId?: string | null,
+): FriendWithUserData {
+ return {
+ id: user.id,
+ friend_id: currentUserId ?? null,
+ status: null,
+ last_updated: null,
+ created: dayjs(),
+ username: user.username,
+ accepted: false,
+ online: false,
+ avatar: user.avatarUrl ?? '',
+ }
+}
+
+export function getFriendUserId(
+ friend: Pick,
+ currentUserId?: string | null,
+) {
+ return friend.id === currentUserId && friend.friend_id ? friend.friend_id : friend.id
+}
+
+export function matchesFriend(
+ friend: FriendWithUserData,
+ id: string,
+ username: string,
+ currentUserId?: string | null,
+) {
+ const friendId = getFriendUserId(friend, currentUserId)
+ return (
+ normalizeFriendKey(friendId) === normalizeFriendKey(id) ||
+ normalizeFriendKey(friend.username) === normalizeFriendKey(username)
+ )
+}
+
+export function upsertCachedFriend(
+ friends: FriendWithUserData[],
+ friend: FriendWithUserData,
+ currentUserId?: string | null,
+) {
+ const existingFriend = friends.find((cachedFriend) =>
+ matchesFriend(cachedFriend, friend.id, friend.username, currentUserId),
+ )
+
+ if (!existingFriend) return [friend, ...friends]
+
+ return friends.map((cachedFriend) =>
+ matchesFriend(cachedFriend, friend.id, friend.username, currentUserId)
+ ? {
+ ...cachedFriend,
+ ...friend,
+ id: cachedFriend.id,
+ friend_id: cachedFriend.friend_id,
+ }
+ : cachedFriend,
+ )
+}
+
+export function acceptCachedFriend(
+ friends: FriendWithUserData[],
+ id: string,
+ username: string,
+ currentUserId?: string | null,
+) {
+ return friends.map((friend) =>
+ matchesFriend(friend, id, username, currentUserId)
+ ? {
+ ...friend,
+ accepted: true,
+ }
+ : friend,
+ )
+}
+
+export function removeCachedFriend(
+ friends: FriendWithUserData[],
+ id: string,
+ username: string,
+ currentUserId?: string | null,
+) {
+ return friends.filter((friend) => !matchesFriend(friend, id, username, currentUserId))
+}
+
+export function normalizeFriendKey(value: string) {
+ return value.trim().toLowerCase()
+}
+
export async function transformFriends(
friends: UserFriend[],
credentials: ModrinthCredentials | null,
diff --git a/apps/app-frontend/src/helpers/install.ts b/apps/app-frontend/src/helpers/install.ts
index ee0f3f2d83..32aa3377c3 100644
--- a/apps/app-frontend/src/helpers/install.ts
+++ b/apps/app-frontend/src/helpers/install.ts
@@ -45,6 +45,86 @@ export interface InstallPostInstallEdit {
link?: InstanceLink | null
}
+export interface SharedInstanceInstallPreview {
+ name: string
+ iconUrl?: string | null
+ gameVersion: string
+ loader: InstanceLoader
+ modCount: number
+ externalFileCount: number
+ modpackVersionId?: string | null
+ contentVersionIds: string[]
+ externalFiles: SharedInstanceExternalFilePreview[]
+}
+
+export interface SharedInstanceInviteInstallPreview {
+ sharedInstanceId: string
+ managerId?: string | null
+ serverManagerName?: string | null
+ serverManagerIconUrl?: string | null
+ instanceIconUrl?: string | null
+ preview: SharedInstanceInstallPreview
+}
+
+export interface SharedInstanceExternalFilePreview {
+ fileName: string
+ fileType: string
+}
+
+export interface SharedInstanceUpdatePreview {
+ sharedInstanceId: string
+ currentVersion?: number | null
+ latestVersion: number
+ updateAvailable: boolean
+ diffs: SharedInstanceUpdateDiff[]
+}
+
+export interface SharedInstanceUpdateDiff {
+ type:
+ | 'added'
+ | 'removed'
+ | 'updated'
+ | 'modpack_linked'
+ | 'modpack_updated'
+ | 'modpack_unlinked'
+ | 'game_version_updated'
+ | 'loader_updated'
+ | 'config_files_updated'
+ projectId?: string | null
+ projectName?: string | null
+ fileName?: string | null
+ currentVersionName?: string | null
+ newVersionName?: string | null
+ configFileCount?: number | null
+ disabled?: boolean
+}
+
+export const SHARED_INSTANCE_UNAVAILABLE_ERROR_CODE = 'shared_instance_unavailable'
+
+export type SharedInstanceUnavailableReason = 'deleted' | 'access_revoked'
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null
+}
+
+export function isSharedInstanceUnavailableError(error: unknown) {
+ return getSharedInstanceUnavailableReason(error) !== null
+}
+
+export function getSharedInstanceUnavailableReason(
+ error: unknown,
+): SharedInstanceUnavailableReason | null {
+ if (!isRecord(error) || error.code !== SHARED_INSTANCE_UNAVAILABLE_ERROR_CODE) return null
+ return error.reason === 'deleted' || error.reason === 'access_revoked' ? error.reason : null
+}
+
+export function getErrorMessage(error: unknown): string {
+ if (typeof error === 'string') return error
+ if (error instanceof Error) return error.message || 'Unknown error'
+ if (isRecord(error) && typeof error.message === 'string') return error.message
+ return 'Unknown error'
+}
+
export type InstallJobStatus =
| 'queued'
| 'running'
@@ -90,6 +170,7 @@ export interface InstallErrorView {
code: string
phase?: InstallPhaseId | null
message: string
+ reason?: SharedInstanceUnavailableReason | null
api?: {
error: string
status?: number | null
@@ -122,6 +203,8 @@ export interface InstallJobSnapshot {
kind:
| 'create_instance'
| 'create_modpack_instance'
+ | 'create_shared_instance'
+ | 'update_shared_instance'
| 'import_instance'
| 'duplicate_instance'
| 'install_existing_instance'
@@ -172,6 +255,58 @@ export async function install_create_modpack_instance(
})
}
+export async function install_get_shared_instance_preview(sharedInstanceId: string, name: string) {
+ return await invoke(
+ 'plugin:install|install_get_shared_instance_preview',
+ {
+ sharedInstanceId,
+ name,
+ },
+ )
+}
+
+export async function install_accept_shared_instance_invite(inviteId: string) {
+ return await invoke(
+ 'plugin:install|install_accept_shared_instance_invite',
+ {
+ inviteId,
+ },
+ )
+}
+
+export async function install_get_shared_instance_update_preview(instanceId: string) {
+ return await invoke(
+ 'plugin:install|install_get_shared_instance_update_preview',
+ {
+ instanceId,
+ },
+ )
+}
+
+export async function install_shared_instance(
+ sharedInstanceId: string,
+ name: string,
+ managerId?: string | null,
+ serverManagerName?: string | null,
+ serverManagerIconUrl?: string | null,
+ instanceIconUrl?: string | null,
+) {
+ return await invoke('plugin:install|install_shared_instance', {
+ sharedInstanceId,
+ name,
+ managerId,
+ serverManagerName,
+ serverManagerIconUrl,
+ instanceIconUrl,
+ })
+}
+
+export async function install_update_shared_instance(instanceId: string) {
+ return await invoke('plugin:install|install_update_shared_instance', {
+ instanceId,
+ })
+}
+
export async function install_import_instance(
launcherType: string,
basePath: string,
@@ -249,7 +384,8 @@ export function isInstallJobFinished(status: InstallJobStatus) {
function settleInstallJob(job: InstallJobSnapshot) {
if (job.status === 'succeeded') return job
- throw new Error(job.error?.message ?? `Install job ${job.job_id} ${job.status}`)
+ if (job.error) throw job.error
+ throw new Error(`Install job ${job.job_id} ${job.status}`)
}
export async function wait_for_install_job(jobId: string) {
diff --git a/apps/app-frontend/src/helpers/instance.ts b/apps/app-frontend/src/helpers/instance.ts
index 3a7a1b567e..4a02ed932b 100644
--- a/apps/app-frontend/src/helpers/instance.ts
+++ b/apps/app-frontend/src/helpers/instance.ts
@@ -7,13 +7,14 @@ import type { Labrinth } from '@modrinth/api-client'
import type { ContentItem, ContentOwner } from '@modrinth/ui'
import { invoke } from '@tauri-apps/api/core'
-import type { InstallJobSnapshot } from './install'
+import type { InstallJobSnapshot, SharedInstanceUpdateDiff } from './install'
import type {
CacheBehaviour,
ContentFile,
ContentFileProjectType,
GameInstance,
InstanceLoader,
+ SharedInstanceAttachment,
} from './types'
export async function remove(instanceId: string): Promise {
@@ -335,3 +336,82 @@ export async function edit(instanceId: string, editInstance: Partial {
return await invoke('plugin:instance|instance_edit_icon', { instanceId, iconPath })
}
+
+export type SharedInstanceUsers = {
+ user_ids: string[]
+ users: SharedInstanceUser[]
+ tokens: number
+}
+
+export type SharedInstanceJoinType = 'owner' | 'invite' | 'link'
+
+export type SharedInstanceUser = {
+ id: string
+ joined_at?: string | null
+ join_type: SharedInstanceJoinType
+ last_played?: string | null
+}
+
+export interface SharedInstancePublishPreview {
+ sharedInstanceId: string
+ latestVersion: number
+ diffs: SharedInstanceUpdateDiff[]
+}
+
+export interface SharedInstanceInviteLink {
+ inviteId: string
+ expiresAt: string
+ maxUses: number
+}
+
+export async function get_shared_instance_users(instanceId: string): Promise {
+ return await invoke('plugin:instance|instance_share_get_users', { instanceId })
+}
+
+export async function invite_shared_instance_users(
+ instanceId: string,
+ userIds: string[],
+): Promise {
+ return await invoke('plugin:instance|instance_share_invite_users', { instanceId, userIds })
+}
+
+export async function create_shared_instance_invite_link(
+ instanceId: string,
+ options: {
+ maxAgeSeconds?: number
+ maxUses?: number
+ replaceInviteId?: string
+ } = {},
+): Promise {
+ return await invoke('plugin:instance|instance_share_create_invite_link', {
+ instanceId,
+ ...options,
+ })
+}
+
+export async function remove_shared_instance_users(
+ instanceId: string,
+ userIds: string[],
+): Promise {
+ return await invoke('plugin:instance|instance_share_remove_users', { instanceId, userIds })
+}
+
+export async function get_shared_instance_publish_preview(
+ instanceId: string,
+): Promise {
+ return await invoke('plugin:instance|instance_share_get_publish_preview', { instanceId })
+}
+
+export async function publish_shared_instance(
+ instanceId: string,
+): Promise {
+ return await invoke('plugin:instance|instance_share_publish', { instanceId })
+}
+
+export async function unlink_shared_instance(instanceId: string): Promise {
+ return await invoke('plugin:instance|instance_share_unlink', { instanceId })
+}
+
+export async function unpublish_shared_instance(instanceId: string): Promise {
+ return await invoke('plugin:instance|instance_share_unpublish', { instanceId })
+}
diff --git a/apps/app-frontend/src/helpers/mr_auth.ts b/apps/app-frontend/src/helpers/mr_auth.ts
index d28ad63db4..111b1f088c 100644
--- a/apps/app-frontend/src/helpers/mr_auth.ts
+++ b/apps/app-frontend/src/helpers/mr_auth.ts
@@ -12,8 +12,10 @@ export type ModrinthCredentials = {
active: boolean
}
-export async function login(): Promise {
- return await invoke('plugin:mr-auth|modrinth_login')
+export type ModrinthAuthFlow = 'sign-in' | 'sign-up'
+
+export async function login(flow: ModrinthAuthFlow = 'sign-in'): Promise {
+ return await invoke('plugin:mr-auth|modrinth_login', { flow })
}
export async function logout(): Promise {
diff --git a/apps/app-frontend/src/helpers/shared-instance-errors.ts b/apps/app-frontend/src/helpers/shared-instance-errors.ts
new file mode 100644
index 0000000000..ad851a4586
--- /dev/null
+++ b/apps/app-frontend/src/helpers/shared-instance-errors.ts
@@ -0,0 +1,80 @@
+import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
+
+import { getErrorMessage, type SharedInstanceUnavailableReason } from '@/helpers/install'
+
+export const sharedInstanceErrorMessages = defineMessages({
+ unavailableTitle: {
+ id: 'instance.shared-instance.unavailable.title',
+ defaultMessage: 'Shared instance no longer available',
+ },
+ unavailableText: {
+ id: 'instance.shared-instance.unavailable.text-v2',
+ defaultMessage:
+ "Your local instance is still available, but it is no longer linked and won't receive updates.",
+ },
+ deletedText: {
+ id: 'instance.shared-instance.unavailable.deleted-text-v2',
+ defaultMessage:
+ "The shared instance was deleted. Your local instance is still available, but it is no longer linked and won't receive updates.",
+ },
+ accessRevokedText: {
+ id: 'instance.shared-instance.unavailable.access-revoked-text-v2',
+ defaultMessage:
+ "Your access to the shared instance was revoked. Your local instance is still available, but it is no longer linked and won't receive updates.",
+ },
+ unavailableFallbackManager: {
+ id: 'instance.shared-instance.unavailable.manager-fallback',
+ defaultMessage: 'the instance manager',
+ },
+ errorTitle: {
+ id: 'instance.shared-instance.error.title',
+ defaultMessage: 'Something has gone wrong',
+ },
+})
+
+export function sharedInstanceUnavailableTextMessage(
+ reason: SharedInstanceUnavailableReason | null,
+) {
+ if (reason === 'deleted') return sharedInstanceErrorMessages.deletedText
+ if (reason === 'access_revoked') return sharedInstanceErrorMessages.accessRevokedText
+ return sharedInstanceErrorMessages.unavailableText
+}
+
+export function useSharedInstanceErrors() {
+ const { formatMessage } = useVIntl()
+ const { addNotification } = injectNotificationManager()
+
+ function formatSharedInstanceUnavailable(
+ reason: SharedInstanceUnavailableReason | null = null,
+ manager?: string | null,
+ ) {
+ return formatMessage(sharedInstanceUnavailableTextMessage(reason), {
+ manager: manager || formatMessage(sharedInstanceErrorMessages.unavailableFallbackManager),
+ })
+ }
+
+ function notifySharedInstanceUnavailable(
+ reason: SharedInstanceUnavailableReason | null = null,
+ manager?: string | null,
+ ) {
+ addNotification({
+ type: 'warning',
+ title: formatMessage(sharedInstanceErrorMessages.unavailableTitle),
+ text: formatSharedInstanceUnavailable(reason, manager),
+ })
+ }
+
+ function notifySharedInstanceError(error: unknown) {
+ addNotification({
+ type: 'error',
+ title: formatMessage(sharedInstanceErrorMessages.errorTitle),
+ text: getErrorMessage(error),
+ })
+ }
+
+ return {
+ formatSharedInstanceUnavailable,
+ notifySharedInstanceError,
+ notifySharedInstanceUnavailable,
+ }
+}
diff --git a/apps/app-frontend/src/helpers/types.d.ts b/apps/app-frontend/src/helpers/types.d.ts
index 761226eb91..43dacaeefd 100644
--- a/apps/app-frontend/src/helpers/types.d.ts
+++ b/apps/app-frontend/src/helpers/types.d.ts
@@ -17,6 +17,7 @@ export type GameInstance = {
groups: string[]
link?: InstanceLink | null
+ shared_instance?: SharedInstanceAttachment | null
update_channel: ReleaseChannel
created: Date
@@ -86,18 +87,47 @@ export type InstanceLink = InstanceLinkIdentity &
}
| {
type: 'shared_instance'
- shared_instance_id: string
+ modpack_project_id?: ModrinthId | null
+ modpack_version_id?: ModrinthId | null
}
)
+export type SharedInstanceAttachment = {
+ id: string
+ role: 'owner' | 'member'
+ manager_id?: string | null
+ server_manager_name?: string | null
+ server_manager_icon_url?: string | null
+ linked_user_id?: string | null
+ status:
+ | 'unknown'
+ | 'up_to_date'
+ | 'update_available'
+ | 'applying'
+ | 'stale'
+ | 'not_ready'
+ | 'error'
+ applied_version?: number | null
+ latest_version?: number | null
+}
+
export type Instance = GameInstance
type ReleaseChannel = 'release' | 'beta' | 'alpha'
export type InstanceLoader = 'vanilla' | 'forge' | 'fabric' | 'quilt' | 'neoforge'
+export type ContentSourceKind =
+ | 'local'
+ | 'modrinth_modpack'
+ | 'server_project'
+ | 'modrinth_hosting'
+ | 'imported_modpack'
+ | 'shared_instance'
+
type ContentFile = {
enabled: boolean
+ source_kind?: ContentSourceKind | null
metadata?: {
project_id: string
version_id: string
diff --git a/apps/app-frontend/src/helpers/users.ts b/apps/app-frontend/src/helpers/users.ts
new file mode 100644
index 0000000000..042b0d4cbb
--- /dev/null
+++ b/apps/app-frontend/src/helpers/users.ts
@@ -0,0 +1,11 @@
+import { invoke } from '@tauri-apps/api/core'
+
+export type SearchUser = {
+ id: string
+ username: string
+ avatar_url: string | null
+}
+
+export async function search_user(query: string): Promise {
+ return await invoke('plugin:users|search_user', { query })
+}
diff --git a/apps/app-frontend/src/locales/en-US/index.json b/apps/app-frontend/src/locales/en-US/index.json
index 7c481d1542..59b3e3b415 100644
--- a/apps/app-frontend/src/locales/en-US/index.json
+++ b/apps/app-frontend/src/locales/en-US/index.json
@@ -89,6 +89,9 @@
"app.action-bar.install.unknown-instance": {
"message": "Unknown instance"
},
+ "app.action-bar.install.updating-shared-content": {
+ "message": "Updating shared content"
+ },
"app.action-bar.installs": {
"message": "Installs"
},
@@ -323,6 +326,36 @@
"app.install.phase.running_loader_processors": {
"message": "Running loader processors"
},
+ "app.instance.admonitions.shared-instance.added-label": {
+ "message": "Added"
+ },
+ "app.instance.admonitions.shared-instance.changes-body": {
+ "message": "Your local instance is ahead of the users you've shared it with."
+ },
+ "app.instance.admonitions.shared-instance.changes-header": {
+ "message": "Your changes haven't been shared yet"
+ },
+ "app.instance.admonitions.shared-instance.publish-button": {
+ "message": "Push update"
+ },
+ "app.instance.admonitions.shared-instance.publishing-button": {
+ "message": "Pushing..."
+ },
+ "app.instance.admonitions.shared-instance.removed-label": {
+ "message": "Removed"
+ },
+ "app.instance.admonitions.shared-instance.review-admonition-header": {
+ "message": "Push update"
+ },
+ "app.instance.admonitions.shared-instance.review-description": {
+ "message": "Review the content changes that will be shared with everyone using this instance."
+ },
+ "app.instance.admonitions.shared-instance.review-header": {
+ "message": "Review changes"
+ },
+ "app.instance.admonitions.shared-instance.reviewing-button": {
+ "message": "Reviewing..."
+ },
"app.instance.confirm-delete.admonition-body": {
"message": "All data for your instance will be permanently deleted, including your worlds, configs, and all installed content."
},
@@ -374,6 +407,90 @@
"app.instance.mods.successfully-uploaded": {
"message": "Successfully uploaded"
},
+ "app.instance.share.empty.description": {
+ "message": "You can share this instance with your friends!"
+ },
+ "app.instance.share.empty.heading": {
+ "message": "No friends invited"
+ },
+ "app.instance.share.empty.invite-friends-button": {
+ "message": "Invite friends"
+ },
+ "app.instance.share.invite-modal.heading": {
+ "message": "Share {name}"
+ },
+ "app.instance.share.locked.empty-description-prefix": {
+ "message": "You need to sign in as"
+ },
+ "app.instance.share.locked.empty-description-suffix": {
+ "message": "to access this page."
+ },
+ "app.instance.share.locked.linked-account-fallback": {
+ "message": "the linked account"
+ },
+ "app.instance.share.locked.signed-out-heading": {
+ "message": "Not signed in"
+ },
+ "app.instance.share.locked.switch-account-button": {
+ "message": "Switch account"
+ },
+ "app.instance.share.locked.wrong-account-heading": {
+ "message": "Wrong account"
+ },
+ "app.instance.share.remove-user-modal.effect-access": {
+ "message": "They will no longer receive updates for this shared instance"
+ },
+ "app.instance.share.remove-user-modal.effect-installed-copy": {
+ "message": "Any copy they already installed will stay on their device"
+ },
+ "app.instance.share.remove-user-modal.effect-invite-again": {
+ "message": "You can invite them again later"
+ },
+ "app.instance.share.remove-user-modal.effect-last-user": {
+ "message": "If this is the last user, sharing will be turned off for this instance"
+ },
+ "app.instance.share.remove-user-modal.effects-label": {
+ "message": "What happens?"
+ },
+ "app.instance.share.remove-user-modal.header": {
+ "message": "Revoke access"
+ },
+ "app.instance.share.remove-user-modal.remove-button": {
+ "message": "Revoke access"
+ },
+ "app.instance.share.remove-user-modal.user-avatar-alt": {
+ "message": "{username}'s avatar"
+ },
+ "app.instance.share.remove-user-modal.warning-body": {
+ "message": "If you revoke {username}'s access to this shared instance, you'll need to invite them again before they can receive updates."
+ },
+ "app.instance.share.sign-in.button": {
+ "message": "Sign in"
+ },
+ "app.instance.share.unlink.body": {
+ "message": "You must unlink this modpack to share your instance"
+ },
+ "app.instance.share.unlink.header": {
+ "message": "Sharing requires unlinking"
+ },
+ "app.instance.shared-instance-wrong-account.fallback-username": {
+ "message": "the linked account"
+ },
+ "app.instance.shared-instance-wrong-account.owner-admonition-body-v2": {
+ "message": "to manage this shared instance. You won't be able to push updates to users."
+ },
+ "app.instance.shared-instance-wrong-account.sign-in-as-label": {
+ "message": "Sign in as"
+ },
+ "app.instance.shared-instance-wrong-account.signed-out-header": {
+ "message": "You need to sign in to Modrinth"
+ },
+ "app.instance.shared-instance-wrong-account.user-admonition-body-v2": {
+ "message": "to receive updates for this shared instance."
+ },
+ "app.instance.shared-instance-wrong-account.warning-header": {
+ "message": "You are using the wrong Modrinth account"
+ },
"app.instance.worlds.add-server": {
"message": "Add server"
},
@@ -425,8 +542,20 @@
"app.instance.worlds.search-worlds-placeholder": {
"message": "Search {count} worlds..."
},
- "app.modal.install-to-play.dont-install": {
- "message": "Dont install"
+ "app.modal.install-to-play.additional-context": {
+ "message": "Additional context"
+ },
+ "app.modal.install-to-play.additional-context-placeholder": {
+ "message": "Include links and images if possible and relevant"
+ },
+ "app.modal.install-to-play.block-user": {
+ "message": "Block this user"
+ },
+ "app.modal.install-to-play.content-you-are-reporting": {
+ "message": "Content you’re reporting"
+ },
+ "app.modal.install-to-play.external-files-dont-install": {
+ "message": "Don't install"
},
"app.modal.install-to-play.header": {
"message": "Install to play"
@@ -443,12 +572,45 @@
"app.modal.install-to-play.mod-count": {
"message": "{count, plural, one {# mod} other {# mods}}"
},
+ "app.modal.install-to-play.report-description": {
+ "message": "Use this form to report content that may violate our Rules or Terms of Use."
+ },
+ "app.modal.install-to-play.report-legal-claims": {
+ "message": "For DMCA notices or other legal claims, see our Copyright Policy."
+ },
+ "app.modal.install-to-play.report-reason": {
+ "message": "Which rule does this content violate?"
+ },
+ "app.modal.install-to-play.report-reason.illegal": {
+ "message": "Illegal"
+ },
+ "app.modal.install-to-play.report-reason.malicious": {
+ "message": "Malicious"
+ },
+ "app.modal.install-to-play.report-reason.other": {
+ "message": "Other"
+ },
+ "app.modal.install-to-play.report-support-and-bugs": {
+ "message": "For support requests, contact our support team. For bug reports, open a GitHub issue."
+ },
"app.modal.install-to-play.reviewed-files": {
"message": "A file is only reviewed if it’s published to Modrinth, regardless of its file format (including .mrpack)."
},
"app.modal.install-to-play.shared-instance": {
"message": "Shared instance"
},
+ "app.modal.install-to-play.shared-instance-content": {
+ "message": "Shared instance content"
+ },
+ "app.modal.install-to-play.shared-instance-unknown-files-description": {
+ "message": "This shared instance contains files that aren’t published on Modrinth. We strongly recommend only installing files from sources you trust."
+ },
+ "app.modal.install-to-play.trust-warning-description": {
+ "message": "A shared instance will install files on your computer and may include content not from Modrinth."
+ },
+ "app.modal.install-to-play.trust-warning-header": {
+ "message": "Do you trust this user?"
+ },
"app.modal.install-to-play.unknown-files-description": {
"message": "This server modpack contains files that aren’t published on Modrinth. We strongly recommend only installing files from sources you trust."
},
@@ -467,8 +629,23 @@
"app.modal.update-to-play.removed": {
"message": "Removed"
},
+ "app.modal.update-to-play.server-modpack-unknown-files-description": {
+ "message": "This server modpack update contains files that aren’t published on Modrinth. We strongly recommend only installing files from sources you trust."
+ },
+ "app.modal.update-to-play.shared-instance-added-label": {
+ "message": "Added"
+ },
+ "app.modal.update-to-play.shared-instance-removed-label": {
+ "message": "Removed"
+ },
+ "app.modal.update-to-play.shared-instance-unknown-files-description": {
+ "message": "This shared instance update contains files that aren’t published on Modrinth. We strongly recommend only installing files from sources you trust."
+ },
+ "app.modal.update-to-play.update-required": {
+ "message": "Update required"
+ },
"app.modal.update-to-play.update-required-description": {
- "message": "An update is required to play {name}. Please update to the latest version to launch the game."
+ "message": "An update is required to play {name}. Please update to latest version to launch the game."
},
"app.project.install-button.already-installed": {
"message": "This project is already installed"
@@ -716,6 +893,39 @@
"app.world.world-item.players-online": {
"message": "{count} online"
},
+ "content.shared-instance.change-version-body": {
+ "message": "Changing the version only changes your local copy. Future shared instance updates may restore or change it again."
+ },
+ "content.shared-instance.delete-bulk-body": {
+ "message": "Some selected projects are part of the shared instance. Deleting them only changes your local copy, and future shared instance updates may restore or change them again."
+ },
+ "content.shared-instance.delete-button": {
+ "message": "Delete anyway"
+ },
+ "content.shared-instance.delete-many-button": {
+ "message": "Delete {count, number} projects anyway"
+ },
+ "content.shared-instance.delete-single-body": {
+ "message": "Deleting it only changes your local copy. Future shared instance updates may restore or change it again."
+ },
+ "content.shared-instance.disable-bulk-body": {
+ "message": "Some selected projects are part of the shared instance. Disabling them only changes your local copy, and future shared instance updates may re-enable, restore, or change them again."
+ },
+ "content.shared-instance.disable-button": {
+ "message": "Disable anyway"
+ },
+ "content.shared-instance.disable-many-button": {
+ "message": "Disable {count, number} projects anyway"
+ },
+ "content.shared-instance.disable-single-body": {
+ "message": "Disabling it only changes your local copy. Future shared instance updates may re-enable, restore, or change it again."
+ },
+ "content.shared-instance.unlink-body": {
+ "message": "Unlinking only changes your local copy. Future shared instance updates may restore or change it again."
+ },
+ "content.shared-instance.warning-header": {
+ "message": "This is part of the shared instance"
+ },
"friends.action.add-friend": {
"message": "Add a friend"
},
@@ -779,6 +989,48 @@
"friends.sign-in-to-add-friends": {
"message": "Sign in to a Modrinth account to add friends and see what they're playing!"
},
+ "installation-settings.shared-instance.linked-title": {
+ "message": "Linked shared instance"
+ },
+ "installation-settings.shared-instance.title": {
+ "message": "Shared instance"
+ },
+ "installation-settings.shared-instance.unlink-button": {
+ "message": "Unlink shared instance"
+ },
+ "installation-settings.shared-instance.unlink-description": {
+ "message": "Disconnect this local instance from future shared updates."
+ },
+ "installation-settings.shared-instance.unlinking-button": {
+ "message": "Unlinking..."
+ },
+ "installation-settings.shared-instance.unpublish-button": {
+ "message": "Unpublish shared instance"
+ },
+ "installation-settings.shared-instance.unpublish-description": {
+ "message": "Stop sharing this instance and remove it from Modrinth."
+ },
+ "installation-settings.shared-instance.unpublishing-button": {
+ "message": "Unpublishing..."
+ },
+ "installation-settings.unlink-shared-instance.modal.admonition-body": {
+ "message": "This only affects your local instance. Your installed content will stay on this device, and the shared instance and other people using it will not be affected."
+ },
+ "installation-settings.unlink-shared-instance.modal.admonition-header": {
+ "message": "Unlinking shared instance"
+ },
+ "installation-settings.unlink-shared-instance.modal.header": {
+ "message": "Unlink shared instance"
+ },
+ "installation-settings.unpublish-shared-instance.modal.admonition-body": {
+ "message": "This deletes the shared instance from Modrinth's servers. People using it in the Modrinth App will stop receiving updates, but your local instance and its content will stay on this device."
+ },
+ "installation-settings.unpublish-shared-instance.modal.admonition-header": {
+ "message": "Unpublishing shared instance"
+ },
+ "installation-settings.unpublish-shared-instance.modal.header": {
+ "message": "Unpublish shared instance"
+ },
"instance.add-server.add-and-play": {
"message": "Add and play"
},
@@ -1025,6 +1277,30 @@
"instance.settings.tabs.window.width.enter": {
"message": "Enter width..."
},
+ "instance.shared-instance.error.title": {
+ "message": "Something has gone wrong"
+ },
+ "instance.shared-instance.owner-tooltip": {
+ "message": "This instance's content is being shared to other users."
+ },
+ "instance.shared-instance.tooltip": {
+ "message": "This instance's content is being managed by someone else."
+ },
+ "instance.shared-instance.unavailable.access-revoked-text-v2": {
+ "message": "Your access to the shared instance was revoked. Your local instance is still available, but it is no longer linked and won't receive updates."
+ },
+ "instance.shared-instance.unavailable.deleted-text-v2": {
+ "message": "The shared instance was deleted. Your local instance is still available, but it is no longer linked and won't receive updates."
+ },
+ "instance.shared-instance.unavailable.manager-fallback": {
+ "message": "the instance manager"
+ },
+ "instance.shared-instance.unavailable.text-v2": {
+ "message": "Your local instance is still available, but it is no longer linked and won't receive updates."
+ },
+ "instance.shared-instance.unavailable.title": {
+ "message": "Shared instance no longer available"
+ },
"instance.worlds.a_minecraft_server": {
"message": "A Minecraft Server"
},
@@ -1088,6 +1364,24 @@
"minecraft-account.sign-in": {
"message": "Sign in to Minecraft"
},
+ "modal.modrinth-account-required.create-account-button": {
+ "message": "Create an account"
+ },
+ "modal.modrinth-account-required.description": {
+ "message": "You'll need to sign into your Modrinth account before you can use this feature."
+ },
+ "modal.modrinth-account-required.header": {
+ "message": "Account required"
+ },
+ "modal.modrinth-account-required.sign-in-button": {
+ "message": "Sign in to Modrinth"
+ },
+ "modal.modrinth-account-required.sign-in-heading": {
+ "message": "Sign in to a Modrinth account"
+ },
+ "modal.modrinth-account-required.support-prompt": {
+ "message": "Having trouble signing in? Get support"
+ },
"search.filter.locked.instance": {
"message": "Provided by the instance"
},
diff --git a/apps/app-frontend/src/pages/instance/Index.vue b/apps/app-frontend/src/pages/instance/Index.vue
index 6828483c59..ecab84e652 100644
--- a/apps/app-frontend/src/pages/instance/Index.vue
+++ b/apps/app-frontend/src/pages/instance/Index.vue
@@ -13,6 +13,10 @@
@unlinked="fetchInstance"
/>
+
{{ instance.name }}
+
+
+ Shared
+
+
+
@@ -89,6 +105,31 @@
+
+
+
+
+
+
+ {{ sharedInstanceManager.type === 'server' ? 'Linked to' : 'Managed by' }}
+
+
+
+ {{ sharedInstanceManager.name }}
+
+
+