diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue
index 1f39661978..10d7d95f50 100644
--- a/apps/app-frontend/src/App.vue
+++ b/apps/app-frontend/src/App.vue
@@ -16,6 +16,7 @@ import {
HomeIcon,
LeftArrowIcon,
LibraryIcon,
+ LinkIcon,
LogInIcon,
LogOutIcon,
NewspaperIcon,
@@ -85,6 +86,7 @@ import NavButton from '@/components/ui/NavButton.vue'
import PrideFundraiserBanner from '@/components/ui/PrideFundraiserBanner.vue'
import PromotionWrapper from '@/components/ui/PromotionWrapper.vue'
import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue'
+import SharedInstanceInviteHandler from '@/components/ui/shared-instances/shared-instance-invite-handler/index.vue'
import SplashScreen from '@/components/ui/SplashScreen.vue'
import WindowControls from '@/components/ui/WindowControls.vue'
import { useCheckDisableMouseover } from '@/composables/macCssFix.js'
@@ -147,6 +149,7 @@ const APP_SIDEBAR_WIDTH = 300
const INTERCOM_BUBBLE_DEFAULT_PADDING = 20
const PRIDE_FUNDRAISER_END_DATE = new Date('2026-07-01T00:00:00Z').getTime()
const credentials = ref()
+let credentialsRefreshId = 0
const sidebarToggled = ref(true)
const unsubscribeSidebarToggle = themeStore.$subscribe(() => {
sidebarToggled.value = !themeStore.toggleSidebar
@@ -637,6 +640,7 @@ const contentInstallModpackAlreadyInstalledModal = ref()
const addServerToInstanceModal = ref()
const incompatibilityWarningModal = ref()
const installToPlayModal = ref()
+const sharedInstanceInviteHandler = ref()
const updateToPlayModal = ref()
const modrinthLoginFlowWaitModal = ref()
@@ -647,8 +651,8 @@ watch(incompatibilityWarningModal, (modal) => {
}
})
-setupAuthProvider(credentials, async (_redirectPath) => {
- await signIn()
+setupAuthProvider(credentials, async (_redirectPath, flow) => {
+ await signIn(flow)
})
async function validateSession(sessionToken) {
@@ -665,23 +669,33 @@ async function validateSession(sessionToken) {
}
async function fetchCredentials() {
+ const refreshId = ++credentialsRefreshId
+ credentials.value = undefined
+
const creds = await getCreds().catch(handleError)
+ if (refreshId !== credentialsRefreshId) return
+
if (creds && creds.user_id) {
if (creds.session && !(await validateSession(creds.session))) {
+ if (refreshId !== credentialsRefreshId) return
+
await logout().catch(handleError)
+ if (refreshId !== credentialsRefreshId) return
+
credentials.value = null
return
}
creds.user = await get_user(creds.user_id, 'bypass').catch(handleError)
+ if (refreshId !== credentialsRefreshId) return
}
credentials.value = creds ?? null
}
-async function signIn() {
+async function signIn(flow = 'sign-in') {
modrinthLoginFlowWaitModal.value.show()
try {
- await login()
+ await login(flow)
await fetchCredentials()
} catch (error) {
if (
@@ -699,6 +713,13 @@ async function signIn() {
}
async function logOut() {
+ await performLogOut()
+}
+
+async function performLogOut() {
+ credentialsRefreshId++
+ credentials.value = undefined
+
await logout().catch(handleError)
await fetchCredentials()
}
@@ -821,30 +842,34 @@ function openServerInviteInviterProfile(inviterName) {
}
async function handleLiveNotification(notification) {
- if (notification?.body?.type !== 'server_invite' || notification.read) return
- if (displayedServerInviteNotifications.has(notification.id)) return
-
- displayedServerInviteNotifications.add(notification.id)
-
- const serverName =
- typeof notification.body.server_name === 'string' ? notification.body.server_name : 'a server'
- const inviterId = notification.body.invited_by
- const invitedBy =
- typeof inviterId === 'string' ? await get_user(inviterId, 'bypass').catch(() => null) : null
-
- addPopupNotification({
- title: serverName,
- autoCloseMs: null,
- toast: {
- type: 'server-invite',
- actorName: invitedBy?.username ?? null,
- actorAvatarUrl: invitedBy?.avatar_url ?? null,
- entityName: serverName,
- onAccept: () => acceptServerInviteNotification(notification),
- onDecline: () => declineServerInviteNotification(notification),
- onOpenActor: () => openServerInviteInviterProfile(invitedBy?.username ?? null),
- },
- })
+ if (!notification?.body || notification.read) return
+ if (await sharedInstanceInviteHandler.value?.handleNotification(notification)) return
+
+ if (notification.body.type === 'server_invite') {
+ if (displayedServerInviteNotifications.has(notification.id)) return
+
+ displayedServerInviteNotifications.add(notification.id)
+
+ const serverName =
+ typeof notification.body.server_name === 'string' ? notification.body.server_name : 'a server'
+ const inviterId = notification.body.invited_by
+ const invitedBy =
+ typeof inviterId === 'string' ? await get_user(inviterId, 'bypass').catch(() => null) : null
+
+ addPopupNotification({
+ title: serverName,
+ autoCloseMs: null,
+ toast: {
+ type: 'server-invite',
+ actorName: invitedBy?.username ?? null,
+ actorAvatarUrl: invitedBy?.avatar_url ?? null,
+ entityName: serverName,
+ onAccept: () => acceptServerInviteNotification(notification),
+ onDecline: () => declineServerInviteNotification(notification),
+ onOpenActor: () => openServerInviteInviterProfile(invitedBy?.username ?? null),
+ },
+ })
+ }
}
async function handleCommand(e) {
@@ -877,6 +902,8 @@ async function handleCommand(e) {
} else {
await run(e.id).catch(handleError)
}
+ } else if (e.event === 'InstallSharedInstanceInvite') {
+ await sharedInstanceInviteHandler.value?.installFromInviteId(e.invite_id)
} else if (e.event === 'InstallServer') {
await router.push(`/project/${e.id}`)
await playServerProject(e.id).catch(handleError)
@@ -1467,6 +1494,12 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
>
+
+
+
+
diff --git a/apps/app-frontend/src/components/ui/Instance.vue b/apps/app-frontend/src/components/ui/Instance.vue
index 9fff27fa31..97dbca3039 100644
--- a/apps/app-frontend/src/components/ui/Instance.vue
+++ b/apps/app-frontend/src/components/ui/Instance.vue
@@ -142,7 +142,9 @@ const unlisten = await process_listener((e) => {
}
})
-onMounted(() => checkProcess())
+onMounted(() => {
+ checkProcess()
+})
onUnmounted(() => unlisten())
diff --git a/apps/app-frontend/src/components/ui/friends/FriendsList.vue b/apps/app-frontend/src/components/ui/friends/FriendsList.vue
index c622c078e4..71c8c1c8fe 100644
--- a/apps/app-frontend/src/components/ui/friends/FriendsList.vue
+++ b/apps/app-frontend/src/components/ui/friends/FriendsList.vue
@@ -10,18 +10,12 @@ import {
useRelativeTime,
useVIntl,
} from '@modrinth/ui'
-import { computed, onUnmounted, ref, watch } from 'vue'
+import { computed, ref } from 'vue'
import FriendsSection from '@/components/ui/friends/FriendsSection.vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
-import { friend_listener } from '@/helpers/events'
-import {
- add_friend,
- friends,
- type FriendWithUserData,
- remove_friend,
- transformFriends,
-} from '@/helpers/friends.ts'
+import { useFriends } from '@/composables/use-friends'
+import type { FriendWithUserData } from '@/helpers/friends.ts'
import type { ModrinthCredentials } from '@/helpers/mr_auth'
const { formatMessage } = useVIntl()
@@ -35,36 +29,23 @@ const props = defineProps<{
}>()
const userCredentials = computed(() => props.credentials)
+const {
+ friends: userFriends,
+ loading,
+ requestFriend,
+ acceptFriend,
+ removeFriend: removeFriendRecord,
+} = useFriends({
+ currentUserId: () => userCredentials.value?.user_id,
+ getCredentials: () => userCredentials.value,
+ onError: handleError,
+})
const search = ref('')
const friendInvitesModal = ref()
-
const username = ref('')
const addFriendModal = ref()
-async function addFriendFromModal() {
- addFriendModal.value.hide()
- await add_friend(username.value).catch(handleError)
- username.value = ''
- await loadFriends()
-}
-
-async function addFriend(friend: FriendWithUserData) {
- const id = friend.id === userCredentials.value?.user_id ? friend.friend_id : friend.id
- if (id) {
- await add_friend(id).catch(handleError)
- await loadFriends()
- }
-}
-
-async function removeFriend(friend: FriendWithUserData) {
- const id = friend.id === userCredentials.value?.user_id ? friend.friend_id : friend.id
- if (id) {
- await remove_friend(id).catch(handleError)
- await loadFriends()
- }
-}
-const userFriends = ref([])
const sortedFriends = computed(() =>
userFriends.value.slice().sort((a, b) => {
if (a.last_updated === null && b.last_updated === null) {
@@ -108,42 +89,22 @@ const incomingRequests = computed(() =>
.sort((a, b) => b.created.diff(a.created)),
)
-const loading = ref(true)
-async function loadFriends(timeout = false) {
- loading.value = timeout
+function addFriendFromModal() {
+ const target = username.value.trim()
+ if (!target) return
- try {
- const friendsList = await friends()
- userFriends.value = await transformFriends(friendsList, userCredentials.value)
- loading.value = false
- } catch (e) {
- console.error('Error loading friends', e)
- if (timeout) {
- setTimeout(() => loadFriends(), 15 * 1000)
- }
- }
+ addFriendModal.value.hide()
+ requestFriend({ id: target, username: target })
+ username.value = ''
}
-watch(
- userCredentials,
- () => {
- if (userCredentials.value === undefined) {
- userFriends.value = []
- loading.value = false
- } else if (userCredentials.value === null) {
- userFriends.value = []
- loading.value = false
- } else {
- loadFriends(true)
- }
- },
- { immediate: true },
-)
+function addFriend(friend: FriendWithUserData) {
+ acceptFriend(friend)
+}
-const unlisten = await friend_listener(() => loadFriends())
-onUnmounted(() => {
- unlisten()
-})
+function removeFriend(friend: FriendWithUserData) {
+ removeFriendRecord(friend)
+}
const messages = defineMessages({
addFriend: {
diff --git a/apps/app-frontend/src/components/ui/instance/instance-admonitions/index.vue b/apps/app-frontend/src/components/ui/instance/instance-admonitions/index.vue
new file mode 100644
index 0000000000..de3d43dd25
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/instance/instance-admonitions/index.vue
@@ -0,0 +1,105 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/app-frontend/src/components/ui/instance/instance-admonitions/instance-admonitions-messages.ts b/apps/app-frontend/src/components/ui/instance/instance-admonitions/instance-admonitions-messages.ts
new file mode 100644
index 0000000000..5191ae9b26
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/instance/instance-admonitions/instance-admonitions-messages.ts
@@ -0,0 +1,69 @@
+import { defineMessages } from '@modrinth/ui'
+
+export const instanceAdmonitionsMessages = defineMessages({
+ sharedInstanceChangesHeader: {
+ id: 'app.instance.admonitions.shared-instance.changes-header',
+ defaultMessage: "Your changes haven't been shared yet",
+ },
+ sharedInstanceChangesBody: {
+ id: 'app.instance.admonitions.shared-instance.changes-body',
+ defaultMessage: "Your local instance is ahead of the users you've shared it with.",
+ },
+ sharedInstancePublishButton: {
+ id: 'app.instance.admonitions.shared-instance.publish-button',
+ defaultMessage: 'Push update',
+ },
+ sharedInstancePublishingButton: {
+ id: 'app.instance.admonitions.shared-instance.publishing-button',
+ defaultMessage: 'Pushing...',
+ },
+ sharedInstanceReviewingButton: {
+ id: 'app.instance.admonitions.shared-instance.reviewing-button',
+ defaultMessage: 'Reviewing...',
+ },
+ sharedInstanceReviewHeader: {
+ id: 'app.instance.admonitions.shared-instance.review-header',
+ defaultMessage: 'Review changes',
+ },
+ sharedInstanceReviewAdmonitionHeader: {
+ id: 'app.instance.admonitions.shared-instance.review-admonition-header',
+ defaultMessage: 'Push update',
+ },
+ sharedInstanceReviewDescription: {
+ id: 'app.instance.admonitions.shared-instance.review-description',
+ defaultMessage:
+ 'Review the content changes that will be shared with everyone using this instance.',
+ },
+ sharedInstanceAddedLabel: {
+ id: 'app.instance.admonitions.shared-instance.added-label',
+ defaultMessage: 'Added',
+ },
+ sharedInstanceRemovedLabel: {
+ id: 'app.instance.admonitions.shared-instance.removed-label',
+ defaultMessage: 'Removed',
+ },
+ sharedInstanceWrongAccountHeader: {
+ id: 'app.instance.shared-instance-wrong-account.warning-header',
+ defaultMessage: 'You are using the wrong Modrinth account',
+ },
+ sharedInstanceSignedOutHeader: {
+ id: 'app.instance.shared-instance-wrong-account.signed-out-header',
+ defaultMessage: 'You need to sign in to Modrinth',
+ },
+ sharedInstanceWrongAccountSignInAs: {
+ id: 'app.instance.shared-instance-wrong-account.sign-in-as-label',
+ defaultMessage: 'Sign in as',
+ },
+ sharedInstanceWrongAccountUserBody: {
+ id: 'app.instance.shared-instance-wrong-account.user-admonition-body-v2',
+ defaultMessage: 'to receive updates for this shared instance.',
+ },
+ sharedInstanceWrongAccountOwnerBody: {
+ id: 'app.instance.shared-instance-wrong-account.owner-admonition-body-v2',
+ defaultMessage: "to manage this shared instance. You won't be able to push updates to users.",
+ },
+ sharedInstanceWrongAccountFallbackUsername: {
+ id: 'app.instance.shared-instance-wrong-account.fallback-username',
+ defaultMessage: 'the linked account',
+ },
+})
diff --git a/apps/app-frontend/src/components/ui/instance/instance-admonitions/instance-admonitions-shared-instance-stale.vue b/apps/app-frontend/src/components/ui/instance/instance-admonitions/instance-admonitions-shared-instance-stale.vue
new file mode 100644
index 0000000000..8618bd9631
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/instance/instance-admonitions/instance-admonitions-shared-instance-stale.vue
@@ -0,0 +1,133 @@
+
+
+ {{ formatMessage(messages.sharedInstanceChangesBody) }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/app-frontend/src/components/ui/instance/instance-admonitions/instance-admonitions-shared-instance-unavailable.vue b/apps/app-frontend/src/components/ui/instance/instance-admonitions/instance-admonitions-shared-instance-unavailable.vue
new file mode 100644
index 0000000000..c2b5cf6335
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/instance/instance-admonitions/instance-admonitions-shared-instance-unavailable.vue
@@ -0,0 +1,33 @@
+
+
+ {{ formatSharedInstanceUnavailable(reason ?? null, manager) }}
+
+
+
+
diff --git a/apps/app-frontend/src/components/ui/instance/instance-admonitions/instance-admonitions-shared-instance-wrong-account.vue b/apps/app-frontend/src/components/ui/instance/instance-admonitions/instance-admonitions-shared-instance-wrong-account.vue
new file mode 100644
index 0000000000..aab1f2fa8f
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/instance/instance-admonitions/instance-admonitions-shared-instance-wrong-account.vue
@@ -0,0 +1,78 @@
+
+
+
+ {{ formatMessage(messages.sharedInstanceWrongAccountSignInAs) }}
+
+
+ {{ sharedInstanceExpectedUsername }}
+
+ {{ sharedInstanceExpectedUsername }}
+ {{ formatMessage(bodyMessage) }}
+
+
+
+
+
diff --git a/apps/app-frontend/src/components/ui/instance/instance-admonitions/types.ts b/apps/app-frontend/src/components/ui/instance/instance-admonitions/types.ts
new file mode 100644
index 0000000000..ea7eb81be1
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/instance/instance-admonitions/types.ts
@@ -0,0 +1,12 @@
+import type { StackedAdmonitionItem } from '@modrinth/ui'
+
+export type InstanceAdmonitionKind =
+ | 'shared-instance-stale'
+ | 'shared-instance-unavailable'
+ | 'shared-instance-wrong-account'
+
+export type InstanceAdmonitionItem = StackedAdmonitionItem & {
+ kind: InstanceAdmonitionKind
+}
+
+export type SharedInstanceRole = 'owner' | 'member'
diff --git a/apps/app-frontend/src/components/ui/instance_settings/InstallationSettings.vue b/apps/app-frontend/src/components/ui/instance_settings/InstallationSettings.vue
index fa1a4d0312..aeab8aa4cd 100644
--- a/apps/app-frontend/src/components/ui/instance_settings/InstallationSettings.vue
+++ b/apps/app-frontend/src/components/ui/instance_settings/InstallationSettings.vue
@@ -7,7 +7,6 @@ import {
injectFilePicker,
injectNotificationManager,
InstallationSettingsLayout,
- provideAppBackup,
provideInstallationSettings,
useDebugLogger,
useVIntl,
@@ -16,24 +15,26 @@ import type { GameVersionTag, PlatformTag } from '@modrinth/utils'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, ref } from 'vue'
+import SharedInstanceInstallationSettingsControls from '@/components/ui/shared-instances/SharedInstanceInstallationSettingsControls.vue'
+import { useManagedContentPolicy } from '@/composables/instances/use-managed-content-policy'
import { trackEvent } from '@/helpers/analytics'
import { get_project_versions, get_version } from '@/helpers/cache'
import {
- install_duplicate_instance,
install_existing_instance,
install_pack_to_existing_instance,
- installJobInstanceId,
wait_for_install_job,
} from '@/helpers/install'
import {
edit,
get_linked_modpack_info,
- list,
+ unlink_shared_instance,
+ unpublish_shared_instance,
update_managed_modrinth_version,
update_repair_modrinth,
} from '@/helpers/instance'
import { get_loader_versions } from '@/helpers/metadata'
import { get_game_versions, get_loaders } from '@/helpers/tags'
+import { provideInstanceBackup } from '@/providers/instance-backup'
import { injectInstanceSettings } from '@/providers/instance-settings'
import { useTheming } from '@/store/state'
@@ -47,6 +48,7 @@ const debug = useDebugLogger('AppInstallationSettings')
const themeStore = useTheming()
const { instance, offline, isMinecraftServer, onUnlinked, closeModal } = injectInstanceSettings()
+const managedContentPolicy = useManagedContentPolicy(instance)
const skipNonEssentialWarnings = computed(() =>
themeStore.getFeatureFlag('skip_non_essential_warnings'),
)
@@ -111,9 +113,15 @@ debug('metadata queries configured', {
const isModrinthLinkedModpack = computed(
() =>
instance.value.link?.type === 'modrinth_modpack' ||
- instance.value.link?.type === 'server_project_modpack',
+ instance.value.link?.type === 'server_project_modpack' ||
+ (instance.value.link?.type === 'shared_instance' &&
+ !!instance.value.link.modpack_project_id &&
+ !!instance.value.link.modpack_version_id),
)
const isImportedModpack = computed(() => instance.value.link?.type === 'imported_modpack')
+const isSharedInstanceManagedModpack = managedContentPolicy.isManagedModpack
+const canUnpublishSharedInstance = managedContentPolicy.canUnpublish
+const canUnlinkSharedInstance = managedContentPolicy.canUnlink
const modpackInfoQuery = useQuery({
queryKey: computed(() => ['linkedModpackInfo', instance.value.id]),
@@ -124,6 +132,36 @@ const modpackInfo = modpackInfoQuery.data
const repairing = ref(false)
const reinstalling = ref(false)
+const unpublishingSharedInstance = ref(false)
+const unlinkingSharedInstance = ref(false)
+
+async function unpublishSharedInstance() {
+ unpublishingSharedInstance.value = true
+ try {
+ await unpublish_shared_instance(instance.value.id)
+ await queryClient.invalidateQueries({ queryKey: ['sharedInstanceUsers', instance.value.id] })
+ await queryClient.invalidateQueries({ queryKey: ['linkedModpackInfo', instance.value.id] })
+ onUnlinked()
+ } catch (error) {
+ handleError(error)
+ } finally {
+ unpublishingSharedInstance.value = false
+ }
+}
+
+async function unlinkSharedInstance() {
+ unlinkingSharedInstance.value = true
+ try {
+ await unlink_shared_instance(instance.value.id)
+ await queryClient.invalidateQueries({ queryKey: ['sharedInstanceUsers', instance.value.id] })
+ await queryClient.invalidateQueries({ queryKey: ['linkedModpackInfo', instance.value.id] })
+ onUnlinked()
+ } catch (error) {
+ handleError(error)
+ } finally {
+ unlinkingSharedInstance.value = false
+ }
+}
const messages = defineMessages({
loaderVersion: {
@@ -162,27 +200,7 @@ async function installLocalModpackFromPicker() {
return !!completed
}
-provideAppBackup({
- async createBackup() {
- debug('createBackup: start', {
- instanceId: instance.value.id,
- instanceName: instance.value.name,
- })
- const allInstances = await list()
- const prefix = `${instance.value.name} - Backup #`
- const existingNums = allInstances
- .filter((p) => p.name.startsWith(prefix))
- .map((p) => parseInt(p.name.slice(prefix.length), 10))
- .filter((n) => !isNaN(n))
- const nextNum = existingNums.length > 0 ? Math.max(...existingNums) + 1 : 1
- const job = await install_duplicate_instance(instance.value.id)
- const newInstanceId = installJobInstanceId(job)
- if (newInstanceId) {
- await edit(newInstanceId, { name: `${prefix}${nextNum}` })
- }
- debug('createBackup: done', { newInstanceId, backupName: `${prefix}${nextNum}` })
- },
-})
+provideInstanceBackup(instance)
provideInstallationSettings({
closeSettings: closeModal,
@@ -208,12 +226,19 @@ provideInstallationSettings({
}
return rows
}),
- isLinked: computed(() => isModrinthLinkedModpack.value || isImportedModpack.value),
+ isLinked: computed(
+ () =>
+ isModrinthLinkedModpack.value ||
+ isImportedModpack.value ||
+ isSharedInstanceManagedModpack.value,
+ ),
isBusy: computed(
() =>
instance.value.install_stage !== 'installed' ||
repairing.value ||
reinstalling.value ||
+ unlinkingSharedInstance.value ||
+ unpublishingSharedInstance.value ||
!!offline,
),
skipNonEssentialWarnings,
@@ -452,14 +477,38 @@ provideInstallationSettings({
isServer: false,
isApp: true,
showModpackVersionActions: computed(
- () => isModrinthLinkedModpack.value && !isMinecraftServer.value,
+ () =>
+ isModrinthLinkedModpack.value &&
+ !isMinecraftServer.value &&
+ !isSharedInstanceManagedModpack.value,
),
isLocalFile: isImportedModpack,
+ isManagedModpack: isSharedInstanceManagedModpack,
+ managedModpackWarning: managedContentPolicy.managedModpackWarning,
repairing,
reinstalling,
})
-
+
+
+
+
+
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 ab5c3b7acb..4fd04b94f2 100644
--- a/apps/app-frontend/src/components/ui/modal/UpdateToPlayModal.vue
+++ b/apps/app-frontend/src/components/ui/modal/UpdateToPlayModal.vue
@@ -77,6 +77,11 @@ const { formatMessage } = useVIntl()
const { startInstallingServer, stopInstallingServer } = injectServerInstall()
type UpdateCompleteCallback = () => void | Promise
+const emit = defineEmits<{
+ cancel: []
+ complete: []
+}>()
+
const diffModal = ref>()
const instance = ref(null)
const onUpdateComplete = ref(() => {})
@@ -84,15 +89,15 @@ 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,
projectName: diff.project?.title,
fileName: diff.fileName,
currentVersionName: diff.currentVersion?.version_number,
newVersionName: diff.newVersion?.version_number,
- })),
-)
+ }))
+})
async function computeDependencyDiffs(
currentDeps: Dependency[],
@@ -238,7 +243,6 @@ watch(
)
async function handleUpdate() {
- hide()
const serverProjectId = instance.value?.link?.project_id
if (serverProjectId) startInstallingServer(serverProjectId)
try {
@@ -251,6 +255,7 @@ async function handleUpdate() {
console.error('Error updating instance:', error)
} finally {
if (serverProjectId) stopInstallingServer(serverProjectId)
+ emit('complete')
}
}
@@ -261,7 +266,7 @@ function handleReport() {
}
function handleDecline() {
- hide()
+ emit('cancel')
}
function show(
@@ -292,7 +297,7 @@ const messages = defineMessages({
updateRequiredDescription: {
id: 'app.modal.update-to-play.update-required-description',
defaultMessage:
- 'An update is required to play {name}. Please update to the latest version to launch the game.',
+ 'An update is required to play {name}. Please update to latest version to launch the game.',
},
})
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..1197a75d43
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/shared-instances/SharedInstanceUpdateModal.vue
@@ -0,0 +1,137 @@
+
+
+
+
+
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..32761cb482
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/shared-instances/shared-instance-install-modal/index.vue
@@ -0,0 +1,121 @@
+
+
+
+
+ {{ formatMessage(messages.trustWarningDescription) }}
+
+
+
+
+ {{
+ formatMessage(messages.externalFileWarning, { count: preview.externalFileCount })
+ }}
+
+
+
+
+
+
+
+
+
+
+
+
+
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..a12cd488d6
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/shared-instances/shared-instance-install-modal/shared-instance-install-summary.vue
@@ -0,0 +1,59 @@
+
+
+
+ {{ 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 76fc45cfa4..f0b22ddf03 100644
--- a/apps/app-frontend/src/helpers/install.ts
+++ b/apps/app-frontend/src/helpers/install.ts
@@ -44,6 +44,84 @@ 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'
+ projectId?: string | null
+ projectName?: string | null
+ fileName?: string | null
+ currentVersionName?: string | null
+ newVersionName?: string | 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'
@@ -89,6 +167,7 @@ export interface InstallErrorView {
code: string
phase?: InstallPhaseId | null
message: string
+ reason?: SharedInstanceUnavailableReason | null
api?: {
error: string
status?: number | null
@@ -121,6 +200,8 @@ export interface InstallJobSnapshot {
kind:
| 'create_instance'
| 'create_modpack_instance'
+ | 'create_shared_instance'
+ | 'update_shared_instance'
| 'import_instance'
| 'duplicate_instance'
| 'install_existing_instance'
@@ -171,6 +252,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,
@@ -248,7 +381,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 4570973974..99dec09d0b 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 {
@@ -331,3 +332,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 b6a092b1d4..b10ec5eeed 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"
},
@@ -428,6 +545,9 @@
"app.modal.install-to-play.content-required": {
"message": "Content required"
},
+ "app.modal.install-to-play.external-file-warning": {
+ "message": "{count, plural, one {This instance includes # file that is not from Modrinth.} other {This instance includes # files that are not from Modrinth.}}"
+ },
"app.modal.install-to-play.header": {
"message": "Install to play"
},
@@ -446,20 +566,35 @@
"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-server-instance": {
"message": "Shared server instance"
},
+ "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.view-contents": {
"message": "View contents"
},
"app.modal.update-to-play.header": {
"message": "Update to play"
},
+ "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.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"
@@ -707,6 +842,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"
},
@@ -770,6 +938,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"
},
@@ -1016,6 +1226,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"
},
@@ -1079,6 +1313,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..b66ce3b7b5 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,29 @@
+
+
+
+
+
+ {{ sharedInstanceManager.type === 'server' ? 'Linked to' : 'Managed by' }}
+
+
+ {{ sharedInstanceManager.name }}
+
+
+
@@ -217,6 +256,17 @@
+
@@ -288,14 +338,17 @@ import {
SettingsIcon,
StopCircleIcon,
TerminalSquareIcon,
+ UnknownIcon,
UpdatedIcon,
UserPlusIcon,
XIcon,
} from '@modrinth/assets'
import {
+ AutoLink,
Avatar,
ButtonStyled,
ContentPageHeader,
+ defineMessages,
injectNotificationManager,
NavTabs,
OverflowMenu,
@@ -304,9 +357,11 @@ import {
ServerRecentPlays,
ServerRegion,
useLoadingBarToken,
+ useVIntl,
} from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import { convertFileSrc } from '@tauri-apps/api/core'
+import { openUrl } from '@tauri-apps/plugin-opener'
import dayjs from 'dayjs'
import duration from 'dayjs/plugin/duration'
import relativeTime from 'dayjs/plugin/relativeTime'
@@ -315,8 +370,10 @@ import { useRoute, useRouter } from 'vue-router'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import ExportModal from '@/components/ui/ExportModal.vue'
+import InstanceAdmonitions from '@/components/ui/instance/instance-admonitions/index.vue'
import InstanceSettingsModal from '@/components/ui/modal/InstanceSettingsModal.vue'
import UpdateToPlayModal from '@/components/ui/modal/UpdateToPlayModal.vue'
+import SharedInstanceUpdateModal from '@/components/ui/shared-instances/SharedInstanceUpdateModal.vue'
import {
fetchCachedServerStatus,
getFreshCachedServerStatus,
@@ -325,10 +382,15 @@ import { useInstanceConsole } from '@/composables/useInstanceConsole'
import { trackEvent } from '@/helpers/analytics'
import { get_project_v3 } from '@/helpers/cache.js'
import { instance_listener, process_listener } from '@/helpers/events'
-import { install_existing_instance, install_pack_to_existing_instance } from '@/helpers/install'
+import {
+ install_existing_instance,
+ install_pack_to_existing_instance,
+ type SharedInstanceUnavailableReason,
+} from '@/helpers/install'
import { get, get_full_path, kill, run } from '@/helpers/instance'
import { type InstanceContentData, loadInstanceContentData } from '@/helpers/instance-content'
import { get_by_instance_id } from '@/helpers/process'
+import { useSharedInstanceErrors } from '@/helpers/shared-instance-errors'
import type { GameInstance } from '@/helpers/types'
import { createInstanceShortcut, showInstanceInFolder } from '@/helpers/utils.js'
import { refreshWorlds, type ServerStatus } from '@/helpers/worlds'
@@ -336,6 +398,8 @@ import { injectServerInstall } from '@/providers/server-install'
import { handleSevereError } from '@/store/error.js'
import { useBreadcrumbs, useTheming } from '@/store/state'
+import { provideSharedInstanceState, useSharedInstanceState } from './use-shared-instance-state'
+
dayjs.extend(duration)
dayjs.extend(relativeTime)
@@ -367,6 +431,21 @@ const subpagePending = ref(false)
const stopping = ref(false)
const exportModal = ref>()
const updateToPlayModal = ref>()
+const sharedInstanceUpdateModal = ref>()
+
+const { formatMessage } = useVIntl()
+const { notifySharedInstanceError, notifySharedInstanceUnavailable } = useSharedInstanceErrors()
+
+const messages = defineMessages({
+ sharedInstanceTooltip: {
+ id: 'instance.shared-instance.tooltip',
+ defaultMessage: "This instance's content is being managed by someone else.",
+ },
+ sharedInstanceOwnerTooltip: {
+ id: 'instance.shared-instance.owner-tooltip',
+ defaultMessage: "This instance's content is being shared to other users.",
+ },
+})
useLoadingBarToken(subpagePending)
@@ -385,11 +464,39 @@ const playersOnline = ref(undefined)
const ping = ref(undefined)
const loadingServerPing = ref(false)
const activeInstanceId = ref()
+const sharedInstanceState = useSharedInstanceState(instance, offline, notifySharedInstanceError)
+provideSharedInstanceState(sharedInstanceState)
+const {
+ actionsLocked: sharedInstanceActionsLocked,
+ expectedUserId: sharedInstanceExpectedUserId,
+ manager: sharedInstanceManager,
+ setUnavailable: setSharedInstanceUnavailable,
+ signedOut: sharedInstanceSignedOut,
+ unavailableManager: sharedInstanceUnavailableManager,
+ unavailableReason: sharedInstanceUnavailableReason,
+ updatePreview: sharedInstanceUpdatePreview,
+ wrongAccount: sharedInstanceWrongAccount,
+} = sharedInstanceState
+const sharedInstanceTooltip = computed(() =>
+ formatMessage(
+ instance.value?.shared_instance?.role === 'owner'
+ ? messages.sharedInstanceOwnerTooltip
+ : messages.sharedInstanceTooltip,
+ ),
+)
+const sharedInstanceManagerLink = computed(() => {
+ const manager = sharedInstanceManager.value
+ if (manager?.type !== 'user') return undefined
+ return () => openUrl(`https://modrinth.com/user/${encodeURIComponent(manager.name)}`)
+})
watch(
() => router.currentRoute.value,
(nextRoute) => {
- if (nextRoute.path.startsWith('/instance')) {
+ if (
+ nextRoute.path.startsWith('/instance') &&
+ (!instance.value || nextRoute.params.id === instance.value.id)
+ ) {
displayedInstanceRoute.value = nextRoute
}
},
@@ -415,17 +522,15 @@ function isContentSubpageRoute(routeName = displayedInstanceRoute.value.name) {
}
async function fetchInstance() {
- isServerInstance.value = false
- linkedProjectV3.value = undefined
- preloadedContent.value = null
- resetServerStatus()
+ const requestedInstanceId = route.params.id as string
+ const requestedRouteName = route.name
- const nextInstance = await get(route.params.id as string).catch(handleError)
+ const nextInstance = await get(requestedInstanceId).catch(handleError)
let nextLinkedProjectV3: Labrinth.Projects.v3.Project | undefined
let nextIsServerInstance = false
const contentPreloadPromise =
- nextInstance && isContentSubpageRoute()
+ nextInstance && isContentSubpageRoute(requestedRouteName)
? loadInstanceContentData(nextInstance.id, undefined, handleError)
: Promise.resolve(null)
@@ -441,13 +546,25 @@ async function fetchInstance() {
}
}
- const nextPreloadedContent = await contentPreloadPromise
+ let nextPreloadedContent = await contentPreloadPromise
+ let nextRoute = router.currentRoute.value
+ if (nextRoute.params.id !== requestedInstanceId) return
+
+ if (nextInstance && isContentSubpageRoute(nextRoute.name) && !nextPreloadedContent) {
+ nextPreloadedContent = await loadInstanceContentData(nextInstance.id, undefined, handleError)
+ nextRoute = router.currentRoute.value
+ if (nextRoute.params.id !== requestedInstanceId) return
+ }
instance.value = nextInstance ?? undefined
+ displayedInstanceRoute.value = nextRoute
+ sharedInstanceState.reset()
+ sharedInstanceState.refreshAvailability()
linkedProjectV3.value = nextLinkedProjectV3
isServerInstance.value = nextIsServerInstance
preloadedContent.value = nextPreloadedContent
activeInstanceId.value = nextInstance?.id
+ resetServerStatus()
fetchDeferredData(nextInstance?.id)
@@ -531,29 +648,50 @@ const isFixedRender = computed(() => renderMode.value === 'fixed')
const contentSubpageProps = computed(() =>
isContentSubpageRoute() ? { preloadedContent: preloadedContent.value } : {},
)
+const showShareTab = computed(() => {
+ const linkType = instance.value?.link?.type
-const tabs = computed(() => [
- {
- label: 'Content',
- href: `${basePath.value}`,
- icon: BoxesIcon,
- },
- {
- label: 'Files',
- href: `${basePath.value}/files`,
- icon: FolderOpenIcon,
- },
- {
- label: 'Worlds',
- href: `${basePath.value}/worlds`,
- icon: GlobeIcon,
- },
- {
- label: 'Logs',
- href: `${basePath.value}/logs`,
- icon: TerminalSquareIcon,
- },
-])
+ return (
+ instance.value?.shared_instance?.role !== 'member' &&
+ linkType !== 'server_project' &&
+ linkType !== 'server_project_modpack'
+ )
+})
+
+const tabs = computed(() => {
+ const instanceTabs = [
+ {
+ label: 'Content',
+ href: `${basePath.value}`,
+ icon: BoxesIcon,
+ },
+ {
+ label: 'Files',
+ href: `${basePath.value}/files`,
+ icon: FolderOpenIcon,
+ },
+ {
+ label: 'Worlds',
+ href: `${basePath.value}/worlds`,
+ icon: GlobeIcon,
+ },
+ {
+ label: 'Logs',
+ href: `${basePath.value}/logs`,
+ icon: TerminalSquareIcon,
+ },
+ ]
+
+ if (showShareTab.value) {
+ instanceTabs.push({
+ label: 'Share',
+ href: `${basePath.value}/share`,
+ icon: UserPlusIcon,
+ })
+ }
+
+ return instanceTabs
+})
if (instance.value) {
breadcrumbs.setName(
@@ -571,13 +709,7 @@ if (instance.value) {
const options = ref | null>(null)
-const startInstance = async (context: string) => {
- if (!instance.value) return
- if (updateToPlayModal.value?.hasUpdate) {
- updateToPlayModal.value.show(instance.value)
- return
- }
-
+const launchInstance = async (context: string) => {
loading.value = true
try {
await run(route.params.id as string)
@@ -587,6 +719,7 @@ const startInstance = async (context: string) => {
}
loading.value = false
+ if (!instance.value) return
trackEvent('InstanceStart', {
loader: instance.value.loader,
game_version: instance.value.game_version,
@@ -594,6 +727,49 @@ const startInstance = async (context: string) => {
})
}
+async function handleSharedInstanceUnavailable(
+ reason: SharedInstanceUnavailableReason | null = null,
+) {
+ notifySharedInstanceUnavailable(reason, sharedInstanceUnavailableManager.value)
+ await fetchInstance()
+ setSharedInstanceUnavailable(reason)
+}
+
+const startInstance = async (context: string) => {
+ if (!instance.value) return
+ if (loading.value || playing.value) return
+
+ const isSharedInstanceMember = instance.value.shared_instance?.role === 'member'
+ const canCheckSharedInstanceUpdate =
+ isSharedInstanceMember && !sharedInstanceActionsLocked.value && !offline.value
+
+ if (canCheckSharedInstanceUpdate) {
+ const preview = sharedInstanceUpdatePreview.value
+
+ if (preview?.updateAvailable && sharedInstanceUpdateModal.value) {
+ sharedInstanceUpdateModal.value.show(instance.value, preview, async () => {
+ await fetchInstance()
+ await launchInstance(context)
+ })
+ return
+ }
+ }
+
+ if (updateToPlayModal.value?.hasUpdate) {
+ if (isSharedInstanceMember) {
+ updateToPlayModal.value.show(instance.value, null, async () => {
+ await fetchInstance()
+ await launchInstance(context)
+ })
+ } else {
+ updateToPlayModal.value.show(instance.value)
+ }
+ return
+ }
+
+ await launchInstance(context)
+}
+
const stopInstance = async (context: string) => {
stopping.value = true
await kill(route.params.id as string).catch(handleError)
diff --git a/apps/app-frontend/src/pages/instance/Mods.vue b/apps/app-frontend/src/pages/instance/Mods.vue
index 485d3065de..d72fdab7e8 100644
--- a/apps/app-frontend/src/pages/instance/Mods.vue
+++ b/apps/app-frontend/src/pages/instance/Mods.vue
@@ -12,14 +12,24 @@
ref="modpackContentModal"
:modpack-name="displayedModpackProject?.title"
:modpack-icon-url="displayedModpackProject?.icon_url ?? undefined"
- :enable-toggle="!props.isServerInstance"
+ :enable-toggle="!props.isServerInstance && !isSharedMember"
:busy="isBulkOperating"
:get-overflow-options="getOverflowOptions"
- :switch-version="handleSwitchVersion"
+ :switch-version="
+ props.isServerInstance || isSharedMember ? undefined : handleSwitchVersion
+ "
@update:enabled="handleModpackContentToggle"
@bulk:enable="(items) => handleModpackContentBulkToggle(items, true)"
@bulk:disable="(items) => handleModpackContentBulkToggle(items, false)"
/>
+
void
preloadedContent?: InstanceContentData | null
}>()
+const managedContentPolicy = useManagedContentPolicy(computed(() => props.instance))
+const {
+ isManagedModpack: isSharedMember,
+ canMutateContent,
+ canUpdateContent: canUpdateProject,
+} = managedContentPolicy
function hasPreloadedContent(contentData: InstanceContentData | null | undefined) {
return contentData?.path === props.instance.id
@@ -284,6 +300,8 @@ const exportModal = ref(null)
const contentUpdaterModal = ref | null>()
const modpackContentModal = ref | null>()
const modpackUpdateConfirmModal = ref | null>()
+const sharedDisableConfirmModal = ref | null>()
+const pendingModpackDisableItems = ref([])
const modpackContentQueryKey = computed(() => ['linkedModpackContent', props.instance.id])
const modpackContentQuery = useQuery({
@@ -362,8 +380,8 @@ function hasContentOperation(item: ContentItem) {
return keys.some((key) => activeContentOperationKeys.value.has(key))
}
-function canUpdateProject(item: ContentItem) {
- return !!item.file_path && !!item.has_update && !!item.update_version_id
+function canDeleteContent(item: ContentItem) {
+ return canMutateContent(item)
}
function setContentItemBusy(item: ContentItem, busy: boolean, originalFileName = item.file_name) {
@@ -716,6 +734,7 @@ async function updateProject(mod: ContentItem) {
}
async function switchProjectVersion(mod: ContentItem, version: Labrinth.Versions.v2.Version) {
+ if (!canMutateContent(mod)) return
if (!mod.file_path) return
const operation = beginContentOperation(mod)
if (!operation) return
@@ -852,6 +871,7 @@ async function handleUpdate(id: string) {
}
async function handleSwitchVersion(item: ContentItem) {
+ if (!canMutateContent(item)) return
if (!item.project?.id || !item.version?.id) return
const requestId = beginUpdateRequest()
@@ -879,10 +899,32 @@ async function handleSwitchVersion(item: ContentItem) {
}
async function handleModpackContentToggle(item: ContentItem, enabled: boolean) {
+ if (!enabled && managedContentPolicy.disableWarning([item])) {
+ pendingModpackDisableItems.value = [item]
+ sharedDisableConfirmModal.value?.show()
+ return
+ }
+
await toggleDisableDebounced(item, enabled)
}
async function handleModpackContentBulkToggle(items: ContentItem[], enabled: boolean) {
+ if (!enabled && managedContentPolicy.disableWarning(items)) {
+ pendingModpackDisableItems.value = items
+ sharedDisableConfirmModal.value?.show()
+ return
+ }
+
+ await setModpackContentEnabled(items, enabled)
+}
+
+async function confirmPendingModpackContentDisable() {
+ const items = [...pendingModpackDisableItems.value]
+ pendingModpackDisableItems.value = []
+ await setModpackContentEnabled(items, false)
+}
+
+async function setModpackContentEnabled(items: ContentItem[], enabled: boolean) {
await Promise.all(items.map((item) => toggleDisableMod(item, enabled)))
}
@@ -1232,29 +1274,7 @@ function applyContentData(contentData: InstanceContentData) {
return true
}
-provideAppBackup({
- async createBackup() {
- const allInstances = await list()
- const prefix = `${props.instance.name} - Backup #`
- const existingNums = allInstances
- .filter((p) => p.name.startsWith(prefix))
- .map((p) => parseInt(p.name.slice(prefix.length), 10))
- .filter((n) => !isNaN(n))
- const nextNum = existingNums.length > 0 ? Math.max(...existingNums) + 1 : 1
- const job = await install_duplicate_instance(props.instance.id)
- const newInstanceId = installJobInstanceId(job)
- if (newInstanceId) {
- await edit(newInstanceId, { name: `${prefix}${nextNum}` })
- }
- },
-})
-
-const CONTENT_HINT_KEY = 'content-tab-modpack-hint-dismissed'
-const showContentHint = ref(localStorage.getItem(CONTENT_HINT_KEY) === null)
-function dismissContentHint() {
- showContentHint.value = false
- localStorage.setItem(CONTENT_HINT_KEY, 'true')
-}
+provideInstanceBackup(() => props.instance)
provideContentManager({
items: mergedProjects,
@@ -1316,15 +1336,23 @@ provideContentManager({
toggleEnabled: toggleDisableDebounced,
bulkEnableItems: (items: ContentItem[]) =>
Promise.all(
- items.filter((item) => !item.enabled).map((item) => toggleDisableMod(item, true)),
+ items
+ .filter((item) => canMutateContent(item) && !item.enabled)
+ .map((item) => toggleDisableMod(item, true)),
).then(() => {}),
bulkDisableItems: (items: ContentItem[]) =>
Promise.all(
- items.filter((item) => item.enabled).map((item) => toggleDisableMod(item, false)),
+ items
+ .filter((item) => canMutateContent(item) && item.enabled)
+ .map((item) => toggleDisableMod(item, false)),
).then(() => {}),
deleteItem: removeMod,
bulkDeleteItems: (items: ContentItem[]) =>
- Promise.all(items.map((item) => removeMod(item))).then(() => {}),
+ Promise.all(items.filter(canMutateContent).map((item) => removeMod(item))).then(() => {}),
+ canDeleteItem: canDeleteContent,
+ canToggleItem: canMutateContent,
+ getDeleteWarning: managedContentPolicy.deleteWarning,
+ getDisableWarning: managedContentPolicy.disableWarning,
getDeleteDependencyWarning,
refresh: () => initProjects('must_revalidate'),
browse: handleBrowseContent,
@@ -1333,14 +1361,12 @@ provideContentManager({
updateItem: handleUpdate,
bulkUpdateAll: bulkUpdateAllProjects,
bulkUpdateItem: updateProject,
- updateModpack: props.isServerInstance ? undefined : handleModpackUpdate,
+ updateModpack: props.isServerInstance || isSharedMember.value ? undefined : handleModpackUpdate,
viewModpackContent: handleModpackContent,
unlinkModpack: unpairInstance,
openSettings: props.openSettings,
switchVersion: handleSwitchVersion,
getOverflowOptions,
- showContentHint,
- dismissContentHint,
shareItems: handleShareItems,
getItemId: getContentItemId,
mapToTableItem: (item: ContentItem) => ({
@@ -1372,8 +1398,11 @@ provideContentManager({
link: () => openUrl(`https://modrinth.com/${item.owner!.type}/${item.owner!.id}`),
}
: undefined,
- enabled: item.enabled,
+ enabled: canMutateContent(item) ? item.enabled : undefined,
installing: item.installing,
+ hideDelete: !canDeleteContent(item),
+ hideSwitchVersion: !canMutateContent(item),
+ hasUpdate: canUpdateProject(item),
}),
filterPersistKey: props.instance.id,
})
diff --git a/apps/app-frontend/src/pages/instance/index.js b/apps/app-frontend/src/pages/instance/index.js
index b96705e883..0e6a0e7ec2 100644
--- a/apps/app-frontend/src/pages/instance/index.js
+++ b/apps/app-frontend/src/pages/instance/index.js
@@ -3,6 +3,7 @@ import Index from './Index.vue'
import Logs from './Logs.vue'
import Mods from './Mods.vue'
import Overview from './Overview.vue'
+import Share from './share/index.vue'
import Worlds from './Worlds.vue'
-export { Files, Index, Logs, Mods, Overview, Worlds }
+export { Files, Index, Logs, Mods, Overview, Share, Worlds }
diff --git a/apps/app-frontend/src/pages/instance/share/index.vue b/apps/app-frontend/src/pages/instance/share/index.vue
new file mode 100644
index 0000000000..57767f2503
--- /dev/null
+++ b/apps/app-frontend/src/pages/instance/share/index.vue
@@ -0,0 +1,355 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ formatMessage(messages.lockedEmptyDescriptionPrefix) }}
+
+
+ {{ linkedAccount.username }}
+
+ {{
+ formatMessage(messages.linkedAccountFallback)
+ }}
+ {{ formatMessage(messages.lockedEmptyDescriptionSuffix) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/app-frontend/src/pages/instance/share/shared-instance-members-table.vue b/apps/app-frontend/src/pages/instance/share/shared-instance-members-table.vue
new file mode 100644
index 0000000000..3b16f285d7
--- /dev/null
+++ b/apps/app-frontend/src/pages/instance/share/shared-instance-members-table.vue
@@ -0,0 +1,270 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ No users match your filters.
+
+
+
+
+
+ {{ row.username }}
+
+
+
+
+ {{
+ formatRelativeTime(row.lastPlayedAt)
+ }}
+ Never
+
+
+ Pending
+ {{
+ formatRelativeTime(row.joinedAt)
+ }}
+
+
+
+
+
+ {{ methodLabels[row.method] }}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/app-frontend/src/pages/instance/share/shared-instance-remove-member-modal.vue b/apps/app-frontend/src/pages/instance/share/shared-instance-remove-member-modal.vue
new file mode 100644
index 0000000000..3f2c4dac94
--- /dev/null
+++ b/apps/app-frontend/src/pages/instance/share/shared-instance-remove-member-modal.vue
@@ -0,0 +1,122 @@
+
+
+
+
{{ formatMessage(messages.warning, { username }) }}
+
+
+
+ {{ username }}
+ {{
+ row ? methodLabels[row.method] : ''
+ }}
+
+
+
+
{{ formatMessage(messages.effectsLabel) }}
+
+ -
+ {{ formatMessage(effect) }}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/app-frontend/src/pages/instance/share/shared-instance-share-empty-state.vue b/apps/app-frontend/src/pages/instance/share/shared-instance-share-empty-state.vue
new file mode 100644
index 0000000000..22839fe4d0
--- /dev/null
+++ b/apps/app-frontend/src/pages/instance/share/shared-instance-share-empty-state.vue
@@ -0,0 +1,15 @@
+
+
+ {{ heading }}
+ {{ description }}
+
+
+
+
+
diff --git a/apps/app-frontend/src/pages/instance/share/shared-instance-share-types.ts b/apps/app-frontend/src/pages/instance/share/shared-instance-share-types.ts
new file mode 100644
index 0000000000..a6e478ba4e
--- /dev/null
+++ b/apps/app-frontend/src/pages/instance/share/shared-instance-share-types.ts
@@ -0,0 +1,20 @@
+export type ShareMethod = 'direct' | 'link'
+export type MethodFilter = ShareMethod | 'all'
+export type ShareTableColumn = 'username' | 'lastPlayed' | 'joined' | 'method' | 'actions'
+
+export type ShareRow = {
+ id: string
+ username: string
+ avatarUrl?: string
+ lastPlayedAt: Date | null
+ joinedAt: Date | null
+ method: ShareMethod
+ pending?: boolean
+}
+
+export const methodLabels: Record = {
+ direct: 'Direct invite',
+ link: 'Share link',
+}
+
+export { normalizeInviteKey } from '@modrinth/ui'
diff --git a/apps/app-frontend/src/pages/instance/share/use-shared-instance-invite-candidates.ts b/apps/app-frontend/src/pages/instance/share/use-shared-instance-invite-candidates.ts
new file mode 100644
index 0000000000..b25e9e91df
--- /dev/null
+++ b/apps/app-frontend/src/pages/instance/share/use-shared-instance-invite-candidates.ts
@@ -0,0 +1,105 @@
+import {
+ injectNotificationManager,
+ type InvitePlayersSearchUser,
+ type InvitePlayersUser,
+} from '@modrinth/ui'
+import { computed, type Ref } from 'vue'
+
+import { useFriends } from '@/composables/use-friends'
+import { getFriendUserId } from '@/helpers/friends.ts'
+import { get as getCredentials } from '@/helpers/mr_auth.ts'
+import { search_user } from '@/helpers/users.ts'
+
+import { normalizeInviteKey, type ShareRow } from './shared-instance-share-types'
+
+export function useSharedInstanceInviteCandidates(options: {
+ rows: Ref
+ currentUserId: Ref
+ isSignedIn: Ref
+ actionsLocked: Ref
+}) {
+ const { handleError } = injectNotificationManager()
+ const friendsState = useFriends({
+ currentUserId: options.currentUserId,
+ getCredentials,
+ enabled: computed(
+ () =>
+ options.isSignedIn.value && !!options.currentUserId.value && !options.actionsLocked.value,
+ ),
+ onError: handleError,
+ })
+ const friends = friendsState.friends
+ const invitedRows = computed(() => {
+ const invited = new Map()
+ for (const row of options.rows.value) {
+ invited.set(normalizeInviteKey(row.id), row)
+ invited.set(normalizeInviteKey(row.username), row)
+ }
+ return invited
+ })
+ const inviteFriends = computed(() =>
+ friends.value
+ .filter((friend) => friend.username && friend.accepted)
+ .sort((a, b) => Number(b.online) - Number(a.online))
+ .map((friend) => {
+ const id = getFriendUserId(friend, options.currentUserId.value)
+ const invited =
+ invitedRows.value.get(normalizeInviteKey(id)) ??
+ invitedRows.value.get(normalizeInviteKey(friend.username))
+ return {
+ id,
+ username: friend.username,
+ avatarUrl: friend.avatar,
+ online: friend.online,
+ status: invited ? (invited.pending ? 'pending' : 'added') : 'available',
+ }
+ }),
+ )
+ const candidateKeys = computed(() => {
+ const keys = new Set()
+ for (const friend of inviteFriends.value) {
+ keys.add(normalizeInviteKey(friend.id))
+ keys.add(normalizeInviteKey(friend.username))
+ }
+ return keys
+ })
+
+ async function search(query: string): Promise {
+ if (options.actionsLocked.value) return []
+ const credentials = await getCredentials()
+ const ownUserId = options.currentUserId.value ?? credentials?.user_id ?? null
+ return (await search_user(query))
+ .filter((user) => user.id !== ownUserId)
+ .filter((user) => {
+ const id = normalizeInviteKey(user.id)
+ const username = normalizeInviteKey(user.username)
+ return (
+ !candidateKeys.value.has(id) &&
+ !candidateKeys.value.has(username) &&
+ !invitedRows.value.has(id) &&
+ !invitedRows.value.has(username)
+ )
+ })
+ .map((user) => ({
+ id: user.id,
+ username: user.username,
+ avatarUrl: user.avatar_url || undefined,
+ }))
+ }
+
+ async function requestFriend(user: InvitePlayersUser) {
+ if (options.actionsLocked.value) return
+ const credentials = await getCredentials()
+ const ownUserId = options.currentUserId.value ?? credentials?.user_id ?? null
+ if (ownUserId && normalizeInviteKey(user.id) === normalizeInviteKey(ownUserId)) return
+ if (!friendsState.findFriend(user.id, user.username)) {
+ friendsState.requestFriend({
+ id: user.id,
+ username: user.username,
+ avatarUrl: user.avatarUrl,
+ })
+ }
+ }
+
+ return { inviteFriends, search, requestFriend }
+}
diff --git a/apps/app-frontend/src/pages/instance/share/use-shared-instance-invite-link.ts b/apps/app-frontend/src/pages/instance/share/use-shared-instance-invite-link.ts
new file mode 100644
index 0000000000..7a7d0a77a9
--- /dev/null
+++ b/apps/app-frontend/src/pages/instance/share/use-shared-instance-invite-link.ts
@@ -0,0 +1,63 @@
+import type { InviteLinkSettings } from '@modrinth/ui'
+import { computed, type Ref, ref, watch } from 'vue'
+
+import { config } from '@/config'
+import { toError } from '@/helpers/errors'
+import { create_shared_instance_invite_link } from '@/helpers/instance'
+
+export function useSharedInstanceInviteLink(
+ instanceId: Ref,
+ onError: (error: unknown) => void,
+) {
+ const details = ref>>()
+ const pending = ref(false)
+ const link = computed(() =>
+ details.value
+ ? `${config.siteUrl}/share/${encodeURIComponent(details.value.inviteId)}`
+ : undefined,
+ )
+
+ async function ensure() {
+ if (details.value) return true
+ if (pending.value) return false
+
+ pending.value = true
+ try {
+ details.value = await create_shared_instance_invite_link(instanceId.value)
+ return true
+ } catch (error) {
+ onError(error)
+ return false
+ } finally {
+ pending.value = false
+ }
+ }
+
+ async function update(settings: InviteLinkSettings) {
+ if (!details.value) return
+
+ pending.value = true
+ try {
+ const maxAgeSeconds = Math.max(
+ 1,
+ Math.min(604800, Math.floor((settings.expiresAt.getTime() - Date.now()) / 1000)),
+ )
+ details.value = await create_shared_instance_invite_link(instanceId.value, {
+ maxAgeSeconds,
+ maxUses: settings.maxUses,
+ replaceInviteId: details.value.inviteId,
+ })
+ } catch (error) {
+ throw toError(error)
+ } finally {
+ pending.value = false
+ }
+ }
+
+ watch(instanceId, () => {
+ details.value = undefined
+ pending.value = false
+ })
+
+ return { details, pending, link, ensure, update }
+}
diff --git a/apps/app-frontend/src/pages/instance/share/use-shared-instance-members.ts b/apps/app-frontend/src/pages/instance/share/use-shared-instance-members.ts
new file mode 100644
index 0000000000..07fd09565b
--- /dev/null
+++ b/apps/app-frontend/src/pages/instance/share/use-shared-instance-members.ts
@@ -0,0 +1,294 @@
+import type { InvitePlayersUser } from '@modrinth/ui'
+import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
+import { computed, type Ref, ref, watch } from 'vue'
+
+import { get_user_many } from '@/helpers/cache.js'
+import { toError } from '@/helpers/errors'
+import {
+ get_shared_instance_users,
+ invite_shared_instance_users,
+ remove_shared_instance_users,
+ type SharedInstanceUser,
+ type SharedInstanceUsers,
+} from '@/helpers/instance'
+import type { GameInstance } from '@/helpers/types'
+
+import { normalizeInviteKey, type ShareRow } from './shared-instance-share-types'
+
+type MutationContext = {
+ queryKey: readonly ['sharedInstanceUsers', string]
+ previousRows?: ShareRow[]
+ previousPendingRows: Record
+}
+
+export function useSharedInstanceMembers(options: {
+ instance: Ref
+ currentUserId: Ref
+ isSignedIn: Ref
+ actionsLocked: Ref
+ onError: (error: unknown) => void
+ onHydrationError: (error: Error) => void
+}) {
+ const queryClient = useQueryClient()
+ const queryKey = computed(() => ['sharedInstanceUsers', options.instance.value.id] as const)
+ const pendingRows = ref>(loadPendingRows(options.instance.value.id))
+
+ async function usersToRows(users: SharedInstanceUsers): Promise {
+ const excludedIds = new Set(
+ [options.instance.value.shared_instance?.manager_id, options.currentUserId.value].filter(
+ (id): id is string => !!id,
+ ),
+ )
+ const usersToDisplay = userEntries(users).filter((user) => !excludedIds.has(user.id))
+ if (usersToDisplay.length === 0) return []
+
+ const profiles = (await get_user_many(usersToDisplay.map((user) => user.id))) as Array<{
+ id: string
+ username?: string
+ avatar_url?: string | null
+ }>
+
+ return usersToDisplay.map((user) => {
+ const profile = profiles.find((candidate) => candidate.id === user.id)
+ const joinedAt = parseDate(user.joined_at)
+ return {
+ id: user.id,
+ username: profile?.username ?? user.id,
+ avatarUrl: profile?.avatar_url ?? undefined,
+ lastPlayedAt: parseDate(user.last_played),
+ joinedAt,
+ method: user.join_type === 'link' ? 'link' : 'direct',
+ pending: !joinedAt,
+ }
+ })
+ }
+
+ async function loadRows() {
+ if (options.actionsLocked.value) return []
+ const loadedRows = await usersToRows(await get_shared_instance_users(options.instance.value.id))
+ removePendingRows(loadedRows.map((row) => row.id))
+ return loadedRows
+ }
+
+ const query = useQuery({
+ queryKey,
+ queryFn: loadRows,
+ enabled: () =>
+ options.isSignedIn.value && !!options.instance.value.id && !options.actionsLocked.value,
+ staleTime: Infinity,
+ })
+ const remoteRows = computed(
+ () => query.data.value ?? queryClient.getQueryData(queryKey.value) ?? [],
+ )
+ const rows = computed(() => {
+ if (options.actionsLocked.value) return remoteRows.value
+ const remoteIds = new Set(remoteRows.value.map((row) => normalizeInviteKey(row.id)))
+ return [
+ ...Object.values(pendingRows.value).filter(
+ (row) => !remoteIds.has(normalizeInviteKey(row.id)),
+ ),
+ ...remoteRows.value,
+ ]
+ })
+
+ const inviteMutation = useMutation({
+ mutationFn: async (user: InvitePlayersUser) => {
+ if (options.actionsLocked.value) return
+ return await invite_shared_instance_users(options.instance.value.id, [user.id])
+ },
+ onMutate: async (user): Promise => {
+ const currentKey = queryKey.value
+ await queryClient.cancelQueries({ queryKey: currentKey })
+ const context = {
+ queryKey: currentKey,
+ previousRows: queryClient.getQueryData(currentKey),
+ previousPendingRows: pendingRows.value,
+ }
+ setPendingRow(user)
+ return context
+ },
+ onError: (error, user, context) => {
+ removePendingRow(user.id)
+ restore(context)
+ options.onError(error)
+ },
+ onSuccess: async (users, user) => {
+ try {
+ if (users) {
+ queryClient.setQueryData(queryKey.value, await usersToRows(users))
+ } else {
+ upsert(inviteUserToRow(user))
+ }
+ } catch (error) {
+ options.onHydrationError(toError(error))
+ upsert(inviteUserToRow(user))
+ await queryClient.invalidateQueries({ queryKey: queryKey.value })
+ }
+ },
+ })
+
+ const removeMutation = useMutation({
+ mutationFn: async (id: string) => {
+ if (options.actionsLocked.value) {
+ return { user_ids: [], users: [], tokens: 0 } satisfies SharedInstanceUsers
+ }
+ return await remove_shared_instance_users(options.instance.value.id, [id])
+ },
+ onMutate: async (id): Promise => {
+ const currentKey = queryKey.value
+ await queryClient.cancelQueries({ queryKey: currentKey })
+ const context = {
+ queryKey: currentKey,
+ previousRows: queryClient.getQueryData(currentKey),
+ previousPendingRows: pendingRows.value,
+ }
+ queryClient.setQueryData(currentKey, (currentRows = []) =>
+ currentRows.filter((row) => normalizeInviteKey(row.id) !== normalizeInviteKey(id)),
+ )
+ removePendingRow(id)
+ return context
+ },
+ onError: (error, _id, context) => {
+ restore(context)
+ options.onError(error)
+ },
+ onSuccess: async (users) => {
+ try {
+ queryClient.setQueryData(queryKey.value, await usersToRows(users))
+ } catch (error) {
+ options.onHydrationError(toError(error))
+ await queryClient.invalidateQueries({ queryKey: queryKey.value })
+ }
+ },
+ })
+
+ function find(id: string, username: string) {
+ const normalizedId = normalizeInviteKey(id)
+ const normalizedUsername = normalizeInviteKey(username)
+ return rows.value.find(
+ (row) =>
+ normalizeInviteKey(row.id) === normalizedId ||
+ normalizeInviteKey(row.username) === normalizedUsername,
+ )
+ }
+
+ function invite(user: InvitePlayersUser) {
+ if (!options.actionsLocked.value && !find(user.id, user.username)) inviteMutation.mutate(user)
+ }
+
+ function remove(id: string) {
+ if (!options.actionsLocked.value) removeMutation.mutate(id)
+ }
+
+ function setPendingRow(user: InvitePlayersUser) {
+ pendingRows.value = { ...pendingRows.value, [user.id]: inviteUserToRow(user) }
+ savePendingRows()
+ }
+
+ function removePendingRow(id: string) {
+ const normalizedId = normalizeInviteKey(id)
+ pendingRows.value = Object.fromEntries(
+ Object.entries(pendingRows.value).filter(
+ ([pendingId]) => normalizeInviteKey(pendingId) !== normalizedId,
+ ),
+ )
+ savePendingRows()
+ }
+
+ function removePendingRows(ids: string[]) {
+ if (ids.length === 0) return
+ const normalizedIds = new Set(ids.map(normalizeInviteKey))
+ pendingRows.value = Object.fromEntries(
+ Object.entries(pendingRows.value).filter(
+ ([id]) => !normalizedIds.has(normalizeInviteKey(id)),
+ ),
+ )
+ savePendingRows()
+ }
+
+ function upsert(row: ShareRow) {
+ queryClient.setQueryData(queryKey.value, (currentRows = []) => [
+ row,
+ ...currentRows.filter(
+ (existing) => normalizeInviteKey(existing.id) !== normalizeInviteKey(row.id),
+ ),
+ ])
+ }
+
+ function restore(context?: MutationContext) {
+ if (!context) return
+ if (context.previousRows === undefined) {
+ queryClient.removeQueries({ queryKey: context.queryKey, exact: true })
+ } else {
+ queryClient.setQueryData(context.queryKey, context.previousRows)
+ }
+ pendingRows.value = context.previousPendingRows
+ savePendingRows()
+ }
+
+ function savePendingRows() {
+ if (typeof localStorage === 'undefined') return
+ const key = pendingRowsStorageKey(options.instance.value.id)
+ const storedRows = Object.values(pendingRows.value)
+ if (storedRows.length === 0) localStorage.removeItem(key)
+ else localStorage.setItem(key, JSON.stringify(storedRows))
+ }
+
+ watch(
+ () => options.instance.value.id,
+ (instanceId) => {
+ pendingRows.value = loadPendingRows(instanceId)
+ },
+ )
+
+ return { rows, query, find, invite, remove }
+}
+
+function userEntries(users: SharedInstanceUsers): SharedInstanceUser[] {
+ if (users.users?.length > 0) return users.users
+ return users.user_ids.map((id) => ({
+ id,
+ joined_at: null,
+ join_type: 'invite',
+ last_played: null,
+ }))
+}
+
+function parseDate(value?: string | null) {
+ if (!value) return null
+ const date = new Date(value)
+ return Number.isNaN(date.getTime()) ? null : date
+}
+
+function inviteUserToRow(user: InvitePlayersUser): ShareRow {
+ return {
+ id: user.id,
+ username: user.username,
+ avatarUrl: user.avatarUrl ?? undefined,
+ lastPlayedAt: null,
+ joinedAt: null,
+ method: 'direct',
+ pending: true,
+ }
+}
+
+function loadPendingRows(instanceId: string): Record {
+ if (typeof localStorage === 'undefined') return {}
+ try {
+ const stored = localStorage.getItem(pendingRowsStorageKey(instanceId))
+ if (!stored) return {}
+ const rows = JSON.parse(stored) as ShareRow[]
+ return Object.fromEntries(
+ rows.map((row) => [
+ row.id,
+ { ...row, lastPlayedAt: null, joinedAt: null, pending: true } satisfies ShareRow,
+ ]),
+ )
+ } catch {
+ return {}
+ }
+}
+
+function pendingRowsStorageKey(instanceId: string) {
+ return `modrinth:shared-instance-pending-users:${instanceId}`
+}
diff --git a/apps/app-frontend/src/pages/instance/use-shared-instance-state.ts b/apps/app-frontend/src/pages/instance/use-shared-instance-state.ts
new file mode 100644
index 0000000000..2b47ca7ab4
--- /dev/null
+++ b/apps/app-frontend/src/pages/instance/use-shared-instance-state.ts
@@ -0,0 +1,190 @@
+import { injectAuth } from '@modrinth/ui'
+import { computed, inject, type InjectionKey, provide, type Ref, ref, watch } from 'vue'
+
+import { useUserQuery } from '@/composables/users/use-user-query'
+import {
+ getSharedInstanceUnavailableReason,
+ install_get_shared_instance_update_preview,
+ isSharedInstanceUnavailableError,
+ type SharedInstanceUnavailableReason,
+} from '@/helpers/install'
+import type { GameInstance } from '@/helpers/types'
+
+export type SharedInstanceManager =
+ | {
+ type: 'user'
+ name: string
+ avatarUrl?: string
+ tintBy: string
+ }
+ | {
+ type: 'server'
+ name: string
+ avatarUrl?: string
+ tintBy: string
+ }
+
+export function useSharedInstanceState(
+ instance: Ref,
+ offline: Ref,
+ notifyError: (error: unknown) => void,
+) {
+ const auth = injectAuth()
+ const updatePreview =
+ ref>>(null)
+ const unavailableReason = ref(null)
+ const availabilityCheckKey = ref(null)
+ const availabilityRefresh = ref(0)
+ let availabilityRequestId = 0
+
+ const expectedUserId = computed(() => instance.value?.shared_instance?.linked_user_id ?? null)
+ const wrongAccount = computed(() => {
+ if (auth.isReady && !auth.isReady.value) return false
+ if (!expectedUserId.value) return false
+ return auth.user.value?.id !== expectedUserId.value
+ })
+ const actionsLocked = computed(() => wrongAccount.value)
+ const shareActionsLocked = computed(() => actionsLocked.value || unavailableReason.value !== null)
+ const signedOut = computed(() => !auth.session_token.value)
+ const managerUserId = computed(() => {
+ const attachment = instance.value?.shared_instance
+ if (!attachment) return null
+ if (attachment.role === 'owner') {
+ return actionsLocked.value ? (attachment.linked_user_id ?? null) : null
+ }
+ return attachment.manager_id ?? null
+ })
+ const managerUserQuery = useUserQuery(managerUserId)
+ const manager = computed(() => {
+ const attachment = instance.value?.shared_instance
+ if (!attachment) return null
+
+ if (attachment.server_manager_name) {
+ return {
+ type: 'server',
+ name: attachment.server_manager_name,
+ avatarUrl: attachment.server_manager_icon_url ?? undefined,
+ tintBy: attachment.server_manager_name,
+ }
+ }
+
+ const user = managerUserQuery.data.value
+ if (!user) return null
+ return {
+ type: 'user',
+ name: user.username,
+ avatarUrl: user.avatar_url ?? undefined,
+ tintBy: user.id,
+ }
+ })
+ const unavailableManager = computed(() => manager.value?.name ?? null)
+
+ function reset() {
+ availabilityRequestId++
+ availabilityCheckKey.value = null
+ updatePreview.value = null
+ unavailableReason.value = null
+ }
+
+ function refreshAvailability() {
+ availabilityRefresh.value++
+ }
+
+ function setUnavailable(reason: SharedInstanceUnavailableReason | null) {
+ availabilityRequestId++
+ availabilityCheckKey.value = null
+ updatePreview.value = null
+ unavailableReason.value = reason
+ }
+
+ async function checkAvailability(instanceId: string, key: string) {
+ const requestId = ++availabilityRequestId
+ try {
+ const preview = await install_get_shared_instance_update_preview(instanceId)
+ if (!isCurrentRequest(requestId, instanceId, key)) return
+ updatePreview.value = preview
+ unavailableReason.value = null
+ } catch (error) {
+ if (!isCurrentRequest(requestId, instanceId, key)) return
+ if (isSharedInstanceUnavailableError(error)) {
+ updatePreview.value = null
+ unavailableReason.value = getSharedInstanceUnavailableReason(error)
+ return
+ }
+ notifyError(error)
+ }
+ }
+
+ function isCurrentRequest(requestId: number, instanceId: string, key: string) {
+ return (
+ requestId === availabilityRequestId &&
+ instance.value?.id === instanceId &&
+ availabilityCheckKey.value === key
+ )
+ }
+
+ watch(
+ () => ({
+ refresh: availabilityRefresh.value,
+ instanceId: instance.value?.id,
+ role: instance.value?.shared_instance?.role,
+ locked: actionsLocked.value,
+ offline: offline.value,
+ signedIn: !!auth.session_token.value,
+ userId: auth.user.value?.id ?? null,
+ authReady: auth.isReady?.value ?? true,
+ }),
+ async ({ instanceId, role, locked, offline, signedIn, userId, authReady }) => {
+ if (
+ !instanceId ||
+ role !== 'member' ||
+ locked ||
+ offline ||
+ !authReady ||
+ !signedIn ||
+ !userId
+ ) {
+ availabilityRequestId++
+ availabilityCheckKey.value = null
+ updatePreview.value = null
+ if (instanceId && role) unavailableReason.value = null
+ return
+ }
+
+ const key = `${instanceId}:${userId}`
+ if (availabilityCheckKey.value === key) return
+ availabilityCheckKey.value = key
+ await checkAvailability(instanceId, key)
+ },
+ { immediate: true },
+ )
+
+ return {
+ actionsLocked,
+ shareActionsLocked,
+ unavailableReason,
+ unavailableManager,
+ manager,
+ updatePreview,
+ expectedUserId,
+ wrongAccount,
+ signedOut,
+ reset,
+ refreshAvailability,
+ setUnavailable,
+ }
+}
+
+export type SharedInstanceState = ReturnType
+
+const sharedInstanceStateKey: InjectionKey = Symbol('shared-instance-state')
+
+export function provideSharedInstanceState(state: SharedInstanceState) {
+ provide(sharedInstanceStateKey, state)
+}
+
+export function injectSharedInstanceState() {
+ const state = inject(sharedInstanceStateKey)
+ if (!state) throw new Error('Shared instance state has not been provided.')
+ return state
+}
diff --git a/apps/app-frontend/src/providers/instance-backup.ts b/apps/app-frontend/src/providers/instance-backup.ts
new file mode 100644
index 0000000000..dee36ebf5d
--- /dev/null
+++ b/apps/app-frontend/src/providers/instance-backup.ts
@@ -0,0 +1,25 @@
+import { provideAppBackup } from '@modrinth/ui'
+import { type MaybeRefOrGetter, toValue } from 'vue'
+
+import { install_duplicate_instance, installJobInstanceId } from '@/helpers/install'
+import { edit, list } from '@/helpers/instance'
+import type { GameInstance } from '@/helpers/types'
+
+export function provideInstanceBackup(instance: MaybeRefOrGetter) {
+ provideAppBackup({
+ async createBackup() {
+ const source = toValue(instance)
+ const prefix = `${source.name} - Backup #`
+ const existingNumbers = (await list())
+ .filter((candidate) => candidate.name.startsWith(prefix))
+ .map((candidate) => Number.parseInt(candidate.name.slice(prefix.length), 10))
+ .filter(Number.isFinite)
+ const nextNumber = existingNumbers.length ? Math.max(...existingNumbers) + 1 : 1
+ const job = await install_duplicate_instance(source.id)
+ const backupInstanceId = installJobInstanceId(job)
+ if (backupInstanceId) {
+ await edit(backupInstanceId, { name: `${prefix}${nextNumber}` })
+ }
+ },
+ })
+}
diff --git a/apps/app-frontend/src/providers/setup/auth.ts b/apps/app-frontend/src/providers/setup/auth.ts
index 8832c09c6a..6556b7dd8f 100644
--- a/apps/app-frontend/src/providers/setup/auth.ts
+++ b/apps/app-frontend/src/providers/setup/auth.ts
@@ -1,5 +1,5 @@
import type { Labrinth } from '@modrinth/api-client'
-import { type AuthProvider, type AuthUser, provideAuth } from '@modrinth/ui'
+import { type AuthFlow, type AuthProvider, type AuthUser, provideAuth } from '@modrinth/ui'
import { computed, type Ref, ref, watchEffect } from 'vue'
type AppCredentials = {
@@ -9,7 +9,7 @@ type AppCredentials = {
export function setupAuthProvider(
credentials: Ref,
- requestSignIn: (redirectPath: string) => void | Promise,
+ requestSignIn: (redirectPath: string, flow?: AuthFlow) => void | Promise,
) {
const sessionToken = ref(null)
const user = ref(null)
diff --git a/apps/app-frontend/src/routes.js b/apps/app-frontend/src/routes.js
index 5919805754..2baa1edd06 100644
--- a/apps/app-frontend/src/routes.js
+++ b/apps/app-frontend/src/routes.js
@@ -215,6 +215,15 @@ export default new createRouter({
breadcrumb: [{ name: '?Instance', link: '/instance/{id}/' }, { name: 'Worlds' }],
},
},
+ {
+ path: 'share',
+ name: 'InstanceShare',
+ component: Instance.Share,
+ meta: {
+ useRootContext: true,
+ breadcrumb: [{ name: '?Instance', link: '/instance/{id}/' }, { name: 'Share' }],
+ },
+ },
{
path: '',
name: 'Mods',
diff --git a/apps/app/build.rs b/apps/app/build.rs
index 8a8fe00c79..5b4c07c9c6 100644
--- a/apps/app/build.rs
+++ b/apps/app/build.rs
@@ -148,6 +148,11 @@ fn main() {
"install_get_modpack_preview",
"install_create_instance",
"install_create_modpack_instance",
+ "install_get_shared_instance_preview",
+ "install_accept_shared_instance_invite",
+ "install_get_shared_instance_update_preview",
+ "install_shared_instance",
+ "install_update_shared_instance",
"install_import_instance",
"install_duplicate_instance",
"install_existing_instance",
@@ -210,6 +215,14 @@ fn main() {
"instance_kill",
"instance_edit",
"instance_edit_icon",
+ "instance_share_get_users",
+ "instance_share_invite_users",
+ "instance_share_create_invite_link",
+ "instance_share_remove_users",
+ "instance_share_get_publish_preview",
+ "instance_share_publish",
+ "instance_share_unlink",
+ "instance_share_unpublish",
"instance_export_mrpack",
"instance_get_pack_export_candidates",
])
@@ -251,6 +264,14 @@ fn main() {
DefaultPermissionRule::AllowAllCommands,
),
)
+ .plugin(
+ "users",
+ InlinedPlugin::new()
+ .commands(&["search_user"])
+ .default_permission(
+ DefaultPermissionRule::AllowAllCommands,
+ ),
+ )
.plugin(
"utils",
InlinedPlugin::new()
diff --git a/apps/app/capabilities/plugins.json b/apps/app/capabilities/plugins.json
index 2cb2521766..4a30955f70 100644
--- a/apps/app/capabilities/plugins.json
+++ b/apps/app/capabilities/plugins.json
@@ -107,6 +107,7 @@
"settings:default",
"shortcuts:default",
"tags:default",
+ "users:default",
"utils:default",
"ads:default",
"friends:default",
diff --git a/apps/app/src/api/install.rs b/apps/app/src/api/install.rs
index 96d2f0481b..22dcbef219 100644
--- a/apps/app/src/api/install.rs
+++ b/apps/app/src/api/install.rs
@@ -6,6 +6,10 @@ use theseus::data::ModLoader;
use theseus::install::{
InstallJobSnapshot, InstallModpackPreview, InstallPostInstallEdit,
};
+use theseus::instance::{
+ SharedInstanceInstallPreview, SharedInstanceInviteInstallPreview,
+ SharedInstanceUpdatePreview,
+};
use theseus::pack::import::ImportLauncherType;
use theseus::pack::install_from::CreatePackLocation;
use uuid::Uuid;
@@ -16,6 +20,11 @@ pub fn init() -> tauri::plugin::TauriPlugin {
install_get_modpack_preview,
install_create_instance,
install_create_modpack_instance,
+ install_get_shared_instance_preview,
+ install_accept_shared_instance_invite,
+ install_get_shared_instance_update_preview,
+ install_shared_instance,
+ install_update_shared_instance,
install_import_instance,
install_duplicate_instance,
install_existing_instance,
@@ -101,6 +110,67 @@ pub async fn install_create_modpack_instance(
.await?)
}
+#[tauri::command]
+pub async fn install_get_shared_instance_preview(
+ shared_instance_id: String,
+ name: String,
+) -> Result {
+ Ok(theseus::instance::get_shared_instance_install_preview(
+ &shared_instance_id,
+ name,
+ )
+ .await?)
+}
+
+#[tauri::command]
+pub async fn install_accept_shared_instance_invite(
+ invite_id: String,
+) -> Result {
+ Ok(
+ theseus::instance::accept_shared_instance_invite_for_install(
+ &invite_id,
+ )
+ .await?,
+ )
+}
+
+#[tauri::command]
+pub async fn install_get_shared_instance_update_preview(
+ instance_id: String,
+) -> Result