diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue index 1f39661978..286e0aca14 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,35 @@ 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 +903,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 +1495,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..8e57391ee0 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 { useFriends } from '@/composables/use-friends' 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 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 @@ + + + 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 @@ + + + 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 @@ + + + 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..a830befac4 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,25 +15,27 @@ 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 { injectInstanceSettings } from '@/providers/instance-settings' +import { provideInstanceBackup } from '@/providers/instance-backup' import { useTheming } from '@/store/state' import type { Manifest } from '../../../helpers/types' @@ -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 @@ + + + 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..247626d563 --- /dev/null +++ b/apps/app-frontend/src/components/ui/shared-instances/SharedInstanceInstallationSettingsControls.vue @@ -0,0 +1,81 @@ + + + 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..bf5c7b4fec --- /dev/null +++ b/apps/app-frontend/src/components/ui/shared-instances/SharedInstanceUpdateModal.vue @@ -0,0 +1,91 @@ + + + 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..fbfbe4b836 --- /dev/null +++ b/apps/app-frontend/src/components/ui/shared-instances/shared-instance-install-modal/index.vue @@ -0,0 +1,71 @@ + + + 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..69ea7e3816 --- /dev/null +++ b/apps/app-frontend/src/components/ui/shared-instances/shared-instance-install-modal/shared-instance-install-summary.vue @@ -0,0 +1,44 @@ + + + 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..31b7fbaa53 --- /dev/null +++ b/apps/app-frontend/src/components/ui/shared-instances/shared-instance-install-modal/use-shared-instance-preview-content.ts @@ -0,0 +1,127 @@ +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 @@ + + + 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..30d538d0a9 --- /dev/null +++ b/apps/app-frontend/src/components/ui/shared-instances/shared-instance-invite-handler/use-shared-instance-invite-handler.ts @@ -0,0 +1,162 @@ +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..3e56d400c4 --- /dev/null +++ b/apps/app-frontend/src/composables/instances/use-managed-content-policy.ts @@ -0,0 +1,110 @@ +import { + defineMessages, + type ContentActionWarning, + type ContentItem, + 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..650d3a9cbe --- /dev/null +++ b/apps/app-frontend/src/composables/use-friends.ts @@ -0,0 +1,156 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query' +import { computed, onUnmounted, type MaybeRefOrGetter, toValue } from 'vue' + +import { toError } from '@/helpers/errors' +import { friend_listener } from '@/helpers/events.js' +import { + acceptCachedFriend, + add_friend, + createPendingFriend, + type FriendCacheUser, + type FriendWithUserData, + friendsQueryKey, + 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..e0ac2e925a --- /dev/null +++ b/apps/app-frontend/src/helpers/shared-instance-errors.ts @@ -0,0 +1,83 @@ +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..678adeb4a7 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,84 @@ "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.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 +539,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 +560,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" @@ -1016,6 +1145,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 +1232,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..3f2b80a903 100644 --- a/apps/app-frontend/src/pages/instance/Index.vue +++ b/apps/app-frontend/src/pages/instance/Index.vue @@ -12,7 +12,14 @@ :offline="offline" @unlinked="fetchInstance" /> - + + +