diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue index 52f5c6d5c7..6d79ba9484 100644 --- a/apps/app-frontend/src/App.vue +++ b/apps/app-frontend/src/App.vue @@ -14,7 +14,7 @@ import { ChevronLeftIcon, ChevronRightIcon, CompassIcon, - ImagesIcon, + ImageIcon, LogInIcon, LogOutIcon, NewspaperIcon, @@ -91,8 +91,6 @@ import ModpackAlreadyInstalledModal from '@/components/ui/modal/ModpackAlreadyIn import ModrinthAccountRequiredModal from '@/components/ui/modal/ModrinthAccountRequiredModal.vue' import UpdateToPlayModal from '@/components/ui/modal/UpdateToPlayModal.vue' import NavButton from '@/components/ui/NavButton.vue' -import NewIconEditorNotification from '@/components/ui/new-icon-editor-notification/index.vue' -import { shouldShowNewIconEditorNotification } from '@/components/ui/new-icon-editor-notification/show-notification' import OnboardingChecklist from '@/components/ui/onboarding-checklist/index.vue' import PrideFundraiserBanner from '@/components/ui/PrideFundraiserBanner.vue' import PromotionWrapper from '@/components/ui/PromotionWrapper.vue' @@ -100,12 +98,14 @@ 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 SurveyPopup from '@/components/ui/SurveyPopup.vue' +import SyncInstancesUpdateModal from '@/components/ui/sync-instances-update-modal/index.vue' import WindowControls from '@/components/ui/WindowControls.vue' import { useCheckDisableMouseover } from '@/composables/macCssFix.js' import { useAppEvent } from '@/composables/use-app-event' import { useAppSettings } from '@/composables/use-app-settings.ts' import { useError } from '@/composables/use-error.js' import { useInstanceMetadataRefresh } from '@/composables/use-instance-metadata-refresh' +import { useQuickInstanceLimit } from '@/composables/use-quick-instance-limit.ts' import { isDarkTheme, useTheme } from '@/composables/use-theme.ts' import { config } from '@/config' import { getAccountAppearance, rememberAccountAppearance } from '@/helpers/account-appearance.ts' @@ -124,7 +124,6 @@ import { install_create_modpack_instance, install_get_modpack_preview } from '@/ import { can_current_user_use_shared_instances, get as getInstance, - get_global_synced_options, run, set_global_synced_option, } from '@/helpers/instance' @@ -137,8 +136,9 @@ import { setActive, } from '@/helpers/mr_auth.ts' import { mergeUrlQuery, parseModrinthLink } from '@/helpers/project-links.ts' -import { get as getSettings, set as setSettings } from '@/helpers/settings.ts' +import { appSettingsKeys, get as getSettings, set as setSettings } from '@/helpers/settings.ts' import { get_opening_command, initialize_state } from '@/helpers/state' +import { globalSyncedOptionsQueryOptions, syncedOptionsKeys } from '@/helpers/synced-options' import { hasActivePride26Midas, hasMidasBadge } from '@/helpers/user-campaigns.ts' import { get_user_preferences } from '@/helpers/user-preferences.ts' import { parse_modrinth_user_link } from '@/helpers/users' @@ -153,7 +153,11 @@ import { } from '@/helpers/utils.js' import { start_join_server, start_join_singleplayer_world } from '@/helpers/worlds.ts' import i18n from '@/i18n.config' -import { instanceKeys, screenshotKeys } from '@/pages/instance/query-options' +import { + instanceKeys, + instanceListQueryOptions, + screenshotKeys, +} from '@/pages/instance/query-options' import { appUpdateState, downloadAvailableAppUpdate, @@ -189,6 +193,7 @@ import { const appSettings = useAppSettings() const appTheme = useTheme() +const quickInstances = useQuickInstanceLimit() const router = useRouter() const route = useRoute() const { channel: appEventChannel, events: appEvents } = setupAppEventsProvider() @@ -384,6 +389,7 @@ const { handleModpackDuplicateCreateAnyway, handleModpackDuplicateGoToInstance, onboardingChecklist, + tags, } = setupProviders( tauriApiClient, notificationManager, @@ -452,8 +458,7 @@ const isDevEnvironment = ref(false) const stateInitialized = ref(false) const globalSyncedOptionsQuery = useQuery({ - queryKey: ['global-synced-options'], - queryFn: get_global_synced_options, + ...globalSyncedOptionsQueryOptions(), enabled: computed(() => stateInitialized.value), }) @@ -705,16 +710,9 @@ function handleAdsConsentRequired(required) { } async function setupApp() { + tags.initialize() await onboardingChecklist.initialize() - if (shouldShowNewIconEditorNotification(showChecklist.value)) { - addPopupNotification({ - contentType: 'custom', - component: NewIconEditorNotification, - autoCloseMs: null, - }) - } - const { native_decorations, theme, @@ -725,6 +723,11 @@ async function setupApp() { toggle_sidebar, sync_theme_across_devices, sync_behavior_across_devices, + sync_features_across_devices, + show_files_tab_in_instances, + show_worlds_tab_in_instances, + show_screenshots_tab_in_instances, + show_skin_selector_in_sidebar, developer_mode, feature_flags, pending_update_toast_for_version, @@ -749,10 +752,23 @@ async function setupApp() { appTheme.advancedRendering = advanced_rendering appTheme.syncAcrossDevices = sync_theme_across_devices appSettings.syncBehaviorAcrossDevices = sync_behavior_across_devices + appSettings.syncFeaturesAcrossDevices = sync_features_across_devices appSettings.hideNametagSkinsPage = hide_nametag_skins_page appSettings.toggleSidebar = toggle_sidebar + appSettings.showFilesTabInInstances = show_files_tab_in_instances + appSettings.showWorldsTabInInstances = show_worlds_tab_in_instances + appSettings.showScreenshotsTabInInstances = show_screenshots_tab_in_instances + appSettings.showSkinSelectorInSidebar = show_skin_selector_in_sidebar appSettings.devMode = developer_mode stateInitialized.value = true + await nextTick() + if ( + appSettings.getFeatureFlag('show_sync_instances_update_modal') || + (pending_update_toast_for_version === version && + (await queryClient.fetchQuery(instanceListQueryOptions())).length > 0) + ) { + syncInstancesUpdateModal.value?.show() + } await getCurrentWindow().onResized(async () => { isMaximized.value = await getCurrentWindow().isMaximized() @@ -1038,9 +1054,19 @@ const updateToPlayModal = ref() const modrinthLoginModal = ref() const appSettingsModal = ref() +const syncInstancesUpdateModal = ref() provide(appSettingsModalOpenProfileKey, () => appSettingsModal.value?.showProfile()) provide(appSettingsModalOpenSyncedOptionsKey, () => appSettingsModal.value?.showSyncedOptions()) +watch( + () => appSettings.getFeatureFlag('show_sync_instances_update_modal'), + (enabled) => { + if (enabled && stateInitialized.value) { + syncInstancesUpdateModal.value?.show() + } + }, +) + watch(incompatibilityWarningModal, (modal) => { if (modal) { setContentIncompatibilityWarningModal(modal) @@ -1096,7 +1122,6 @@ watch( if (behavior && appSettings.syncBehaviorAcrossDevices) { const behaviorFeatureFlags = { - worlds_in_home: behavior.show_jump_in, compact_instance_cards: behavior.compact_instance_cards, show_instance_play_time: behavior.show_play_time, skip_unknown_pack_warning: !behavior.warn_on_unknown_modpacks, @@ -1120,25 +1145,53 @@ watch( settingsChanged = true } + for (const [flag, value] of Object.entries(behaviorFeatureFlags)) { + if (settings.feature_flags[flag] !== value) { + settings.feature_flags[flag] = value + settingsChanged = true + } + } + } + + if (behavior && appSettings.syncFeaturesAcrossDevices) { + const featureFlags = { + worlds_in_home: behavior.show_jump_in, + } + const featureSettings = { + show_files_tab_in_instances: 'showFilesTabInInstances', + show_worlds_tab_in_instances: 'showWorldsTabInInstances', + show_screenshots_tab_in_instances: 'showScreenshotsTabInInstances', + show_skin_selector_in_sidebar: 'showSkinSelectorInSidebar', + } + for (const [key, stateKey] of Object.entries(featureSettings)) { + const value = behavior[key] ?? settings[key] + appSettings[stateKey] = value + if (settings[key] !== value) { + settings[key] = value + settingsChanged = true + } + } + Object.assign(appSettings.featureFlags, featureFlags) + if (typeof behavior.quick_instance_count === 'number') { + quickInstances.setLimit(behavior.quick_instance_count) + } + const showAllScreenshots = behavior.show_all_screenshots if (typeof showAllScreenshots === 'boolean') { const globalSyncedOptions = globalSyncedOptionsQuery.data.value ?? - (await queryClient.fetchQuery({ - queryKey: ['global-synced-options'], - queryFn: get_global_synced_options, - })) + (await queryClient.fetchQuery(globalSyncedOptionsQueryOptions())) if (globalSyncedOptions.screenshots !== showAllScreenshots) { const updatedGlobalSyncedOptions = await set_global_synced_option( 'screenshots', showAllScreenshots, ) - queryClient.setQueryData(['global-synced-options'], updatedGlobalSyncedOptions) + queryClient.setQueryData(syncedOptionsKeys.global, updatedGlobalSyncedOptions) await queryClient.invalidateQueries({ queryKey: screenshotKeys.all }) } } - for (const [flag, value] of Object.entries(behaviorFeatureFlags)) { + for (const [flag, value] of Object.entries(featureFlags)) { if (settings.feature_flags[flag] !== value) { settings.feature_flags[flag] = value settingsChanged = true @@ -1148,6 +1201,7 @@ watch( if (settingsChanged) { await setSettings(settings) + queryClient.setQueryData(appSettingsKeys.all, settings) } }) .catch(handleError) @@ -1419,8 +1473,10 @@ async function fetchIntercomToken() { } watch( - [showAd, adConsentAvailable], - async ([showAds, canManageConsent]) => { + [stateInitialized, showAd, adConsentAvailable], + async ([ready, showAds, canManageConsent]) => { + if (!ready) return + if (showAds) { await init_ads_window(true) return @@ -2035,6 +2091,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload) + @@ -2082,7 +2139,11 @@ provideAppUpdateDownloadProgress(appUpdateDownload) > - + - + }), ) const dragging = ref(false) +const quickInstances = useQuickInstanceLimit() -const stored = localStorage.getItem(STORAGE_KEY) -const userLimit = ref(stored === null ? null : Number(stored)) - -const maxVisible = computed(() => Math.min(maxAuto.value, allInstances.value.length)) -const visibleCount = computed(() => Math.min(userLimit.value ?? maxVisible.value, maxVisible.value)) +const maxVisible = computed(() => + Math.min(maxAuto.value, allInstances.value.length, QUICK_INSTANCE_LIMIT_MAX), +) +const visibleCount = computed(() => + Math.min(quickInstances.limit.value ?? maxVisible.value, maxVisible.value), +) const recentInstances = computed(() => allInstances.value.slice(0, visibleCount.value)) const canDrag = computed(() => maxVisible.value > 0) const showOverdrag = ref(false) @@ -74,11 +78,9 @@ const updateMaxAuto = () => { const setLimit = (count) => { const clamped = Math.max(0, Math.min(count, maxVisible.value)) if (clamped >= maxVisible.value) { - userLimit.value = null - localStorage.removeItem(STORAGE_KEY) + quickInstances.setLimit(null) } else { - userLimit.value = clamped - localStorage.setItem(STORAGE_KEY, String(clamped)) + quickInstances.setLimit(clamped) } } diff --git a/apps/app-frontend/src/components/ui/instance/SyncedContentModal.vue b/apps/app-frontend/src/components/ui/instance/SyncedContentModal.vue new file mode 100644 index 0000000000..7590ff4b78 --- /dev/null +++ b/apps/app-frontend/src/components/ui/instance/SyncedContentModal.vue @@ -0,0 +1,248 @@ + + + diff --git a/apps/app-frontend/src/components/ui/modal/AppSettingsModal.vue b/apps/app-frontend/src/components/ui/modal/AppSettingsModal.vue index 78b58fc443..6a4503023f 100644 --- a/apps/app-frontend/src/components/ui/modal/AppSettingsModal.vue +++ b/apps/app-frontend/src/components/ui/modal/AppSettingsModal.vue @@ -4,6 +4,7 @@ import { GaugeIcon, HeartHandshakeIcon, LanguagesIcon, + LightBulbIcon, ModrinthIcon, PaintbrushIcon, RefreshCwIcon, @@ -32,8 +33,9 @@ import SocialSettings from '@/components/ui/settings/account/SocialSettings.vue' import AppearanceSettings from '@/components/ui/settings/display/AppearanceSettings.vue' import BehaviorSettings from '@/components/ui/settings/display/BehaviorSettings.vue' import FeatureFlagSettings from '@/components/ui/settings/display/FeatureFlagSettings.vue' +import FeaturesSettings from '@/components/ui/settings/display/FeaturesSettings.vue' import LanguageSettings from '@/components/ui/settings/display/LanguageSettings.vue' -import InstancesSyncedSettings from '@/components/ui/settings/instances/InstancesSyncedSettings.vue' +import InstancesSyncedSettings from '@/components/ui/settings/instances/instances-synced-settings/index.vue' import JavaSettings from '@/components/ui/settings/instances/JavaSettings.vue' import ResourceManagementSettings from '@/components/ui/settings/instances/ResourceManagementSettings.vue' import { useAppSettings } from '@/composables/use-app-settings.ts' @@ -81,6 +83,15 @@ const tabs = [ icon: PaintbrushIcon, content: AppearanceSettings, }, + { + name: defineMessage({ + id: 'app.settings.tabs.features', + defaultMessage: 'Features', + }), + category: tabCategories.display, + icon: LightBulbIcon, + content: FeaturesSettings, + }, { name: defineMessage({ id: 'app.settings.tabs.behavior', diff --git a/apps/app-frontend/src/components/ui/new-icon-editor-notification/apply-new-icons-modal.vue b/apps/app-frontend/src/components/ui/new-icon-editor-notification/apply-new-icons-modal.vue deleted file mode 100644 index 91c4d02a60..0000000000 --- a/apps/app-frontend/src/components/ui/new-icon-editor-notification/apply-new-icons-modal.vue +++ /dev/null @@ -1,233 +0,0 @@ - - - diff --git a/apps/app-frontend/src/components/ui/new-icon-editor-notification/assets/01.png b/apps/app-frontend/src/components/ui/new-icon-editor-notification/assets/01.png deleted file mode 100644 index f996c89972..0000000000 Binary files a/apps/app-frontend/src/components/ui/new-icon-editor-notification/assets/01.png and /dev/null differ diff --git a/apps/app-frontend/src/components/ui/new-icon-editor-notification/assets/02.png b/apps/app-frontend/src/components/ui/new-icon-editor-notification/assets/02.png deleted file mode 100644 index 371efe55ad..0000000000 Binary files a/apps/app-frontend/src/components/ui/new-icon-editor-notification/assets/02.png and /dev/null differ diff --git a/apps/app-frontend/src/components/ui/new-icon-editor-notification/assets/03.png b/apps/app-frontend/src/components/ui/new-icon-editor-notification/assets/03.png deleted file mode 100644 index 39b88132b5..0000000000 Binary files a/apps/app-frontend/src/components/ui/new-icon-editor-notification/assets/03.png and /dev/null differ diff --git a/apps/app-frontend/src/components/ui/new-icon-editor-notification/assets/04.png b/apps/app-frontend/src/components/ui/new-icon-editor-notification/assets/04.png deleted file mode 100644 index c87cd33b19..0000000000 Binary files a/apps/app-frontend/src/components/ui/new-icon-editor-notification/assets/04.png and /dev/null differ diff --git a/apps/app-frontend/src/components/ui/new-icon-editor-notification/assets/05.png b/apps/app-frontend/src/components/ui/new-icon-editor-notification/assets/05.png deleted file mode 100644 index 4374b9ca3e..0000000000 Binary files a/apps/app-frontend/src/components/ui/new-icon-editor-notification/assets/05.png and /dev/null differ diff --git a/apps/app-frontend/src/components/ui/new-icon-editor-notification/assets/06.png b/apps/app-frontend/src/components/ui/new-icon-editor-notification/assets/06.png deleted file mode 100644 index 6dc6879b1d..0000000000 Binary files a/apps/app-frontend/src/components/ui/new-icon-editor-notification/assets/06.png and /dev/null differ diff --git a/apps/app-frontend/src/components/ui/new-icon-editor-notification/assets/07.png b/apps/app-frontend/src/components/ui/new-icon-editor-notification/assets/07.png deleted file mode 100644 index f9b9b636d0..0000000000 Binary files a/apps/app-frontend/src/components/ui/new-icon-editor-notification/assets/07.png and /dev/null differ diff --git a/apps/app-frontend/src/components/ui/new-icon-editor-notification/assets/08.png b/apps/app-frontend/src/components/ui/new-icon-editor-notification/assets/08.png deleted file mode 100644 index 8359a83917..0000000000 Binary files a/apps/app-frontend/src/components/ui/new-icon-editor-notification/assets/08.png and /dev/null differ diff --git a/apps/app-frontend/src/components/ui/new-icon-editor-notification/assets/09.png b/apps/app-frontend/src/components/ui/new-icon-editor-notification/assets/09.png deleted file mode 100644 index 51768ef23f..0000000000 Binary files a/apps/app-frontend/src/components/ui/new-icon-editor-notification/assets/09.png and /dev/null differ diff --git a/apps/app-frontend/src/components/ui/new-icon-editor-notification/index.vue b/apps/app-frontend/src/components/ui/new-icon-editor-notification/index.vue deleted file mode 100644 index 1e6985e45c..0000000000 --- a/apps/app-frontend/src/components/ui/new-icon-editor-notification/index.vue +++ /dev/null @@ -1,144 +0,0 @@ - - - - - diff --git a/apps/app-frontend/src/components/ui/new-icon-editor-notification/show-notification.ts b/apps/app-frontend/src/components/ui/new-icon-editor-notification/show-notification.ts deleted file mode 100644 index ab4f7fce97..0000000000 --- a/apps/app-frontend/src/components/ui/new-icon-editor-notification/show-notification.ts +++ /dev/null @@ -1,12 +0,0 @@ -const STORAGE_KEY = 'new-icon-editor-notification-shown' - -export function shouldShowNewIconEditorNotification(showOnboardingChecklist: boolean): boolean { - try { - if (localStorage.getItem(STORAGE_KEY) === 'true') return false - if (showOnboardingChecklist) return false - localStorage.setItem(STORAGE_KEY, 'true') - return true - } catch { - return !showOnboardingChecklist - } -} diff --git a/apps/app-frontend/src/components/ui/settings/display/BehaviorSettings.vue b/apps/app-frontend/src/components/ui/settings/display/BehaviorSettings.vue index 2a2cf1e1c5..55622d7ee2 100644 --- a/apps/app-frontend/src/components/ui/settings/display/BehaviorSettings.vue +++ b/apps/app-frontend/src/components/ui/settings/display/BehaviorSettings.vue @@ -2,13 +2,14 @@ import { defineMessages, injectAuth, + injectNotificationManager, injectUserPreferences, Toggle, useSavable, useVIntl, } from '@modrinth/ui' -import { useQueryClient } from '@tanstack/vue-query' -import { inject, onBeforeUnmount, onMounted, ref } from 'vue' +import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query' +import { inject, onBeforeUnmount, onMounted } from 'vue' import { DEFAULT_FEATURE_FLAGS, @@ -16,22 +17,22 @@ import { useAppSettings, } from '@/composables/use-app-settings.ts' import { - get_global_synced_options, - type GlobalSyncedOptions, - set_global_synced_option, -} from '@/helpers/instance.ts' -import { type AppSettings, get, set } from '@/helpers/settings.ts' -import { screenshotKeys } from '@/pages/instance/query-options.ts' + type AppSettings, + appSettingsKeys, + appSettingsQueryOptions, + get, + set, +} from '@/helpers/settings.ts' import { appSettingsModalContextKey } from '@/providers/app-settings-modal' const appSettings = useAppSettings() const { formatMessage } = useVIntl() const auth = injectAuth() +const { handleError } = injectNotificationManager() const { updatePreferences } = injectUserPreferences() const settingsModal = inject(appSettingsModalContextKey, null) const queryClient = useQueryClient() -const worldsInHomeFlag: FeatureFlag = 'worlds_in_home' const compactInstanceCardsFlag: FeatureFlag = 'compact_instance_cards' const skipNonEssentialWarningsFlag: FeatureFlag = 'skip_non_essential_warnings' const skipUnknownPackWarningFlag: FeatureFlag = 'skip_unknown_pack_warning' @@ -59,14 +60,6 @@ const messages = defineMessages({ id: 'app.behavior-settings.content.title', defaultMessage: 'Home and content', }, - showAllScreenshotsTitle: { - id: 'app.behavior-settings.show-all-screenshots.title', - defaultMessage: 'Show all screenshots together', - }, - showAllScreenshotsDescription: { - id: 'app.behavior-settings.show-all-screenshots.description', - defaultMessage: 'View screenshots from all your instances on the Screenshots page.', - }, confirmationsTitle: { id: 'app.behavior-settings.confirmations.title', defaultMessage: 'Confirmations', @@ -95,15 +88,6 @@ const messages = defineMessages({ id: 'app.appearance-settings.toggle-sidebar.description', defaultMessage: 'Hide the right sidebar by default and add a button to show or hide it.', }, - jumpBackIntoWorldsTitle: { - id: 'app.appearance-settings.jump-back-into-worlds.title', - defaultMessage: 'Jump into worlds or instances', - }, - jumpBackIntoWorldsDescription: { - id: 'app.appearance-settings.jump-back-into-worlds.description', - defaultMessage: - 'Show recently played worlds or instances in the "Jump in" section on the Home page.', - }, compactModeTitle: { id: 'app.appearance-settings.compact-mode.title', defaultMessage: 'Compact mode', @@ -152,8 +136,6 @@ type BehaviorSettingsState = { syncBehaviorAcrossDevices: boolean minimizeApp: boolean hideRightSidebar: boolean - showJumpIn: boolean - showAllScreenshots: boolean compactInstanceCards: boolean showPlayTime: boolean hideNametag: boolean @@ -161,23 +143,14 @@ type BehaviorSettingsState = { skipNonEssentialWarnings: boolean } -const [initialSettings, initialGlobalSyncedOptions] = await Promise.all([ - get(), - get_global_synced_options(), -]) -const persistedSettings = ref(initialSettings) -const persistedGlobalSyncedOptions = ref(initialGlobalSyncedOptions) +const settingsQuery = useQuery(appSettingsQueryOptions()) +await settingsQuery.suspense() -function getBehaviorSettingsState( - settings: AppSettings, - globalSyncedOptions: GlobalSyncedOptions, -): BehaviorSettingsState { +function getBehaviorSettingsState(settings: AppSettings): BehaviorSettingsState { return { syncBehaviorAcrossDevices: settings.sync_behavior_across_devices, minimizeApp: settings.hide_on_process_start, hideRightSidebar: settings.toggle_sidebar, - showJumpIn: settings.feature_flags[worldsInHomeFlag] ?? DEFAULT_FEATURE_FLAGS[worldsInHomeFlag], - showAllScreenshots: globalSyncedOptions.screenshots, compactInstanceCards: settings.feature_flags[compactInstanceCardsFlag] ?? DEFAULT_FEATURE_FLAGS[compactInstanceCardsFlag], @@ -194,36 +167,33 @@ function getBehaviorSettingsState( } } -const { saved, current, changes, saving, hasChanges, reset, save } = useSavable( - () => getBehaviorSettingsState(persistedSettings.value, persistedGlobalSyncedOptions.value), - async () => { - const value = current.value - +const settingsMutation = useMutation({ + mutationKey: appSettingsKeys.update, + scope: { id: 'app-settings' }, + mutationFn: async (value: BehaviorSettingsState) => { if (value.syncBehaviorAcrossDevices && auth.user.value) { await updatePreferences({ behavior: { minimize_app: value.minimizeApp, hide_right_sidebar: value.hideRightSidebar, - show_jump_in: value.showJumpIn, compact_instance_cards: value.compactInstanceCards, show_play_time: value.showPlayTime, hide_nametag: value.hideNametag, - show_all_screenshots: value.showAllScreenshots, warn_on_unknown_modpacks: value.warnOnUnknownModpacks, skip_non_essential_warnings: value.skipNonEssentialWarnings, }, }) } + const latestSettings = await get() const nextSettings: AppSettings = { - ...persistedSettings.value, + ...latestSettings, sync_behavior_across_devices: value.syncBehaviorAcrossDevices, hide_on_process_start: value.minimizeApp, toggle_sidebar: value.hideRightSidebar, hide_nametag_skins_page: value.hideNametag, feature_flags: { - ...persistedSettings.value.feature_flags, - [worldsInHomeFlag]: value.showJumpIn, + ...latestSettings.feature_flags, [compactInstanceCardsFlag]: value.compactInstanceCards, [showPlayTimeFlag]: value.showPlayTime, [skipUnknownPackWarningFlag]: !value.warnOnUnknownModpacks, @@ -231,29 +201,24 @@ const { saved, current, changes, saving, hasChanges, reset, save } = useSavable( }, } - const screenshotsChanged = - value.showAllScreenshots !== persistedGlobalSyncedOptions.value.screenshots - const [, updatedGlobalSyncedOptions] = await Promise.all([ - set(nextSettings), - screenshotsChanged - ? set_global_synced_option('screenshots', value.showAllScreenshots) - : Promise.resolve(persistedGlobalSyncedOptions.value), - ]) - persistedSettings.value = nextSettings - persistedGlobalSyncedOptions.value = updatedGlobalSyncedOptions - queryClient.setQueryData(['global-synced-options'], updatedGlobalSyncedOptions) - if (screenshotsChanged) { - await queryClient.invalidateQueries({ queryKey: screenshotKeys.all }) - } + await set(nextSettings) + queryClient.setQueryData(appSettingsKeys.all, nextSettings) appSettings.setBehaviorSyncAcrossDevices(value.syncBehaviorAcrossDevices) appSettings.toggleSidebar = value.hideRightSidebar appSettings.hideNametagSkinsPage = value.hideNametag - appSettings.featureFlags[worldsInHomeFlag] = value.showJumpIn appSettings.featureFlags[compactInstanceCardsFlag] = value.compactInstanceCards appSettings.featureFlags[showPlayTimeFlag] = value.showPlayTime appSettings.featureFlags[skipUnknownPackWarningFlag] = !value.warnOnUnknownModpacks appSettings.featureFlags[skipNonEssentialWarningsFlag] = value.skipNonEssentialWarnings }, + onMutate: () => queryClient.cancelQueries({ queryKey: appSettingsKeys.all }), + onError: handleError, + onSettled: () => queryClient.invalidateQueries({ queryKey: appSettingsKeys.all }), +}) + +const { saved, current, changes, saving, hasChanges, reset, save } = useSavable( + () => getBehaviorSettingsState(settingsQuery.data.value!), + () => settingsMutation.mutateAsync({ ...current.value }), ) async function saveBehaviorSettings(): Promise { @@ -341,30 +306,6 @@ onBeforeUnmount(() => { {{ formatMessage(messages.contentTitle) }}
-
-
-

- {{ formatMessage(messages.showAllScreenshotsTitle) }} -

-

- {{ formatMessage(messages.showAllScreenshotsDescription) }} -

-
- -
- -
-
-

- {{ formatMessage(messages.jumpBackIntoWorldsTitle) }} -

-

- {{ formatMessage(messages.jumpBackIntoWorldsDescription) }} -

-
- -
-

diff --git a/apps/app-frontend/src/components/ui/settings/display/FeaturesSettings.vue b/apps/app-frontend/src/components/ui/settings/display/FeaturesSettings.vue new file mode 100644 index 0000000000..1d3e379416 --- /dev/null +++ b/apps/app-frontend/src/components/ui/settings/display/FeaturesSettings.vue @@ -0,0 +1,406 @@ + + + diff --git a/apps/app-frontend/src/components/ui/settings/instances/InstancesSyncedSettings.vue b/apps/app-frontend/src/components/ui/settings/instances/InstancesSyncedSettings.vue deleted file mode 100644 index 548abba7af..0000000000 --- a/apps/app-frontend/src/components/ui/settings/instances/InstancesSyncedSettings.vue +++ /dev/null @@ -1,1027 +0,0 @@ - - - - - diff --git a/apps/app-frontend/src/components/ui/settings/instances/SyncSourceModal.vue b/apps/app-frontend/src/components/ui/settings/instances/SyncSourceModal.vue new file mode 100644 index 0000000000..739cf067e2 --- /dev/null +++ b/apps/app-frontend/src/components/ui/settings/instances/SyncSourceModal.vue @@ -0,0 +1,171 @@ + + + diff --git a/apps/app-frontend/src/components/ui/settings/instances/SyncedPacksModal.vue b/apps/app-frontend/src/components/ui/settings/instances/SyncedPacksModal.vue new file mode 100644 index 0000000000..f84c6073e0 --- /dev/null +++ b/apps/app-frontend/src/components/ui/settings/instances/SyncedPacksModal.vue @@ -0,0 +1,148 @@ + + + diff --git a/apps/app-frontend/src/components/ui/settings/instances/game-settings-modal/boolean-control.vue b/apps/app-frontend/src/components/ui/settings/instances/game-settings-modal/boolean-control.vue new file mode 100644 index 0000000000..7b29957745 --- /dev/null +++ b/apps/app-frontend/src/components/ui/settings/instances/game-settings-modal/boolean-control.vue @@ -0,0 +1,62 @@ + + + + + diff --git a/apps/app-frontend/src/components/ui/settings/instances/game-settings-modal/editors.ts b/apps/app-frontend/src/components/ui/settings/instances/game-settings-modal/editors.ts new file mode 100644 index 0000000000..b1aa518f93 --- /dev/null +++ b/apps/app-frontend/src/components/ui/settings/instances/game-settings-modal/editors.ts @@ -0,0 +1,151 @@ +import { toRaw } from 'vue' + +import type { + EditableGameSetting, + GameOptionCanonicalValue, + GameSettingChange, + GameSettingsEditorState, +} from '@/helpers/game-options' + +function clonePlainValue(value: unknown): unknown { + if (Array.isArray(value)) { + return toRaw(value).map(clonePlainValue) + } + if (value !== null && typeof value === 'object') { + return Object.fromEntries( + Object.entries(toRaw(value)).map(([key, nestedValue]) => [key, clonePlainValue(nestedValue)]), + ) + } + return value +} + +export function cloneGameSettingsState(state: GameSettingsEditorState): GameSettingsEditorState { + return clonePlainValue(state) as GameSettingsEditorState +} + +export function canonicalValuesEqual( + left: GameOptionCanonicalValue | null | undefined, + right: GameOptionCanonicalValue | null | undefined, +): boolean { + return JSON.stringify(left ?? null) === JSON.stringify(right ?? null) +} + +export function gameSettingChanges( + base: GameSettingsEditorState | null, + draft: GameSettingsEditorState | null, + forcedValueOptionIds: ReadonlySet = new Set(), +): GameSettingChange[] { + if (!base || !draft) return [] + + const baseSettings = new Map(base.settings.map((setting) => [setting.option_id, setting])) + return draft.settings.flatMap((setting) => { + const previous = baseSettings.get(setting.option_id) + if (!previous) return [] + + const syncChanged = previous.sync_enabled !== setting.sync_enabled + const valueChanged = !canonicalValuesEqual(previous.canonical_value, setting.canonical_value) + const promoteLocalValue = + forcedValueOptionIds.has(setting.option_id) && + previous.value_state !== 'canonical' && + setting.canonical_value !== null && + setting.canonical_value !== undefined + if (!syncChanged && !valueChanged && !promoteLocalValue) return [] + + return [ + { + option_id: setting.option_id, + base_option_revision: previous.option_revision, + ...(syncChanged ? { sync_enabled: setting.sync_enabled } : {}), + ...(valueChanged || promoteLocalValue + ? { canonical_value: setting.canonical_value ?? null } + : {}), + }, + ] + }) +} + +export function canonicalValueText(setting: EditableGameSetting): string { + const value = setting.canonical_value + if (!value) return '' + + switch (value.type) { + case 'bool': + return value.value ? 'true' : 'false' + case 'integer': + case 'decimal': + return setting.editor.unit === 'percent' + ? String(Number((Number(value.value) * 100).toFixed(8))) + : String(value.value) + case 'string_list': + return value.value.join(', ') + default: + return value.value + } +} + +export function canonicalBooleanValue(setting: EditableGameSetting): boolean | undefined { + return setting.canonical_value?.type === 'bool' ? setting.canonical_value.value : undefined +} + +export function canonicalValueFromInput( + setting: EditableGameSetting, + value: string | number | boolean | undefined, +): GameOptionCanonicalValue | null { + if (value === undefined || value === '') return null + + switch (setting.editor.type) { + case 'bool': + return { type: 'bool', value: Boolean(value) } + case 'integer': { + const parsed = Number(value) + return Number.isSafeInteger(parsed) ? { type: 'integer', value: parsed } : null + } + case 'decimal': { + const parsed = Number(value) + if (!Number.isFinite(parsed)) return null + return { + type: 'decimal', + value: + setting.editor.unit === 'percent' + ? String(Number((parsed / 100).toFixed(8))) + : String(value), + } + } + case 'enum': + return { type: 'enum', value: String(value) } + case 'string_list': + return { + type: 'string_list', + value: String(value) + .split(',') + .map((item) => item.trim()) + .filter(Boolean), + } + case 'key_binding': + return { type: 'key_binding', value: String(value) } + case 'external_raw': + return { type: 'external_raw', value: String(value) } + default: + return { type: 'text', value: String(value) } + } +} + +export function settingSearchText( + setting: EditableGameSetting, + label: string, + description: string, +): string { + return [label, description, setting.option_id, setting.raw_key] + .filter(Boolean) + .join(' ') + .toLocaleLowerCase() +} + +export function settingCanBeEnabled(setting: EditableGameSetting): boolean { + return ( + !setting.controlled && + !setting.validation_error && + !['mixed', 'unset', 'invalid'].includes(setting.value_state) && + (setting.compatibility.total_participating === 0 || setting.compatibility.will_receive > 0) + ) +} diff --git a/apps/app-frontend/src/components/ui/settings/instances/game-settings-modal/index.vue b/apps/app-frontend/src/components/ui/settings/instances/game-settings-modal/index.vue new file mode 100644 index 0000000000..d83bb9f22c --- /dev/null +++ b/apps/app-frontend/src/components/ui/settings/instances/game-settings-modal/index.vue @@ -0,0 +1,429 @@ + + + diff --git a/apps/app-frontend/src/components/ui/settings/instances/game-settings-modal/keybind-input.vue b/apps/app-frontend/src/components/ui/settings/instances/game-settings-modal/keybind-input.vue new file mode 100644 index 0000000000..bce6c3c5c7 --- /dev/null +++ b/apps/app-frontend/src/components/ui/settings/instances/game-settings-modal/keybind-input.vue @@ -0,0 +1,235 @@ + + + diff --git a/apps/app-frontend/src/components/ui/settings/instances/game-settings-modal/keybinds.ts b/apps/app-frontend/src/components/ui/settings/instances/game-settings-modal/keybinds.ts new file mode 100644 index 0000000000..ec64a326e4 --- /dev/null +++ b/apps/app-frontend/src/components/ui/settings/instances/game-settings-modal/keybinds.ts @@ -0,0 +1,426 @@ +import { defineMessages, type MessageDescriptor, type VIntlFormatters } from '@modrinth/ui' + +type FormatMessage = VIntlFormatters['formatMessage'] + +type StopKeybindRecording = () => void +let stopActiveKeybindRecording: StopKeybindRecording | null = null + +export function activateKeybindRecording(stop: StopKeybindRecording) { + if (stopActiveKeybindRecording === stop) return + const stopPrevious = stopActiveKeybindRecording + stopActiveKeybindRecording = stop + stopPrevious?.() +} + +export function deactivateKeybindRecording(stop: StopKeybindRecording) { + if (stopActiveKeybindRecording === stop) { + stopActiveKeybindRecording = null + } +} + +const keyMessages = defineMessages({ + unbound: { + id: 'app.settings.game-options.keybind.key.unbound', + defaultMessage: 'Unbound', + }, + escape: { + id: 'app.settings.game-options.keybind.key.escape', + defaultMessage: 'Escape', + }, + backspace: { + id: 'app.settings.game-options.keybind.key.backspace', + defaultMessage: 'Backspace', + }, + tab: { + id: 'app.settings.game-options.keybind.key.tab', + defaultMessage: 'Tab', + }, + enter: { + id: 'app.settings.game-options.keybind.key.enter', + defaultMessage: 'Enter', + }, + leftControl: { + id: 'app.settings.game-options.keybind.key.left-control', + defaultMessage: 'Left Ctrl', + }, + rightControl: { + id: 'app.settings.game-options.keybind.key.right-control', + defaultMessage: 'Right Ctrl', + }, + leftShift: { + id: 'app.settings.game-options.keybind.key.left-shift', + defaultMessage: 'Left Shift', + }, + rightShift: { + id: 'app.settings.game-options.keybind.key.right-shift', + defaultMessage: 'Right Shift', + }, + leftAlt: { + id: 'app.settings.game-options.keybind.key.left-alt', + defaultMessage: 'Left Alt', + }, + rightAlt: { + id: 'app.settings.game-options.keybind.key.right-alt', + defaultMessage: 'Right Alt', + }, + space: { + id: 'app.settings.game-options.keybind.key.space', + defaultMessage: 'Space', + }, + capsLock: { + id: 'app.settings.game-options.keybind.key.caps-lock', + defaultMessage: 'Caps Lock', + }, + numLock: { + id: 'app.settings.game-options.keybind.key.num-lock', + defaultMessage: 'Num Lock', + }, + scrollLock: { + id: 'app.settings.game-options.keybind.key.scroll-lock', + defaultMessage: 'Scroll Lock', + }, + printScreen: { + id: 'app.settings.game-options.keybind.key.print-screen', + defaultMessage: 'Print Screen', + }, + pause: { + id: 'app.settings.game-options.keybind.key.pause', + defaultMessage: 'Pause', + }, + home: { + id: 'app.settings.game-options.keybind.key.home', + defaultMessage: 'Home', + }, + end: { + id: 'app.settings.game-options.keybind.key.end', + defaultMessage: 'End', + }, + pageUp: { + id: 'app.settings.game-options.keybind.key.page-up', + defaultMessage: 'Page Up', + }, + pageDown: { + id: 'app.settings.game-options.keybind.key.page-down', + defaultMessage: 'Page Down', + }, + insert: { + id: 'app.settings.game-options.keybind.key.insert', + defaultMessage: 'Insert', + }, + delete: { + id: 'app.settings.game-options.keybind.key.delete', + defaultMessage: 'Delete', + }, + arrowUp: { + id: 'app.settings.game-options.keybind.key.arrow-up', + defaultMessage: 'Up Arrow', + }, + arrowDown: { + id: 'app.settings.game-options.keybind.key.arrow-down', + defaultMessage: 'Down Arrow', + }, + arrowLeft: { + id: 'app.settings.game-options.keybind.key.arrow-left', + defaultMessage: 'Left Arrow', + }, + arrowRight: { + id: 'app.settings.game-options.keybind.key.arrow-right', + defaultMessage: 'Right Arrow', + }, + leftSuper: { + id: 'app.settings.game-options.keybind.key.left-super', + defaultMessage: 'Left Super', + }, + rightSuper: { + id: 'app.settings.game-options.keybind.key.right-super', + defaultMessage: 'Right Super', + }, + leftCommand: { + id: 'app.settings.game-options.keybind.key.left-command', + defaultMessage: 'Left Command', + }, + rightCommand: { + id: 'app.settings.game-options.keybind.key.right-command', + defaultMessage: 'Right Command', + }, + menu: { + id: 'app.settings.game-options.keybind.key.menu', + defaultMessage: 'Menu', + }, + keypadKey: { + id: 'app.settings.game-options.keybind.key.keypad-key', + defaultMessage: 'Numpad {key}', + }, + leftMouse: { + id: 'app.settings.game-options.keybind.mouse.left', + defaultMessage: 'Left Mouse', + }, + rightMouse: { + id: 'app.settings.game-options.keybind.mouse.right', + defaultMessage: 'Right Mouse', + }, + middleMouse: { + id: 'app.settings.game-options.keybind.mouse.middle', + defaultMessage: 'Middle Mouse', + }, + mouseButton: { + id: 'app.settings.game-options.keybind.mouse.button', + defaultMessage: 'Mouse {button}', + }, + scancode: { + id: 'app.settings.game-options.keybind.scancode', + defaultMessage: 'Scancode {code}', + }, + unknownKey: { + id: 'app.settings.game-options.keybind.key.unknown', + defaultMessage: 'Unknown key', + }, + controlModifier: { + id: 'app.settings.game-options.keybind.modifier.control', + defaultMessage: 'Ctrl', + }, + shiftModifier: { + id: 'app.settings.game-options.keybind.modifier.shift', + defaultMessage: 'Shift', + }, + altModifier: { + id: 'app.settings.game-options.keybind.modifier.alt', + defaultMessage: 'Alt', + }, + commandModifier: { + id: 'app.settings.game-options.keybind.modifier.command', + defaultMessage: 'Command', + }, + superModifier: { + id: 'app.settings.game-options.keybind.modifier.super', + defaultMessage: 'Super', + }, +}) + +const keyboardCodeTokens: Record = { + Escape: 'escape', + Minus: 'minus', + Equal: 'equal', + Backspace: 'backspace', + Tab: 'tab', + BracketLeft: 'left.bracket', + BracketRight: 'right.bracket', + Enter: 'enter', + ControlLeft: 'left.control', + Semicolon: 'semicolon', + Quote: 'apostrophe', + Backquote: 'grave.accent', + ShiftLeft: 'left.shift', + Backslash: 'backslash', + Comma: 'comma', + Period: 'period', + Slash: 'slash', + ShiftRight: 'right.shift', + AltLeft: 'left.alt', + Space: 'space', + CapsLock: 'caps.lock', + NumLock: 'num.lock', + ScrollLock: 'scroll.lock', + NumpadSubtract: 'keypad.subtract', + NumpadAdd: 'keypad.add', + NumpadDecimal: 'keypad.decimal', + NumpadComma: 'keypad.decimal', + NumpadEqual: 'keypad.equal', + NumpadEnter: 'keypad.enter', + ControlRight: 'right.control', + NumpadDivide: 'keypad.divide', + NumpadMultiply: 'keypad.multiply', + PrintScreen: 'print.screen', + AltRight: 'right.alt', + Pause: 'pause', + Home: 'home', + ArrowUp: 'up', + PageUp: 'page.up', + ArrowLeft: 'left', + ArrowRight: 'right', + End: 'end', + ArrowDown: 'down', + PageDown: 'page.down', + Insert: 'insert', + Delete: 'delete', + MetaLeft: 'left.win', + MetaRight: 'right.win', + ContextMenu: 'menu', +} + +const keyboardLabels: Record = { + escape: keyMessages.escape, + minus: '-', + equal: '=', + backspace: keyMessages.backspace, + tab: keyMessages.tab, + 'left.bracket': '[', + 'right.bracket': ']', + enter: keyMessages.enter, + 'left.control': keyMessages.leftControl, + semicolon: ';', + apostrophe: "'", + 'grave.accent': '`', + 'left.shift': keyMessages.leftShift, + backslash: '\\', + comma: ',', + period: '.', + slash: '/', + 'right.shift': keyMessages.rightShift, + 'left.alt': keyMessages.leftAlt, + space: keyMessages.space, + 'caps.lock': keyMessages.capsLock, + 'num.lock': keyMessages.numLock, + 'scroll.lock': keyMessages.scrollLock, + 'keypad.subtract': '-', + 'keypad.add': '+', + 'keypad.decimal': '.', + 'keypad.equal': '=', + 'keypad.enter': keyMessages.enter, + 'right.control': keyMessages.rightControl, + 'keypad.divide': '/', + 'keypad.multiply': '*', + 'print.screen': keyMessages.printScreen, + 'right.alt': keyMessages.rightAlt, + pause: keyMessages.pause, + home: keyMessages.home, + up: keyMessages.arrowUp, + 'page.up': keyMessages.pageUp, + left: keyMessages.arrowLeft, + right: keyMessages.arrowRight, + end: keyMessages.end, + down: keyMessages.arrowDown, + 'page.down': keyMessages.pageDown, + insert: keyMessages.insert, + delete: keyMessages.delete, + menu: keyMessages.menu, +} + +function splitKeybind(value: string): [key: string, modifier?: string] { + const separator = value.indexOf(':') + return separator === -1 ? [value] : [value.slice(0, separator), value.slice(separator + 1)] +} + +function keyboardIdentifierFromCode(code: string): string | null { + const letter = /^Key([A-Z])$/.exec(code) + if (letter) return letter[1].toLowerCase() + + const digit = /^Digit([0-9])$/.exec(code) + if (digit) return digit[1] + + const functionKey = /^F([1-9]|1[0-9]|2[0-5])$/.exec(code) + if (functionKey) return `f${functionKey[1]}` + + const keypadDigit = /^Numpad([0-9])$/.exec(code) + if (keypadDigit) return `keypad.${keypadDigit[1]}` + + return keyboardCodeTokens[code] ?? null +} + +export function minecraftKeyTokenFromKeyboardEvent(event: KeyboardEvent): string | null { + const identifier = keyboardIdentifierFromCode(event.code) + return identifier ? `key.keyboard.${identifier}` : null +} + +export function minecraftMouseTokenFromButton(button: number): string | null { + if (!Number.isInteger(button) || button < 0 || button > 15) return null + if (button === 0) return 'key.mouse.left' + if (button === 1) return 'key.mouse.middle' + if (button === 2) return 'key.mouse.right' + return `key.mouse.${button + 1}` +} + +function formatKeyboardLabel( + formatMessage: FormatMessage, + identifier: string, + isMac: boolean, +): string { + if (identifier === 'unknown') return formatMessage(keyMessages.unbound) + if (/^[a-z]$/.test(identifier)) return identifier.toUpperCase() + if (/^[0-9]$/.test(identifier)) return identifier + if (/^f([1-9]|1[0-9]|2[0-5])$/.test(identifier)) return identifier.toUpperCase() + + const keypad = /^keypad\.(.+)$/.exec(identifier) + if (keypad) { + const label = keyboardLabels[identifier] + const key = typeof label === 'string' ? label : label ? formatMessage(label) : keypad[1] + return formatMessage(keyMessages.keypadKey, { key }) + } + + if (identifier === 'left.win') { + return formatMessage(isMac ? keyMessages.leftCommand : keyMessages.leftSuper) + } + if (identifier === 'right.win') { + return formatMessage(isMac ? keyMessages.rightCommand : keyMessages.rightSuper) + } + + const label = keyboardLabels[identifier] + if (typeof label === 'string') return label + if (label) return formatMessage(label) + return identifier + .split('.') + .map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`) + .join(' ') +} + +function formatModifier(formatMessage: FormatMessage, modifier: string, isMac: boolean): string { + switch (modifier.toUpperCase()) { + case 'CONTROL': + case 'CTRL': + return formatMessage(keyMessages.controlModifier) + case 'SHIFT': + return formatMessage(keyMessages.shiftModifier) + case 'ALT': + return formatMessage(keyMessages.altModifier) + case 'META': + case 'SUPER': + return formatMessage(isMac ? keyMessages.commandModifier : keyMessages.superModifier) + default: + return modifier + } +} + +export function formatMinecraftKeybind( + formatMessage: FormatMessage, + value: string, + isMac: boolean, +): string { + const [key, modifier] = splitKeybind(value) + let label: string + + if (key.startsWith('key.keyboard.')) { + label = formatKeyboardLabel(formatMessage, key.slice('key.keyboard.'.length), isMac) + } else if (key === 'key.mouse.left') { + label = formatMessage(keyMessages.leftMouse) + } else if (key === 'key.mouse.right') { + label = formatMessage(keyMessages.rightMouse) + } else if (key === 'key.mouse.middle') { + label = formatMessage(keyMessages.middleMouse) + } else if (key.startsWith('key.mouse.')) { + label = formatMessage(keyMessages.mouseButton, { + button: key.slice('key.mouse.'.length), + }) + } else if (key.startsWith('scancode.')) { + label = formatMessage(keyMessages.scancode, { code: key.slice('scancode.'.length) }) + } else { + label = formatMessage(keyMessages.unknownKey) + } + + if (!modifier || key === 'key.keyboard.unknown') return label + const modifiers = modifier + .split(/[+:]/) + .filter(Boolean) + .map((part) => formatModifier(formatMessage, part, isMac)) + return [...modifiers, label].join(' + ') +} + +export function minecraftKeybindConflictKey(optionId: string, value: string): string | null { + const normalized = value.trim().toLowerCase() + if (!normalized || normalized === 'key.keyboard.unknown') return null + + if (optionId === 'key.debug.modifier') return null + if (optionId.startsWith('key.debug.') && optionId !== 'key.debug.overlay') { + return `debug:${normalized}` + } + return `direct:${normalized}` +} diff --git a/apps/app-frontend/src/components/ui/settings/instances/game-settings-modal/messages.ts b/apps/app-frontend/src/components/ui/settings/instances/game-settings-modal/messages.ts new file mode 100644 index 0000000000..a5e741b63a --- /dev/null +++ b/apps/app-frontend/src/components/ui/settings/instances/game-settings-modal/messages.ts @@ -0,0 +1,1428 @@ +import { defineMessages, type MessageDescriptor, type VIntlFormatters } from '@modrinth/ui' + +import type { + EditableGameSetting, + GameOptionValidationError, + GameSettingCategory, +} from '@/helpers/game-options' + +type FormatMessage = VIntlFormatters['formatMessage'] + +const settingMessages = defineMessages({ + fovLabel: { id: 'app.settings.game-options.setting.fov.label', defaultMessage: 'Field of view' }, + graphicsLabel: { + id: 'app.settings.game-options.setting.graphics.label', + defaultMessage: 'Graphics', + }, + graphicsDescription: { + id: 'app.settings.game-options.setting.graphics.description', + defaultMessage: 'Controls visual quality and performance.', + }, + ambientOcclusionLabel: { + id: 'app.settings.game-options.setting.ambient-occlusion.label', + defaultMessage: 'Smooth lighting', + }, + renderDistanceLabel: { + id: 'app.settings.game-options.setting.render-distance.label', + defaultMessage: 'Render distance', + }, + simulationDistanceLabel: { + id: 'app.settings.game-options.setting.simulation-distance.label', + defaultMessage: 'Simulation distance', + }, + simulationDistanceDescription: { + id: 'app.settings.game-options.setting.simulation-distance.description', + defaultMessage: 'How far away entities update and blocks and fluids tick.', + }, + guiScaleLabel: { + id: 'app.settings.game-options.setting.gui-scale.label', + defaultMessage: 'GUI scale', + }, + guiScaleDescription: { + id: 'app.settings.game-options.setting.gui-scale.description', + defaultMessage: 'The size of the game interface and HUD.', + }, + particlesLabel: { + id: 'app.settings.game-options.setting.particles.label', + defaultMessage: 'Particles', + }, + cloudsLabel: { id: 'app.settings.game-options.setting.clouds.label', defaultMessage: 'Clouds' }, + entityShadowsLabel: { + id: 'app.settings.game-options.setting.entity-shadows.label', + defaultMessage: 'Entity shadows', + }, + viewBobbingLabel: { + id: 'app.settings.game-options.setting.view-bobbing.label', + defaultMessage: 'View bobbing', + }, + viewBobbingDescription: { + id: 'app.settings.game-options.setting.view-bobbing.description', + defaultMessage: 'Add a bobbing motion to the camera while walking.', + }, + vsyncLabel: { id: 'app.settings.game-options.setting.vsync.label', defaultMessage: 'VSync' }, + vsyncDescription: { + id: 'app.settings.game-options.setting.vsync.description', + defaultMessage: 'Limit the frame rate to the display refresh rate to prevent screen tearing.', + }, + fullscreenLabel: { + id: 'app.settings.game-options.setting.fullscreen.label', + defaultMessage: 'Fullscreen', + }, + maxFramerateLabel: { + id: 'app.settings.game-options.setting.max-framerate.label', + defaultMessage: 'Maximum framerate', + }, + mipmapLevelsLabel: { + id: 'app.settings.game-options.setting.mipmap-levels.label', + defaultMessage: 'Mipmap levels', + }, + mipmapLevelsDescription: { + id: 'app.settings.game-options.setting.mipmap-levels.description', + defaultMessage: 'Texture smoothing at a distance.', + }, + biomeBlendRadiusLabel: { + id: 'app.settings.game-options.setting.biome-blend-radius.label', + defaultMessage: 'Biome blend', + }, + biomeBlendRadiusDescription: { + id: 'app.settings.game-options.setting.biome-blend-radius.description', + defaultMessage: 'The distance over which biome colors transition.', + }, + languageLabel: { + id: 'app.settings.game-options.setting.language.label', + defaultMessage: 'Language', + }, + masterVolumeLabel: { + id: 'app.settings.game-options.setting.master-volume.label', + defaultMessage: 'Master volume', + }, + musicVolumeLabel: { + id: 'app.settings.game-options.setting.music-volume.label', + defaultMessage: 'Music', + }, + musicToastLabel: { + id: 'app.settings.game-options.setting.music-toast.label', + defaultMessage: 'Music notification', + }, + musicToastDescription: { + id: 'app.settings.game-options.setting.music-toast.description', + defaultMessage: 'Choose whether music titles appear in the pause menu and as toasts.', + }, + recordVolumeLabel: { + id: 'app.settings.game-options.setting.record-volume.label', + defaultMessage: 'Jukebox/Note Blocks', + }, + weatherVolumeLabel: { + id: 'app.settings.game-options.setting.weather-volume.label', + defaultMessage: 'Weather', + }, + blocksVolumeLabel: { + id: 'app.settings.game-options.setting.blocks-volume.label', + defaultMessage: 'Blocks', + }, + hostileVolumeLabel: { + id: 'app.settings.game-options.setting.hostile-volume.label', + defaultMessage: 'Hostile creatures', + }, + neutralVolumeLabel: { + id: 'app.settings.game-options.setting.neutral-volume.label', + defaultMessage: 'Friendly creatures', + }, + playersVolumeLabel: { + id: 'app.settings.game-options.setting.players-volume.label', + defaultMessage: 'Players', + }, + ambientVolumeLabel: { + id: 'app.settings.game-options.setting.ambient-volume.label', + defaultMessage: 'Ambient/Environment', + }, + voiceVolumeLabel: { + id: 'app.settings.game-options.setting.voice-volume.label', + defaultMessage: 'Voice and speech', + }, + uiVolumeLabel: { + id: 'app.settings.game-options.setting.ui-volume.label', + defaultMessage: 'UI', + }, + sensitivityLabel: { + id: 'app.settings.game-options.setting.sensitivity.label', + defaultMessage: 'Mouse sensitivity', + }, + invertMouseLabel: { + id: 'app.settings.game-options.setting.invert-mouse.label', + defaultMessage: 'Invert mouse', + }, + invertMouseDescription: { + id: 'app.settings.game-options.setting.invert-mouse.description', + defaultMessage: 'Invert vertical mouse movement.', + }, + autoJumpLabel: { + id: 'app.settings.game-options.setting.auto-jump.label', + defaultMessage: 'Auto-jump', + }, + autoJumpDescription: { + id: 'app.settings.game-options.setting.auto-jump.description', + defaultMessage: 'Automatically jump up one-block-high obstacles.', + }, + toggleCrouchLabel: { + id: 'app.settings.game-options.setting.toggle-crouch.label', + defaultMessage: 'Toggle crouch', + }, + toggleCrouchDescription: { + id: 'app.settings.game-options.setting.toggle-crouch.description', + defaultMessage: 'Press once to remain crouched.', + }, + toggleSprintLabel: { + id: 'app.settings.game-options.setting.toggle-sprint.label', + defaultMessage: 'Toggle sprint', + }, + toggleSprintDescription: { + id: 'app.settings.game-options.setting.toggle-sprint.description', + defaultMessage: 'Press once to remain sprinting.', + }, + discreteMouseScrollLabel: { + id: 'app.settings.game-options.setting.discrete-mouse-scroll.label', + defaultMessage: 'Discrete scrolling', + }, + discreteMouseScrollDescription: { + id: 'app.settings.game-options.setting.discrete-mouse-scroll.description', + defaultMessage: 'Treat each mouse-wheel input as a single scroll step.', + }, + keyForwardLabel: { + id: 'app.settings.game-options.setting.key-forward.label', + defaultMessage: 'Move forward', + }, + keyLeftLabel: { + id: 'app.settings.game-options.setting.key-left.label', + defaultMessage: 'Strafe left', + }, + keyBackLabel: { + id: 'app.settings.game-options.setting.key-back.label', + defaultMessage: 'Move backward', + }, + keyRightLabel: { + id: 'app.settings.game-options.setting.key-right.label', + defaultMessage: 'Strafe right', + }, + keyJumpLabel: { id: 'app.settings.game-options.setting.key-jump.label', defaultMessage: 'Jump' }, + keySneakLabel: { + id: 'app.settings.game-options.setting.key-sneak.label', + defaultMessage: 'Sneak', + }, + keySprintLabel: { + id: 'app.settings.game-options.setting.key-sprint.label', + defaultMessage: 'Sprint', + }, + keyInventoryLabel: { + id: 'app.settings.game-options.setting.key-inventory.label', + defaultMessage: 'Inventory', + }, + keySwapOffhandLabel: { + id: 'app.settings.game-options.setting.key-swap-offhand.label', + defaultMessage: 'Swap offhand', + }, + keyDropLabel: { + id: 'app.settings.game-options.setting.key-drop.label', + defaultMessage: 'Drop item', + }, + keyUseLabel: { + id: 'app.settings.game-options.setting.key-use.label', + defaultMessage: 'Use item', + }, + keyAttackLabel: { + id: 'app.settings.game-options.setting.key-attack.label', + defaultMessage: 'Attack', + }, + keyPickItemLabel: { + id: 'app.settings.game-options.setting.key-pick-item.label', + defaultMessage: 'Pick block', + }, + keyChatLabel: { + id: 'app.settings.game-options.setting.key-chat.label', + defaultMessage: 'Open chat', + }, + keyPlayerListLabel: { + id: 'app.settings.game-options.setting.key-player-list.label', + defaultMessage: 'Player list', + }, + keyCommandLabel: { + id: 'app.settings.game-options.setting.key-command.label', + defaultMessage: 'Command', + }, + keyScreenshotLabel: { + id: 'app.settings.game-options.setting.key-screenshot.label', + defaultMessage: 'Screenshot', + }, + keyPerspectiveLabel: { + id: 'app.settings.game-options.setting.key-perspective.label', + defaultMessage: 'Change perspective', + }, + keyFullscreenLabel: { + id: 'app.settings.game-options.setting.key-fullscreen.label', + defaultMessage: 'Toggle fullscreen', + }, + keyAdvancementsLabel: { + id: 'app.settings.game-options.setting.key-advancements.label', + defaultMessage: 'Advancements', + }, + chatVisibilityLabel: { + id: 'app.settings.game-options.setting.chat-visibility.label', + defaultMessage: 'Chat visibility', + }, + chatColorsLabel: { + id: 'app.settings.game-options.setting.chat-colors.label', + defaultMessage: 'Chat colors', + }, + chatLinksLabel: { + id: 'app.settings.game-options.setting.chat-links.label', + defaultMessage: 'Web links', + }, + chatLinksDescription: { + id: 'app.settings.game-options.setting.chat-links.description', + defaultMessage: 'Allow web links in chat to be opened.', + }, + chatLinksPromptLabel: { + id: 'app.settings.game-options.setting.chat-links-prompt.label', + defaultMessage: 'Prompt on links', + }, + chatLinksPromptDescription: { + id: 'app.settings.game-options.setting.chat-links-prompt.description', + defaultMessage: 'Ask before opening links from chat.', + }, + chatOpacityLabel: { + id: 'app.settings.game-options.setting.chat-opacity.label', + defaultMessage: 'Chat opacity', + }, + chatOpacityDescription: { + id: 'app.settings.game-options.setting.chat-opacity.description', + defaultMessage: 'The opacity of chat text.', + }, + chatScaleLabel: { + id: 'app.settings.game-options.setting.chat-scale.label', + defaultMessage: 'Chat scale', + }, + narratorLabel: { + id: 'app.settings.game-options.setting.narrator.label', + defaultMessage: 'Narrator', + }, + narratorDescription: { + id: 'app.settings.game-options.setting.narrator.description', + defaultMessage: 'Choose what the narrator reads.', + }, + subtitlesLabel: { + id: 'app.settings.game-options.setting.subtitles.label', + defaultMessage: 'Subtitles', + }, + subtitlesDescription: { + id: 'app.settings.game-options.setting.subtitles.description', + defaultMessage: 'Show captions for sounds played in the game.', + }, + highContrastLabel: { + id: 'app.settings.game-options.setting.high-contrast.label', + defaultMessage: 'High contrast', + }, + highContrastDescription: { + id: 'app.settings.game-options.setting.high-contrast.description', + defaultMessage: 'Enhance the contrast of interface elements.', + }, + darkSplashLabel: { + id: 'app.settings.game-options.setting.dark-splash.label', + defaultMessage: 'Monochrome logo', + }, + darkSplashDescription: { + id: 'app.settings.game-options.setting.dark-splash.description', + defaultMessage: 'Change the Mojang Studios loading screen from red to black.', + }, + notificationTimeLabel: { + id: 'app.settings.game-options.setting.notification-time.label', + defaultMessage: 'Notification time', + }, + notificationTimeDescription: { + id: 'app.settings.game-options.setting.notification-time.description', + defaultMessage: 'How long toast notifications remain visible.', + }, + mainHandLabel: { + id: 'app.settings.game-options.setting.main-hand.label', + defaultMessage: 'Main hand', + }, + mainHandDescription: { + id: 'app.settings.game-options.setting.main-hand.description', + defaultMessage: 'Choose whether the main hand is left or right.', + }, + capeLabel: { id: 'app.settings.game-options.setting.cape.label', defaultMessage: 'Cape' }, + capeDescription: { + id: 'app.settings.game-options.setting.cape.description', + defaultMessage: "Show the player's cape, including its elytra texture.", + }, + hatLabel: { id: 'app.settings.game-options.setting.hat.label', defaultMessage: 'Hat' }, + hatDescription: { + id: 'app.settings.game-options.setting.hat.description', + defaultMessage: 'Show the hat skin layer.', + }, + jacketLabel: { id: 'app.settings.game-options.setting.jacket.label', defaultMessage: 'Jacket' }, + jacketDescription: { + id: 'app.settings.game-options.setting.jacket.description', + defaultMessage: 'Show the jacket skin layer.', + }, + allowServerListingLabel: { + id: 'app.settings.game-options.setting.allow-server-listing.label', + defaultMessage: 'Server listings', + }, + allowServerListingDescription: { + id: 'app.settings.game-options.setting.allow-server-listing.description', + defaultMessage: "Allow the player's name to appear in server listings.", + }, + realmsNotificationsLabel: { + id: 'app.settings.game-options.setting.realms-notifications.label', + defaultMessage: 'Realms notifications', + }, +}) + +const catalogSettingMessages = defineMessages({ + brightnessLabel: { + id: 'app.settings.game-options.setting.brightness.label', + defaultMessage: 'Brightness', + }, + legacyViewDistanceLabel: { + id: 'app.settings.game-options.setting.legacy-view-distance.label', + defaultMessage: 'View distance', + }, + entityDistanceLabel: { + id: 'app.settings.game-options.setting.entity-distance.label', + defaultMessage: 'Entity distance', + }, + debugGuiScaleLabel: { + id: 'app.settings.game-options.setting.debug-gui-scale.label', + defaultMessage: 'Debug GUI scale', + }, + graphicsBackendLabel: { + id: 'app.settings.game-options.setting.graphics-backend.label', + defaultMessage: 'Graphics backend', + }, + cloudRangeLabel: { + id: 'app.settings.game-options.setting.cloud-range.label', + defaultMessage: 'Cloud distance', + }, + exclusiveFullscreenLabel: { + id: 'app.settings.game-options.setting.exclusive-fullscreen.label', + defaultMessage: 'Exclusive fullscreen', + }, + macFullscreenMenuLabel: { + id: 'app.settings.game-options.setting.mac-fullscreen-menu.label', + defaultMessage: 'Show macOS menu in fullscreen', + }, + legacyFramerateLimitLabel: { + id: 'app.settings.game-options.setting.legacy-framerate-limit.label', + defaultMessage: 'Framerate limit', + }, + inactivityFramerateLimitLabel: { + id: 'app.settings.game-options.setting.inactivity-framerate-limit.label', + defaultMessage: 'Reduced framerate', + }, + prioritizeChunkUpdatesLabel: { + id: 'app.settings.game-options.setting.prioritize-chunk-updates.label', + defaultMessage: 'Prioritize chunk updates', + }, + attackIndicatorLabel: { + id: 'app.settings.game-options.setting.attack-indicator.label', + defaultMessage: 'Attack indicator', + }, + reducedDebugInfoLabel: { + id: 'app.settings.game-options.setting.reduced-debug-info.label', + defaultMessage: 'Reduced debug information', + }, + chunkFadeTimeLabel: { + id: 'app.settings.game-options.setting.chunk-fade-time.label', + defaultMessage: 'Chunk fade time', + }, + cutoutLeavesLabel: { + id: 'app.settings.game-options.setting.cutout-leaves.label', + defaultMessage: 'Cutout leaves', + }, + improvedTransparencyLabel: { + id: 'app.settings.game-options.setting.improved-transparency.label', + defaultMessage: 'Improved transparency', + }, + textureFilteringLabel: { + id: 'app.settings.game-options.setting.texture-filtering.label', + defaultMessage: 'Texture filtering', + }, + anisotropyLabel: { + id: 'app.settings.game-options.setting.anisotropy.label', + defaultMessage: 'Anisotropy', + }, + vignetteLabel: { + id: 'app.settings.game-options.setting.vignette.label', + defaultMessage: 'Vignette', + }, + weatherRadiusLabel: { + id: 'app.settings.game-options.setting.weather-radius.label', + defaultMessage: 'Weather radius', + }, + advancedOpenGlLabel: { + id: 'app.settings.game-options.setting.advanced-opengl.label', + defaultMessage: 'Advanced OpenGL', + }, + anaglyph3dLabel: { + id: 'app.settings.game-options.setting.anaglyph-3d.label', + defaultMessage: '3D anaglyph', + }, + anisotropicFilteringLabel: { + id: 'app.settings.game-options.setting.anisotropic-filtering.label', + defaultMessage: 'Anisotropic filtering', + }, + alternateBlocksLabel: { + id: 'app.settings.game-options.setting.alternate-blocks.label', + defaultMessage: 'Alternate blocks', + }, + heldItemTooltipsLabel: { + id: 'app.settings.game-options.setting.held-item-tooltips.label', + defaultMessage: 'Held item tooltips', + }, + useVboLabel: { + id: 'app.settings.game-options.setting.use-vbo.label', + defaultMessage: 'Use VBOs', + }, + forceUnicodeFontLabel: { + id: 'app.settings.game-options.setting.force-unicode-font.label', + defaultMessage: 'Force Unicode font', + }, + japaneseGlyphVariantsLabel: { + id: 'app.settings.game-options.setting.japanese-glyph-variants.label', + defaultMessage: 'Japanese glyph variants', + }, + musicFrequencyLabel: { + id: 'app.settings.game-options.setting.music-frequency.label', + defaultMessage: 'Music frequency', + }, + directionalAudioLabel: { + id: 'app.settings.game-options.setting.directional-audio.label', + defaultMessage: 'Directional audio', + }, + invertHorizontalMouseLabel: { + id: 'app.settings.game-options.setting.invert-horizontal-mouse.label', + defaultMessage: 'Invert horizontal mouse', + }, + toggleAttackLabel: { + id: 'app.settings.game-options.setting.toggle-attack.label', + defaultMessage: 'Toggle attack', + }, + toggleUseLabel: { + id: 'app.settings.game-options.setting.toggle-use.label', + defaultMessage: 'Toggle use', + }, + mouseWheelSensitivityLabel: { + id: 'app.settings.game-options.setting.mouse-wheel-sensitivity.label', + defaultMessage: 'Mouse wheel sensitivity', + }, + rawMouseInputLabel: { + id: 'app.settings.game-options.setting.raw-mouse-input.label', + defaultMessage: 'Raw mouse input', + }, + touchscreenLabel: { + id: 'app.settings.game-options.setting.touchscreen.label', + defaultMessage: 'Touchscreen mode', + }, + allowCursorChangesLabel: { + id: 'app.settings.game-options.setting.allow-cursor-changes.label', + defaultMessage: 'Allow cursor changes', + }, + sprintWindowLabel: { + id: 'app.settings.game-options.setting.sprint-window.label', + defaultMessage: 'Sprint window', + }, + operatorItemsTabLabel: { + id: 'app.settings.game-options.setting.operator-items-tab.label', + defaultMessage: 'Operator items tab', + }, + ctrlClickRightClickLabel: { + id: 'app.settings.game-options.setting.ctrl-click-right-click.label', + defaultMessage: 'Control-click as right-click', + }, + quitShortcutsLabel: { + id: 'app.settings.game-options.setting.quit-shortcuts.label', + defaultMessage: 'Quit shortcuts', + }, + chatWidthLabel: { + id: 'app.settings.game-options.setting.chat-width.label', + defaultMessage: 'Chat width', + }, + focusedChatHeightLabel: { + id: 'app.settings.game-options.setting.focused-chat-height.label', + defaultMessage: 'Focused chat height', + }, + unfocusedChatHeightLabel: { + id: 'app.settings.game-options.setting.unfocused-chat-height.label', + defaultMessage: 'Unfocused chat height', + }, + chatLineSpacingLabel: { + id: 'app.settings.game-options.setting.chat-line-spacing.label', + defaultMessage: 'Chat line spacing', + }, + chatDelayLabel: { + id: 'app.settings.game-options.setting.chat-delay.label', + defaultMessage: 'Chat delay', + }, + textBackgroundOpacityLabel: { + id: 'app.settings.game-options.setting.text-background-opacity.label', + defaultMessage: 'Text background opacity', + }, + chatBackgroundOnlyLabel: { + id: 'app.settings.game-options.setting.chat-background-only.label', + defaultMessage: 'Chat background only', + }, + autoSuggestionsLabel: { + id: 'app.settings.game-options.setting.auto-suggestions.label', + defaultMessage: 'Command suggestions', + }, + secureChatOnlyLabel: { + id: 'app.settings.game-options.setting.secure-chat-only.label', + defaultMessage: 'Only show secure chat', + }, + saveChatDraftsLabel: { + id: 'app.settings.game-options.setting.save-chat-drafts.label', + defaultMessage: 'Save chat drafts', + }, + hideMatchedNamesLabel: { + id: 'app.settings.game-options.setting.hide-matched-names.label', + defaultMessage: 'Hide matched names', + }, + chatPreviewLabel: { + id: 'app.settings.game-options.setting.chat-preview.label', + defaultMessage: 'Chat preview', + }, + fovEffectsLabel: { + id: 'app.settings.game-options.setting.fov-effects.label', + defaultMessage: 'FOV effects', + }, + screenEffectsLabel: { + id: 'app.settings.game-options.setting.screen-effects.label', + defaultMessage: 'Screen effects', + }, + darknessPulsingLabel: { + id: 'app.settings.game-options.setting.darkness-pulsing.label', + defaultMessage: 'Darkness pulsing', + }, + damageTiltLabel: { + id: 'app.settings.game-options.setting.damage-tilt.label', + defaultMessage: 'Damage tilt', + }, + glintSpeedLabel: { + id: 'app.settings.game-options.setting.glint-speed.label', + defaultMessage: 'Glint speed', + }, + glintStrengthLabel: { + id: 'app.settings.game-options.setting.glint-strength.label', + defaultMessage: 'Glint strength', + }, + hideLightningFlashesLabel: { + id: 'app.settings.game-options.setting.hide-lightning-flashes.label', + defaultMessage: 'Hide lightning flashes', + }, + hideSplashTextsLabel: { + id: 'app.settings.game-options.setting.hide-splash-texts.label', + defaultMessage: 'Hide splash texts', + }, + highContrastOutlineLabel: { + id: 'app.settings.game-options.setting.high-contrast-outline.label', + defaultMessage: 'High contrast block outline', + }, + narratorHotkeyLabel: { + id: 'app.settings.game-options.setting.narrator-hotkey.label', + defaultMessage: 'Narrator hotkey', + }, + autosaveIndicatorLabel: { + id: 'app.settings.game-options.setting.autosave-indicator.label', + defaultMessage: 'Autosave indicator', + }, + panoramaSpeedLabel: { + id: 'app.settings.game-options.setting.panorama-speed.label', + defaultMessage: 'Panorama speed', + }, + menuBackgroundBlurLabel: { + id: 'app.settings.game-options.setting.menu-background-blur.label', + defaultMessage: 'Menu background blur', + }, + rotateWithMinecartLabel: { + id: 'app.settings.game-options.setting.rotate-with-minecart.label', + defaultMessage: 'Rotate with minecart', + }, + leftSleeveLabel: { + id: 'app.settings.game-options.setting.left-sleeve.label', + defaultMessage: 'Left sleeve', + }, + rightSleeveLabel: { + id: 'app.settings.game-options.setting.right-sleeve.label', + defaultMessage: 'Right sleeve', + }, + leftPantsLegLabel: { + id: 'app.settings.game-options.setting.left-pants-leg.label', + defaultMessage: 'Left pants leg', + }, + rightPantsLegLabel: { + id: 'app.settings.game-options.setting.right-pants-leg.label', + defaultMessage: 'Right pants leg', + }, + hideServerAddressLabel: { + id: 'app.settings.game-options.setting.hide-server-address.label', + defaultMessage: 'Hide server address', + }, + serverTexturesLabel: { + id: 'app.settings.game-options.setting.server-textures.label', + defaultMessage: 'Server textures', + }, + snooperLabel: { + id: 'app.settings.game-options.setting.snooper.label', + defaultMessage: 'Snooper', + }, + extraTelemetryLabel: { + id: 'app.settings.game-options.setting.extra-telemetry.label', + defaultMessage: 'Optional telemetry', + }, + inGameNotificationsLabel: { + id: 'app.settings.game-options.setting.in-game-notifications.label', + defaultMessage: 'In-game notifications', + }, + sharePresenceLabel: { + id: 'app.settings.game-options.setting.share-presence.label', + defaultMessage: 'Share presence', + }, +}) + +const catalogKeyMessages = defineMessages({ + smoothCameraLabel: { + id: 'app.settings.game-options.setting.key-smooth-camera.label', + defaultMessage: 'Toggle cinematic camera', + }, + spectatorOutlinesLabel: { + id: 'app.settings.game-options.setting.key-spectator-outlines.label', + defaultMessage: 'Highlight spectators', + }, + saveToolbarLabel: { + id: 'app.settings.game-options.setting.key-save-toolbar.label', + defaultMessage: 'Save toolbar', + }, + loadToolbarLabel: { + id: 'app.settings.game-options.setting.key-load-toolbar.label', + defaultMessage: 'Load toolbar', + }, + socialInteractionsLabel: { + id: 'app.settings.game-options.setting.key-social-interactions.label', + defaultMessage: 'Social interactions', + }, + quickActionsLabel: { + id: 'app.settings.game-options.setting.key-quick-actions.label', + defaultMessage: 'Quick actions', + }, + spectatorHotbarLabel: { + id: 'app.settings.game-options.setting.key-spectator-hotbar.label', + defaultMessage: 'Spectator hotbar', + }, + friendsLabel: { + id: 'app.settings.game-options.setting.key-friends.label', + defaultMessage: 'Friends', + }, + toggleGuiLabel: { + id: 'app.settings.game-options.setting.key-toggle-gui.label', + defaultMessage: 'Toggle HUD', + }, + toggleSpectatorShaderLabel: { + id: 'app.settings.game-options.setting.key-toggle-spectator-shader.label', + defaultMessage: 'Toggle spectator shader', + }, + hotbar1Label: { + id: 'app.settings.game-options.setting.key-hotbar-1.label', + defaultMessage: 'Hotbar 1', + }, + hotbar2Label: { + id: 'app.settings.game-options.setting.key-hotbar-2.label', + defaultMessage: 'Hotbar 2', + }, + hotbar3Label: { + id: 'app.settings.game-options.setting.key-hotbar-3.label', + defaultMessage: 'Hotbar 3', + }, + hotbar4Label: { + id: 'app.settings.game-options.setting.key-hotbar-4.label', + defaultMessage: 'Hotbar 4', + }, + hotbar5Label: { + id: 'app.settings.game-options.setting.key-hotbar-5.label', + defaultMessage: 'Hotbar 5', + }, + hotbar6Label: { + id: 'app.settings.game-options.setting.key-hotbar-6.label', + defaultMessage: 'Hotbar 6', + }, + hotbar7Label: { + id: 'app.settings.game-options.setting.key-hotbar-7.label', + defaultMessage: 'Hotbar 7', + }, + hotbar8Label: { + id: 'app.settings.game-options.setting.key-hotbar-8.label', + defaultMessage: 'Hotbar 8', + }, + hotbar9Label: { + id: 'app.settings.game-options.setting.key-hotbar-9.label', + defaultMessage: 'Hotbar 9', + }, + debugOverlayLabel: { + id: 'app.settings.game-options.setting.key-debug-overlay.label', + defaultMessage: 'Debug overlay', + }, + debugModifierLabel: { + id: 'app.settings.game-options.setting.key-debug-modifier.label', + defaultMessage: 'Debug modifier', + }, + debugReloadChunksLabel: { + id: 'app.settings.game-options.setting.key-debug-reload-chunks.label', + defaultMessage: 'Reload chunks', + }, + debugHitboxesLabel: { + id: 'app.settings.game-options.setting.key-debug-hitboxes.label', + defaultMessage: 'Show hitboxes', + }, + debugClearChatLabel: { + id: 'app.settings.game-options.setting.key-debug-clear-chat.label', + defaultMessage: 'Clear chat', + }, + debugCrashLabel: { + id: 'app.settings.game-options.setting.key-debug-crash.label', + defaultMessage: 'Trigger debug crash', + }, + debugChunkBordersLabel: { + id: 'app.settings.game-options.setting.key-debug-chunk-borders.label', + defaultMessage: 'Show chunk borders', + }, + debugAdvancedTooltipsLabel: { + id: 'app.settings.game-options.setting.key-debug-advanced-tooltips.label', + defaultMessage: 'Show advanced tooltips', + }, + debugCopyRecreateCommandLabel: { + id: 'app.settings.game-options.setting.key-debug-copy-recreate-command.label', + defaultMessage: 'Copy recreate command', + }, + debugSpectateLabel: { + id: 'app.settings.game-options.setting.key-debug-spectate.label', + defaultMessage: 'Spectate entity', + }, + debugSwitchGameModeLabel: { + id: 'app.settings.game-options.setting.key-debug-switch-game-mode.label', + defaultMessage: 'Switch game mode', + }, + debugOptionsLabel: { + id: 'app.settings.game-options.setting.key-debug-options.label', + defaultMessage: 'Debug options', + }, + debugFocusPauseLabel: { + id: 'app.settings.game-options.setting.key-debug-focus-pause.label', + defaultMessage: 'Pause on lost focus', + }, + debugDumpDynamicTexturesLabel: { + id: 'app.settings.game-options.setting.key-debug-dump-dynamic-textures.label', + defaultMessage: 'Dump dynamic textures', + }, + debugReloadResourcePacksLabel: { + id: 'app.settings.game-options.setting.key-debug-reload-resource-packs.label', + defaultMessage: 'Reload resource packs', + }, + debugProfilingLabel: { + id: 'app.settings.game-options.setting.key-debug-profiling.label', + defaultMessage: 'Start profiling', + }, + debugCopyLocationLabel: { + id: 'app.settings.game-options.setting.key-debug-copy-location.label', + defaultMessage: 'Copy location', + }, + debugDumpVersionLabel: { + id: 'app.settings.game-options.setting.key-debug-dump-version.label', + defaultMessage: 'Dump version', + }, + debugProfilingChartLabel: { + id: 'app.settings.game-options.setting.key-debug-profiling-chart.label', + defaultMessage: 'Profiling chart', + }, + debugFpsChartsLabel: { + id: 'app.settings.game-options.setting.key-debug-fps-charts.label', + defaultMessage: 'FPS charts', + }, + debugNetworkChartsLabel: { + id: 'app.settings.game-options.setting.key-debug-network-charts.label', + defaultMessage: 'Network charts', + }, + debugLightmapTextureLabel: { + id: 'app.settings.game-options.setting.key-debug-lightmap-texture.label', + defaultMessage: 'Lightmap texture', + }, + debugImprovedTransparencyLabel: { + id: 'app.settings.game-options.setting.key-debug-improved-transparency.label', + defaultMessage: 'Improved transparency debug view', + }, +}) + +const categoryMessages = defineMessages({ + skinCustomizationLabel: { + id: 'app.settings.game-options.category.skin-customization.label', + defaultMessage: 'Skin customization', + }, + skinCustomizationDescription: { + id: 'app.settings.game-options.category.skin-customization.description', + defaultMessage: 'Skin layers and main hand', + }, + videoLabel: { id: 'app.settings.game-options.category.video.label', defaultMessage: 'Video' }, + videoDescription: { + id: 'app.settings.game-options.category.video.description', + defaultMessage: 'Camera and display settings', + }, + languageLabel: { + id: 'app.settings.game-options.category.language.label', + defaultMessage: 'Language', + }, + languageDescription: { + id: 'app.settings.game-options.category.language.description', + defaultMessage: 'Game language', + }, + musicAndSoundLabel: { + id: 'app.settings.game-options.category.music-and-sound.label', + defaultMessage: 'Music and sound', + }, + musicAndSoundDescription: { + id: 'app.settings.game-options.category.music-and-sound.description', + defaultMessage: 'Volume and audio preferences', + }, + controlsLabel: { + id: 'app.settings.game-options.category.controls.label', + defaultMessage: 'Controls', + }, + controlsDescription: { + id: 'app.settings.game-options.category.controls.description', + defaultMessage: 'Mouse, movement, and key bindings', + }, + chatLabel: { id: 'app.settings.game-options.category.chat.label', defaultMessage: 'Chat' }, + chatDescription: { + id: 'app.settings.game-options.category.chat.description', + defaultMessage: 'Chat visibility and appearance', + }, + accessibilityLabel: { + id: 'app.settings.game-options.category.accessibility.label', + defaultMessage: 'Accessibility', + }, + accessibilityDescription: { + id: 'app.settings.game-options.category.accessibility.description', + defaultMessage: 'Accessibility preferences', + }, + onlineLabel: { id: 'app.settings.game-options.category.online.label', defaultMessage: 'Online' }, + onlineDescription: { + id: 'app.settings.game-options.category.online.description', + defaultMessage: 'Online and Realms preferences', + }, + customLabel: { + id: 'app.settings.game-options.category.custom.label', + defaultMessage: 'Custom settings', + }, + customDescription: { + id: 'app.settings.game-options.category.custom.description', + defaultMessage: 'Settings added by mods', + }, +}) + +const choiceMessages = defineMessages({ + fast: { id: 'app.settings.game-options.choice.fast', defaultMessage: 'Fast' }, + fancy: { id: 'app.settings.game-options.choice.fancy', defaultMessage: 'Fancy' }, + fabulous: { id: 'app.settings.game-options.choice.fabulous', defaultMessage: 'Fabulous' }, + custom: { id: 'app.settings.game-options.choice.custom', defaultMessage: 'Custom' }, + left: { id: 'app.settings.game-options.choice.left', defaultMessage: 'Left' }, + right: { id: 'app.settings.game-options.choice.right', defaultMessage: 'Right' }, + shown: { id: 'app.settings.game-options.choice.shown', defaultMessage: 'Shown' }, + commandsOnly: { + id: 'app.settings.game-options.choice.commands-only', + defaultMessage: 'Commands only', + }, + hidden: { id: 'app.settings.game-options.choice.hidden', defaultMessage: 'Hidden' }, + all: { id: 'app.settings.game-options.choice.all', defaultMessage: 'All' }, + decreased: { id: 'app.settings.game-options.choice.decreased', defaultMessage: 'Decreased' }, + minimal: { id: 'app.settings.game-options.choice.minimal', defaultMessage: 'Minimal' }, + off: { id: 'app.settings.game-options.choice.off', defaultMessage: 'Off' }, + chat: { id: 'app.settings.game-options.choice.chat', defaultMessage: 'Chat' }, + system: { id: 'app.settings.game-options.choice.system', defaultMessage: 'System' }, + on: { id: 'app.settings.game-options.choice.on', defaultMessage: 'On' }, + minimum: { id: 'app.settings.game-options.choice.minimum', defaultMessage: 'Minimum' }, + maximum: { id: 'app.settings.game-options.choice.maximum', defaultMessage: 'Maximum' }, + never: { id: 'app.settings.game-options.choice.never', defaultMessage: 'Never' }, + pause: { id: 'app.settings.game-options.choice.pause', defaultMessage: 'Pause menu' }, + pauseAndToast: { + id: 'app.settings.game-options.choice.pause-and-toast', + defaultMessage: 'Pause menu and toast', + }, + far: { id: 'app.settings.game-options.choice.far', defaultMessage: 'Far' }, + normal: { id: 'app.settings.game-options.choice.normal', defaultMessage: 'Normal' }, + short: { id: 'app.settings.game-options.choice.short', defaultMessage: 'Short' }, + tiny: { id: 'app.settings.game-options.choice.tiny', defaultMessage: 'Tiny' }, + maxFps: { id: 'app.settings.game-options.choice.max-fps', defaultMessage: 'Max FPS' }, + balanced: { id: 'app.settings.game-options.choice.balanced', defaultMessage: 'Balanced' }, + powerSaver: { id: 'app.settings.game-options.choice.power-saver', defaultMessage: 'Power saver' }, + whileAfk: { id: 'app.settings.game-options.choice.while-afk', defaultMessage: 'While AFK' }, + whenMinimized: { + id: 'app.settings.game-options.choice.when-minimized', + defaultMessage: 'When minimized', + }, + none: { id: 'app.settings.game-options.choice.none', defaultMessage: 'None' }, + byPlayer: { id: 'app.settings.game-options.choice.by-player', defaultMessage: 'By player' }, + nearby: { id: 'app.settings.game-options.choice.nearby', defaultMessage: 'Nearby' }, + crosshair: { id: 'app.settings.game-options.choice.crosshair', defaultMessage: 'Crosshair' }, + hotbar: { id: 'app.settings.game-options.choice.hotbar', defaultMessage: 'Hotbar' }, + constant: { id: 'app.settings.game-options.choice.constant', defaultMessage: 'Constant' }, + default: { id: 'app.settings.game-options.choice.default', defaultMessage: 'Default' }, + frequent: { id: 'app.settings.game-options.choice.frequent', defaultMessage: 'Frequent' }, + limited: { id: 'app.settings.game-options.choice.limited', defaultMessage: 'Limited' }, + openGl: { id: 'app.settings.game-options.choice.opengl', defaultMessage: 'OpenGL' }, + vulkan: { id: 'app.settings.game-options.choice.vulkan', defaultMessage: 'Vulkan' }, +}) + +export const presentationMessages = defineMessages({ + customValuePlaceholder: { + id: 'app.settings.game-options.custom-value.placeholder', + defaultMessage: 'Enter a value', + }, + validationMissingValue: { + id: 'app.settings.game-options.validation.missing-value', + defaultMessage: 'Choose a value first.', + }, + validationNoCompatibleInstances: { + id: 'app.settings.game-options.validation.no-compatible-instances', + defaultMessage: 'Some of your instances cannot use this setting', + }, + validationInvalidValue: { + id: 'app.settings.game-options.validation.invalid-value', + defaultMessage: 'Choose a valid value.', + }, + validationChangedSinceOpened: { + id: 'app.settings.game-options.validation.changed-since-opened', + defaultMessage: 'This setting changed elsewhere. Check it and try again.', + }, + compatibilityNone: { + id: 'app.settings.game-options.compatibility.none', + defaultMessage: 'Some of your instances cannot use this setting', + }, + bucketLauncherControlled: { + id: 'app.settings.game-options.compatibility.reason.launcher-controlled', + defaultMessage: 'This setting is managed by Modrinth’s launch settings.', + }, +}) + +const knownSettings: Record = + { + fov: { label: settingMessages.fovLabel }, + graphics: { + label: settingMessages.graphicsLabel, + description: settingMessages.graphicsDescription, + }, + ambient_occlusion: { label: settingMessages.ambientOcclusionLabel }, + render_distance: { label: settingMessages.renderDistanceLabel }, + simulation_distance: { + label: settingMessages.simulationDistanceLabel, + description: settingMessages.simulationDistanceDescription, + }, + gui_scale: { + label: settingMessages.guiScaleLabel, + description: settingMessages.guiScaleDescription, + }, + particles: { label: settingMessages.particlesLabel }, + clouds: { label: settingMessages.cloudsLabel }, + entity_shadows: { label: settingMessages.entityShadowsLabel }, + view_bobbing: { + label: settingMessages.viewBobbingLabel, + description: settingMessages.viewBobbingDescription, + }, + vsync: { label: settingMessages.vsyncLabel, description: settingMessages.vsyncDescription }, + fullscreen: { label: settingMessages.fullscreenLabel }, + max_framerate: { label: settingMessages.maxFramerateLabel }, + mipmap_levels: { + label: settingMessages.mipmapLevelsLabel, + description: settingMessages.mipmapLevelsDescription, + }, + biome_blend_radius: { + label: settingMessages.biomeBlendRadiusLabel, + description: settingMessages.biomeBlendRadiusDescription, + }, + language: { label: settingMessages.languageLabel }, + master_volume: { label: settingMessages.masterVolumeLabel }, + music_volume: { label: settingMessages.musicVolumeLabel }, + music_toast: { + label: settingMessages.musicToastLabel, + description: settingMessages.musicToastDescription, + }, + record_volume: { label: settingMessages.recordVolumeLabel }, + weather_volume: { label: settingMessages.weatherVolumeLabel }, + blocks_volume: { label: settingMessages.blocksVolumeLabel }, + hostile_volume: { label: settingMessages.hostileVolumeLabel }, + neutral_volume: { label: settingMessages.neutralVolumeLabel }, + players_volume: { label: settingMessages.playersVolumeLabel }, + ambient_volume: { label: settingMessages.ambientVolumeLabel }, + voice_volume: { label: settingMessages.voiceVolumeLabel }, + ui_volume: { label: settingMessages.uiVolumeLabel }, + sensitivity: { label: settingMessages.sensitivityLabel }, + invert_mouse: { + label: settingMessages.invertMouseLabel, + description: settingMessages.invertMouseDescription, + }, + auto_jump: { + label: settingMessages.autoJumpLabel, + description: settingMessages.autoJumpDescription, + }, + toggle_crouch: { + label: settingMessages.toggleCrouchLabel, + description: settingMessages.toggleCrouchDescription, + }, + toggle_sprint: { + label: settingMessages.toggleSprintLabel, + description: settingMessages.toggleSprintDescription, + }, + discrete_mouse_scroll: { + label: settingMessages.discreteMouseScrollLabel, + description: settingMessages.discreteMouseScrollDescription, + }, + 'key.forward': { label: settingMessages.keyForwardLabel }, + 'key.left': { label: settingMessages.keyLeftLabel }, + 'key.back': { label: settingMessages.keyBackLabel }, + 'key.right': { label: settingMessages.keyRightLabel }, + 'key.jump': { label: settingMessages.keyJumpLabel }, + 'key.sneak': { label: settingMessages.keySneakLabel }, + 'key.sprint': { label: settingMessages.keySprintLabel }, + 'key.inventory': { label: settingMessages.keyInventoryLabel }, + 'key.swap_offhand': { label: settingMessages.keySwapOffhandLabel }, + 'key.drop': { label: settingMessages.keyDropLabel }, + 'key.use': { label: settingMessages.keyUseLabel }, + 'key.attack': { label: settingMessages.keyAttackLabel }, + 'key.pick_item': { label: settingMessages.keyPickItemLabel }, + 'key.chat': { label: settingMessages.keyChatLabel }, + 'key.player_list': { label: settingMessages.keyPlayerListLabel }, + 'key.command': { label: settingMessages.keyCommandLabel }, + 'key.screenshot': { label: settingMessages.keyScreenshotLabel }, + 'key.perspective': { label: settingMessages.keyPerspectiveLabel }, + 'key.fullscreen': { label: settingMessages.keyFullscreenLabel }, + 'key.advancements': { label: settingMessages.keyAdvancementsLabel }, + chat_visibility: { label: settingMessages.chatVisibilityLabel }, + chat_colors: { label: settingMessages.chatColorsLabel }, + chat_links: { + label: settingMessages.chatLinksLabel, + description: settingMessages.chatLinksDescription, + }, + chat_links_prompt: { + label: settingMessages.chatLinksPromptLabel, + description: settingMessages.chatLinksPromptDescription, + }, + chat_opacity: { + label: settingMessages.chatOpacityLabel, + description: settingMessages.chatOpacityDescription, + }, + chat_scale: { label: settingMessages.chatScaleLabel }, + narrator: { + label: settingMessages.narratorLabel, + description: settingMessages.narratorDescription, + }, + subtitles: { + label: settingMessages.subtitlesLabel, + description: settingMessages.subtitlesDescription, + }, + high_contrast: { + label: settingMessages.highContrastLabel, + description: settingMessages.highContrastDescription, + }, + dark_splash: { + label: settingMessages.darkSplashLabel, + description: settingMessages.darkSplashDescription, + }, + notification_time: { + label: settingMessages.notificationTimeLabel, + description: settingMessages.notificationTimeDescription, + }, + main_hand: { + label: settingMessages.mainHandLabel, + description: settingMessages.mainHandDescription, + }, + cape: { label: settingMessages.capeLabel, description: settingMessages.capeDescription }, + hat: { label: settingMessages.hatLabel, description: settingMessages.hatDescription }, + jacket: { label: settingMessages.jacketLabel, description: settingMessages.jacketDescription }, + allow_server_listing: { + label: settingMessages.allowServerListingLabel, + description: settingMessages.allowServerListingDescription, + }, + realms_notifications: { label: settingMessages.realmsNotificationsLabel }, + brightness: { label: catalogSettingMessages.brightnessLabel }, + legacy_view_distance: { label: catalogSettingMessages.legacyViewDistanceLabel }, + entity_distance: { label: catalogSettingMessages.entityDistanceLabel }, + debug_gui_scale: { label: catalogSettingMessages.debugGuiScaleLabel }, + graphics_backend: { label: catalogSettingMessages.graphicsBackendLabel }, + cloud_range: { label: catalogSettingMessages.cloudRangeLabel }, + exclusive_fullscreen: { label: catalogSettingMessages.exclusiveFullscreenLabel }, + mac_fullscreen_menu: { label: catalogSettingMessages.macFullscreenMenuLabel }, + legacy_framerate_limit: { label: catalogSettingMessages.legacyFramerateLimitLabel }, + inactivity_framerate_limit: { + label: catalogSettingMessages.inactivityFramerateLimitLabel, + }, + prioritize_chunk_updates: { label: catalogSettingMessages.prioritizeChunkUpdatesLabel }, + attack_indicator: { label: catalogSettingMessages.attackIndicatorLabel }, + reduced_debug_info: { label: catalogSettingMessages.reducedDebugInfoLabel }, + chunk_fade_time: { label: catalogSettingMessages.chunkFadeTimeLabel }, + cutout_leaves: { label: catalogSettingMessages.cutoutLeavesLabel }, + improved_transparency: { label: catalogSettingMessages.improvedTransparencyLabel }, + texture_filtering: { label: catalogSettingMessages.textureFilteringLabel }, + anisotropy: { label: catalogSettingMessages.anisotropyLabel }, + vignette: { label: catalogSettingMessages.vignetteLabel }, + weather_radius: { label: catalogSettingMessages.weatherRadiusLabel }, + advanced_opengl: { label: catalogSettingMessages.advancedOpenGlLabel }, + anaglyph_3d: { label: catalogSettingMessages.anaglyph3dLabel }, + anisotropic_filtering: { label: catalogSettingMessages.anisotropicFilteringLabel }, + alternate_blocks: { label: catalogSettingMessages.alternateBlocksLabel }, + held_item_tooltips: { label: catalogSettingMessages.heldItemTooltipsLabel }, + use_vbo: { label: catalogSettingMessages.useVboLabel }, + force_unicode_font: { label: catalogSettingMessages.forceUnicodeFontLabel }, + japanese_glyph_variants: { label: catalogSettingMessages.japaneseGlyphVariantsLabel }, + music_frequency: { label: catalogSettingMessages.musicFrequencyLabel }, + directional_audio: { label: catalogSettingMessages.directionalAudioLabel }, + invert_horizontal_mouse: { label: catalogSettingMessages.invertHorizontalMouseLabel }, + toggle_attack: { label: catalogSettingMessages.toggleAttackLabel }, + toggle_use: { label: catalogSettingMessages.toggleUseLabel }, + mouse_wheel_sensitivity: { label: catalogSettingMessages.mouseWheelSensitivityLabel }, + raw_mouse_input: { label: catalogSettingMessages.rawMouseInputLabel }, + touchscreen: { label: catalogSettingMessages.touchscreenLabel }, + allow_cursor_changes: { label: catalogSettingMessages.allowCursorChangesLabel }, + sprint_window: { label: catalogSettingMessages.sprintWindowLabel }, + operator_items_tab: { label: catalogSettingMessages.operatorItemsTabLabel }, + ctrl_click_right_click: { label: catalogSettingMessages.ctrlClickRightClickLabel }, + quit_shortcuts: { label: catalogSettingMessages.quitShortcutsLabel }, + chat_width: { label: catalogSettingMessages.chatWidthLabel }, + focused_chat_height: { label: catalogSettingMessages.focusedChatHeightLabel }, + unfocused_chat_height: { label: catalogSettingMessages.unfocusedChatHeightLabel }, + chat_line_spacing: { label: catalogSettingMessages.chatLineSpacingLabel }, + chat_delay: { label: catalogSettingMessages.chatDelayLabel }, + text_background_opacity: { label: catalogSettingMessages.textBackgroundOpacityLabel }, + chat_background_only: { label: catalogSettingMessages.chatBackgroundOnlyLabel }, + auto_suggestions: { label: catalogSettingMessages.autoSuggestionsLabel }, + secure_chat_only: { label: catalogSettingMessages.secureChatOnlyLabel }, + save_chat_drafts: { label: catalogSettingMessages.saveChatDraftsLabel }, + hide_matched_names: { label: catalogSettingMessages.hideMatchedNamesLabel }, + chat_preview: { label: catalogSettingMessages.chatPreviewLabel }, + fov_effects: { label: catalogSettingMessages.fovEffectsLabel }, + screen_effects: { label: catalogSettingMessages.screenEffectsLabel }, + darkness_pulsing: { label: catalogSettingMessages.darknessPulsingLabel }, + damage_tilt: { label: catalogSettingMessages.damageTiltLabel }, + glint_speed: { label: catalogSettingMessages.glintSpeedLabel }, + glint_strength: { label: catalogSettingMessages.glintStrengthLabel }, + hide_lightning_flashes: { label: catalogSettingMessages.hideLightningFlashesLabel }, + hide_splash_texts: { label: catalogSettingMessages.hideSplashTextsLabel }, + high_contrast_outline: { label: catalogSettingMessages.highContrastOutlineLabel }, + narrator_hotkey: { label: catalogSettingMessages.narratorHotkeyLabel }, + autosave_indicator: { label: catalogSettingMessages.autosaveIndicatorLabel }, + panorama_speed: { label: catalogSettingMessages.panoramaSpeedLabel }, + menu_background_blur: { label: catalogSettingMessages.menuBackgroundBlurLabel }, + rotate_with_minecart: { label: catalogSettingMessages.rotateWithMinecartLabel }, + left_sleeve: { label: catalogSettingMessages.leftSleeveLabel }, + right_sleeve: { label: catalogSettingMessages.rightSleeveLabel }, + left_pants_leg: { label: catalogSettingMessages.leftPantsLegLabel }, + right_pants_leg: { label: catalogSettingMessages.rightPantsLegLabel }, + hide_server_address: { label: catalogSettingMessages.hideServerAddressLabel }, + server_textures: { label: catalogSettingMessages.serverTexturesLabel }, + snooper: { label: catalogSettingMessages.snooperLabel }, + extra_telemetry: { label: catalogSettingMessages.extraTelemetryLabel }, + in_game_notifications: { label: catalogSettingMessages.inGameNotificationsLabel }, + share_presence: { label: catalogSettingMessages.sharePresenceLabel }, + 'key.smooth_camera': { label: catalogKeyMessages.smoothCameraLabel }, + 'key.spectator_outlines': { label: catalogKeyMessages.spectatorOutlinesLabel }, + 'key.save_toolbar': { label: catalogKeyMessages.saveToolbarLabel }, + 'key.load_toolbar': { label: catalogKeyMessages.loadToolbarLabel }, + 'key.social_interactions': { label: catalogKeyMessages.socialInteractionsLabel }, + 'key.quick_actions': { label: catalogKeyMessages.quickActionsLabel }, + 'key.spectator_hotbar': { label: catalogKeyMessages.spectatorHotbarLabel }, + 'key.friends': { label: catalogKeyMessages.friendsLabel }, + 'key.toggle_gui': { label: catalogKeyMessages.toggleGuiLabel }, + 'key.toggle_spectator_shader': { label: catalogKeyMessages.toggleSpectatorShaderLabel }, + 'key.hotbar.1': { label: catalogKeyMessages.hotbar1Label }, + 'key.hotbar.2': { label: catalogKeyMessages.hotbar2Label }, + 'key.hotbar.3': { label: catalogKeyMessages.hotbar3Label }, + 'key.hotbar.4': { label: catalogKeyMessages.hotbar4Label }, + 'key.hotbar.5': { label: catalogKeyMessages.hotbar5Label }, + 'key.hotbar.6': { label: catalogKeyMessages.hotbar6Label }, + 'key.hotbar.7': { label: catalogKeyMessages.hotbar7Label }, + 'key.hotbar.8': { label: catalogKeyMessages.hotbar8Label }, + 'key.hotbar.9': { label: catalogKeyMessages.hotbar9Label }, + 'key.debug.overlay': { label: catalogKeyMessages.debugOverlayLabel }, + 'key.debug.modifier': { label: catalogKeyMessages.debugModifierLabel }, + 'key.debug.reload_chunks': { label: catalogKeyMessages.debugReloadChunksLabel }, + 'key.debug.hitboxes': { label: catalogKeyMessages.debugHitboxesLabel }, + 'key.debug.clear_chat': { label: catalogKeyMessages.debugClearChatLabel }, + 'key.debug.crash': { label: catalogKeyMessages.debugCrashLabel }, + 'key.debug.chunk_borders': { label: catalogKeyMessages.debugChunkBordersLabel }, + 'key.debug.advanced_tooltips': { + label: catalogKeyMessages.debugAdvancedTooltipsLabel, + }, + 'key.debug.copy_recreate_command': { + label: catalogKeyMessages.debugCopyRecreateCommandLabel, + }, + 'key.debug.spectate': { label: catalogKeyMessages.debugSpectateLabel }, + 'key.debug.switch_game_mode': { label: catalogKeyMessages.debugSwitchGameModeLabel }, + 'key.debug.options': { label: catalogKeyMessages.debugOptionsLabel }, + 'key.debug.focus_pause': { label: catalogKeyMessages.debugFocusPauseLabel }, + 'key.debug.dump_dynamic_textures': { + label: catalogKeyMessages.debugDumpDynamicTexturesLabel, + }, + 'key.debug.reload_resource_packs': { + label: catalogKeyMessages.debugReloadResourcePacksLabel, + }, + 'key.debug.profiling': { label: catalogKeyMessages.debugProfilingLabel }, + 'key.debug.copy_location': { label: catalogKeyMessages.debugCopyLocationLabel }, + 'key.debug.dump_version': { label: catalogKeyMessages.debugDumpVersionLabel }, + 'key.debug.profiling_chart': { label: catalogKeyMessages.debugProfilingChartLabel }, + 'key.debug.fps_charts': { label: catalogKeyMessages.debugFpsChartsLabel }, + 'key.debug.network_charts': { label: catalogKeyMessages.debugNetworkChartsLabel }, + 'key.debug.lightmap_texture': { label: catalogKeyMessages.debugLightmapTextureLabel }, + 'key.debug.improved_transparency': { + label: catalogKeyMessages.debugImprovedTransparencyLabel, + }, + } + +const categories: Record = { + skin_customization: { + label: categoryMessages.skinCustomizationLabel, + description: categoryMessages.skinCustomizationDescription, + }, + video: { label: categoryMessages.videoLabel, description: categoryMessages.videoDescription }, + video_settings: { + label: categoryMessages.videoLabel, + description: categoryMessages.videoDescription, + }, + language: { + label: categoryMessages.languageLabel, + description: categoryMessages.languageDescription, + }, + music_and_sound: { + label: categoryMessages.musicAndSoundLabel, + description: categoryMessages.musicAndSoundDescription, + }, + controls: { + label: categoryMessages.controlsLabel, + description: categoryMessages.controlsDescription, + }, + chat: { label: categoryMessages.chatLabel, description: categoryMessages.chatDescription }, + chat_settings: { + label: categoryMessages.chatLabel, + description: categoryMessages.chatDescription, + }, + accessibility: { + label: categoryMessages.accessibilityLabel, + description: categoryMessages.accessibilityDescription, + }, + online: { label: categoryMessages.onlineLabel, description: categoryMessages.onlineDescription }, + custom: { label: categoryMessages.customLabel, description: categoryMessages.customDescription }, + custom_settings: { + label: categoryMessages.customLabel, + description: categoryMessages.customDescription, + }, +} + +const choices: Record = { + 'graphics:fast': choiceMessages.fast, + 'graphics:fancy': choiceMessages.fancy, + 'graphics:fabulous': choiceMessages.fabulous, + 'graphics:custom': choiceMessages.custom, + 'main_hand:left': choiceMessages.left, + 'main_hand:right': choiceMessages.right, + 'chat_visibility:0': choiceMessages.shown, + 'chat_visibility:1': choiceMessages.commandsOnly, + 'chat_visibility:2': choiceMessages.hidden, + 'particles:0': choiceMessages.all, + 'particles:1': choiceMessages.decreased, + 'particles:2': choiceMessages.minimal, + 'narrator:0': choiceMessages.off, + 'narrator:1': choiceMessages.all, + 'narrator:2': choiceMessages.chat, + 'narrator:3': choiceMessages.system, + 'clouds:false': choiceMessages.off, + 'clouds:fast': choiceMessages.fast, + 'clouds:true': choiceMessages.fancy, + 'ambient_occlusion:off': choiceMessages.off, + 'ambient_occlusion:on': choiceMessages.on, + 'ambient_occlusion:minimum': choiceMessages.minimum, + 'ambient_occlusion:maximum': choiceMessages.maximum, + 'music_toast:never': choiceMessages.never, + 'music_toast:pause': choiceMessages.pause, + 'music_toast:pause_and_toast': choiceMessages.pauseAndToast, + 'legacy_view_distance:0': choiceMessages.far, + 'legacy_view_distance:1': choiceMessages.normal, + 'legacy_view_distance:2': choiceMessages.short, + 'legacy_view_distance:3': choiceMessages.tiny, + 'legacy_framerate_limit:0': choiceMessages.maxFps, + 'legacy_framerate_limit:1': choiceMessages.balanced, + 'legacy_framerate_limit:2': choiceMessages.powerSaver, + 'inactivity_framerate_limit:afk': choiceMessages.whileAfk, + 'inactivity_framerate_limit:minimized': choiceMessages.whenMinimized, + 'prioritize_chunk_updates:0': choiceMessages.none, + 'prioritize_chunk_updates:1': choiceMessages.byPlayer, + 'prioritize_chunk_updates:2': choiceMessages.nearby, + 'attack_indicator:0': choiceMessages.off, + 'attack_indicator:1': choiceMessages.crosshair, + 'attack_indicator:2': choiceMessages.hotbar, + 'chat_preview:0': choiceMessages.off, + 'chat_preview:1': choiceMessages.commandsOnly, + 'chat_preview:2': choiceMessages.on, + 'music_frequency:CONSTANT': choiceMessages.constant, + 'music_frequency:DEFAULT': choiceMessages.default, + 'music_frequency:FREQUENT': choiceMessages.frequent, + 'share_presence:all': choiceMessages.all, + 'share_presence:limited': choiceMessages.limited, + 'share_presence:none': choiceMessages.none, + 'graphics_backend:default': choiceMessages.default, + 'graphics_backend:opengl': choiceMessages.openGl, + 'graphics_backend:vulkan': choiceMessages.vulkan, +} + +const validationMessages: Record = { + missing_value: presentationMessages.validationMissingValue, + no_compatible_instances: presentationMessages.validationNoCompatibleInstances, + invalid_value: presentationMessages.validationInvalidValue, + changed_since_opened: presentationMessages.validationChangedSinceOpened, +} + +export function formatGameSettingLabel( + formatMessage: FormatMessage, + setting: EditableGameSetting, +): string { + if (setting.kind === 'external') return setting.raw_key ?? setting.option_id + const definition = knownSettings[setting.option_id] + return definition ? formatMessage(definition.label) : setting.option_id +} + +export function formatGameSettingDescription( + formatMessage: FormatMessage, + setting: EditableGameSetting, +): string { + if (setting.kind === 'external') return '' + const definition = knownSettings[setting.option_id] + return definition?.description ? formatMessage(definition.description) : '' +} + +export function gameSettingCategoryMessage(category: GameSettingCategory): MessageDescriptor { + return ( + categories[category.id]?.label ?? { + id: `app.settings.game-options.category.${category.id}.label`, + defaultMessage: category.id, + } + ) +} + +export function formatGameSettingChoice( + formatMessage: FormatMessage, + optionId: string, + value: string, +): string { + const message = choices[`${optionId}:${value}`] + return message ? formatMessage(message) : value +} + +export function formatGameSettingValidation( + formatMessage: FormatMessage, + error: GameOptionValidationError | null | undefined, +): string | null { + return error ? formatMessage(validationMessages[error]) : null +} diff --git a/apps/app-frontend/src/components/ui/settings/instances/game-settings-modal/row.vue b/apps/app-frontend/src/components/ui/settings/instances/game-settings-modal/row.vue new file mode 100644 index 0000000000..8adfb4c60c --- /dev/null +++ b/apps/app-frontend/src/components/ui/settings/instances/game-settings-modal/row.vue @@ -0,0 +1,311 @@ + + + diff --git a/apps/app-frontend/src/components/ui/settings/instances/game-settings-modal/use-editor.ts b/apps/app-frontend/src/components/ui/settings/instances/game-settings-modal/use-editor.ts new file mode 100644 index 0000000000..84d850e098 --- /dev/null +++ b/apps/app-frontend/src/components/ui/settings/instances/game-settings-modal/use-editor.ts @@ -0,0 +1,408 @@ +import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui' +import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query' +import { computed, type MaybeRefOrGetter, nextTick, onScopeDispose, ref, toValue } from 'vue' + +import { + type EditableGameSetting, + type GameOptionCanonicalValue, + type GameSettingChange, + type GameSettingsEditorState, + get_local_game_options_config, + get_synced_game_options_config, + preview_local_game_option_changes, + preview_synced_game_option_changes, + save_local_game_option_changes, + save_synced_game_option_changes, + type UpdateGameSettingsRequest, +} from '@/helpers/game-options' + +import { canonicalValuesEqual, cloneGameSettingsState, gameSettingChanges } from './editors' + +function editorQueryKey(instanceId?: string) { + return instanceId + ? (['game-settings', 'local', instanceId] as const) + : (['game-settings', 'synced'] as const) +} + +export function useGameSettingsEditor( + instanceId: MaybeRefOrGetter, + onSaved: () => void, +) { + const { addNotification, handleError } = injectNotificationManager() + const { formatMessage } = useVIntl() + const queryClient = useQueryClient() + const messages = defineMessages({ + conflictTitle: { + id: 'app.settings.synced-options.game-settings.conflict-title', + defaultMessage: 'These settings changed elsewhere', + }, + conflictText: { + id: 'app.settings.synced-options.game-settings.conflict-text', + defaultMessage: 'We loaded the latest settings. Check your changes, then save again.', + }, + }) + + const baseState = ref(null) + const draftState = ref(null) + const touchedValueOptionIds = ref>(new Set()) + let previewTimer: ReturnType | null = null + let previewGeneration = 0 + let loadGeneration = 0 + let saveGeneration = 0 + + const editorInstanceId = ref() + const isLocalEditor = computed(() => !!editorInstanceId.value) + + const dirtyChanges = computed(() => + gameSettingChanges(baseState.value, draftState.value, touchedValueOptionIds.value), + ) + const isDirty = computed(() => dirtyChanges.value.length > 0) + const dirtyOptionIds = computed( + () => new Set(dirtyChanges.value.map((change) => change.option_id)), + ) + const hasBlockingDraft = computed( + () => + draftState.value?.settings.some( + (setting) => + dirtyOptionIds.value.has(setting.option_id) && + (isLocalEditor.value || setting.sync_enabled) && + (!!setting.validation_error || + ['mixed', 'unset', 'invalid'].includes(setting.value_state)), + ) ?? false, + ) + const stateQuery = useQuery( + computed(() => { + const instanceId = editorInstanceId.value + return { + queryKey: editorQueryKey(instanceId), + queryFn: () => + instanceId ? get_local_game_options_config(instanceId) : get_synced_game_options_config(), + enabled: false, + retry: false, + } + }), + ) + const loading = computed(() => stateQuery.isPending.value || stateQuery.isFetching.value) + const previewMutation = useMutation({ + mutationFn: ({ + instanceId, + request, + }: { + instanceId?: string + request: UpdateGameSettingsRequest + }) => + instanceId + ? preview_local_game_option_changes(instanceId, request) + : preview_synced_game_option_changes(request), + }) + const saveMutation = useMutation({ + mutationFn: saveDraft, + onError: handleError, + }) + + async function load() { + const generation = ++loadGeneration + cancelPreview() + editorInstanceId.value = toValue(instanceId) + await nextTick() + const result = await stateQuery.refetch() + if (generation !== loadGeneration) return false + if (result.isError) { + handleError(result.error) + return false + } + if (!result.data) return false + baseState.value = cloneGameSettingsState(result.data) + draftState.value = cloneGameSettingsState(result.data) + touchedValueOptionIds.value = new Set() + return true + } + + function cancelPreview() { + if (previewTimer) clearTimeout(previewTimer) + previewTimer = null + previewGeneration++ + } + + function reset() { + cancelPreview() + loadGeneration++ + saveGeneration++ + baseState.value = null + draftState.value = null + touchedValueOptionIds.value = new Set() + saveMutation.reset() + } + + function cancelChanges() { + if (!baseState.value) return + cancelPreview() + draftState.value = cloneGameSettingsState(baseState.value) + touchedValueOptionIds.value = new Set() + } + + function setSyncEnabled(optionIds: readonly string[], enabled: boolean) { + if (!draftState.value) return + const ids = new Set(optionIds) + draftState.value.settings = draftState.value.settings.map((setting) => + ids.has(setting.option_id) + ? { ...setting, sync_enabled: enabled, validation_error: null } + : setting, + ) + schedulePreview() + } + + function schedulePreview() { + cancelPreview() + previewTimer = setTimeout(() => void preview(), 350) + } + + function save() { + if (saveMutation.isPending.value || loading.value || hasBlockingDraft.value || !isDirty.value) + return + saveMutation.mutate() + } + + function editorRequest(): UpdateGameSettingsRequest | null { + if (!baseState.value || !draftState.value) return null + return { + expected_summary_revision: draftState.value.summary_revision, + expected_canonical_revision: draftState.value.canonical_revision, + expected_catalog_revision: draftState.value.catalog_revision, + changes: dirtyChanges.value, + } + } + + function setCanonicalValue(optionId: string, value: GameOptionCanonicalValue | null) { + if (!draftState.value) return + const currentSetting = draftState.value.settings.find( + (setting) => setting.option_id === optionId, + ) + const baseSetting = baseState.value?.settings.find((setting) => setting.option_id === optionId) + if (!currentSetting) return + + const revertedToBase = + !!baseSetting && + !canonicalValuesEqual(currentSetting.canonical_value, baseSetting.canonical_value) && + canonicalValuesEqual(value, baseSetting.canonical_value) + const touchedOptionIds = new Set(touchedValueOptionIds.value) + if (revertedToBase) { + touchedOptionIds.delete(optionId) + } else { + touchedOptionIds.add(optionId) + } + touchedValueOptionIds.value = touchedOptionIds + draftState.value.settings = draftState.value.settings.map((setting) => + setting.option_id === optionId + ? { + ...setting, + canonical_value: value, + value_state: revertedToBase ? baseSetting.value_state : value ? 'canonical' : 'unset', + validation_error: revertedToBase ? baseSetting.validation_error : null, + } + : setting, + ) + schedulePreview() + } + + function mergePreview(preview: GameSettingsEditorState) { + if (!baseState.value || !draftState.value) return + const previousBase = cloneGameSettingsState(baseState.value) + const previousDraft = cloneGameSettingsState(draftState.value) + const dirtyIds = new Set( + gameSettingChanges(previousBase, previousDraft, touchedValueOptionIds.value).map( + (change) => change.option_id, + ), + ) + const stagedSettings = new Map( + previousDraft.settings + .filter((setting) => dirtyIds.has(setting.option_id)) + .map((setting) => [setting.option_id, setting]), + ) + const previousBaseSettings = new Map( + previousBase.settings.map((setting) => [setting.option_id, setting]), + ) + const nextBase = cloneGameSettingsState(preview) + const nextDraft = cloneGameSettingsState(preview) + nextBase.settings = nextBase.settings.map((setting) => + dirtyIds.has(setting.option_id) + ? { ...(previousBaseSettings.get(setting.option_id) ?? setting) } + : setting, + ) + nextDraft.settings = nextDraft.settings.map((setting): EditableGameSetting => { + const staged = stagedSettings.get(setting.option_id) + if (!staged) return setting + return { + ...setting, + sync_enabled: staged.sync_enabled, + canonical_value: staged.canonical_value, + option_revision: staged.option_revision, + } + }) + baseState.value = nextBase + draftState.value = nextDraft + } + + async function preview() { + const request = editorRequest() + if (!request || request.changes.length === 0) { + previewGeneration++ + if (baseState.value) draftState.value = cloneGameSettingsState(baseState.value) + return + } + + const generation = ++previewGeneration + try { + const previewState = await previewMutation.mutateAsync({ + instanceId: editorInstanceId.value, + request, + }) + if (generation !== previewGeneration) return + mergePreview(previewState) + } catch { + return + } + } + + function applyChangesToRefreshedState( + refreshed: GameSettingsEditorState, + stagedDraft: GameSettingsEditorState, + changes: GameSettingChange[], + touchedOptionIds: ReadonlySet, + conflictOptionIds: ReadonlySet = new Set(), + ) { + const changesById = new Map(changes.map((change) => [change.option_id, change])) + const stagedSettings = new Map( + stagedDraft.settings.map((setting) => [setting.option_id, setting]), + ) + const nextBase = cloneGameSettingsState(refreshed) + const nextDraft = cloneGameSettingsState(refreshed) + + nextDraft.settings = nextDraft.settings.map((setting) => { + const change = changesById.get(setting.option_id) + const staged = stagedSettings.get(setting.option_id) + if (!change || !staged) return setting + + return { + ...setting, + ...(change.sync_enabled !== undefined ? { sync_enabled: staged.sync_enabled } : {}), + ...(change.canonical_value !== undefined + ? { + canonical_value: staged.canonical_value, + value_state: staged.value_state, + } + : {}), + validation_error: conflictOptionIds.has(setting.option_id) + ? 'changed_since_opened' + : staged.validation_error, + } + }) + + baseState.value = nextBase + draftState.value = nextDraft + touchedValueOptionIds.value = new Set( + [...touchedOptionIds].filter( + (optionId) => changesById.get(optionId)?.canonical_value !== undefined, + ), + ) + } + + async function saveDraft() { + const request = editorRequest() + if (!request || request.changes.length === 0 || !baseState.value || !draftState.value) return + + cancelPreview() + const targetInstanceId = editorInstanceId.value + const generation = ++saveGeneration + const previousBase = cloneGameSettingsState(baseState.value) + const optimisticState = cloneGameSettingsState(draftState.value) + const previouslyTouchedOptionIds = new Set(touchedValueOptionIds.value) + baseState.value = cloneGameSettingsState(optimisticState) + touchedValueOptionIds.value = new Set() + try { + const result = targetInstanceId + ? await save_local_game_option_changes(targetInstanceId, request) + : await save_synced_game_option_changes(request) + const refreshed = + result.state ?? + (targetInstanceId + ? await get_local_game_options_config(targetInstanceId) + : await get_synced_game_options_config()) + queryClient.setQueryData(editorQueryKey(targetInstanceId), refreshed) + if (result.conflicts?.length) { + if (generation !== saveGeneration || !draftState.value) return + previewGeneration++ + const stagedDraft = cloneGameSettingsState(draftState.value) + const retainedTouchedOptionIds = new Set([ + ...previouslyTouchedOptionIds, + ...touchedValueOptionIds.value, + ]) + const stagedChanges = gameSettingChanges( + previousBase, + stagedDraft, + retainedTouchedOptionIds, + ) + const conflicts = new Set(result.conflicts) + applyChangesToRefreshedState( + refreshed, + stagedDraft, + stagedChanges, + retainedTouchedOptionIds, + conflicts, + ) + + addNotification({ + type: 'warning', + title: formatMessage(messages.conflictTitle), + text: formatMessage(messages.conflictText), + }) + if (isDirty.value) { + schedulePreview() + } + return + } + + if (generation !== saveGeneration) { + onSaved() + return + } + if (!baseState.value || !draftState.value) return + previewGeneration++ + const stagedDraft = cloneGameSettingsState(draftState.value) + const touchedSinceSave = new Set(touchedValueOptionIds.value) + const changesSinceSave = gameSettingChanges(optimisticState, stagedDraft, touchedSinceSave) + applyChangesToRefreshedState(refreshed, stagedDraft, changesSinceSave, touchedSinceSave) + if (changesSinceSave.length > 0) schedulePreview() + onSaved() + } catch (error) { + if (generation === saveGeneration && draftState.value) { + previewGeneration++ + baseState.value = previousBase + touchedValueOptionIds.value = new Set([ + ...previouslyTouchedOptionIds, + ...touchedValueOptionIds.value, + ]) + if (dirtyChanges.value.length > 0) schedulePreview() + } + throw error + } + } + + onScopeDispose(reset) + + return { + draftState, + isLocalEditor, + isDirty, + hasBlockingDraft, + loading, + loadError: stateQuery.isError, + saving: saveMutation.isPending, + load, + reset, + cancelChanges, + setSyncEnabled, + setCanonicalValue, + save, + } +} diff --git a/apps/app-frontend/src/components/ui/settings/instances/instances-synced-settings/command-history-modal.vue b/apps/app-frontend/src/components/ui/settings/instances/instances-synced-settings/command-history-modal.vue new file mode 100644 index 0000000000..432ba3c75c --- /dev/null +++ b/apps/app-frontend/src/components/ui/settings/instances/instances-synced-settings/command-history-modal.vue @@ -0,0 +1,124 @@ + + + + + diff --git a/apps/app-frontend/src/components/ui/settings/instances/instances-synced-settings/index.vue b/apps/app-frontend/src/components/ui/settings/instances/instances-synced-settings/index.vue new file mode 100644 index 0000000000..2a46487837 --- /dev/null +++ b/apps/app-frontend/src/components/ui/settings/instances/instances-synced-settings/index.vue @@ -0,0 +1,621 @@ + + + diff --git a/apps/app-frontend/src/components/ui/settings/instances/instances-synced-settings/launch-options.vue b/apps/app-frontend/src/components/ui/settings/instances/instances-synced-settings/launch-options.vue new file mode 100644 index 0000000000..e5ac9f5698 --- /dev/null +++ b/apps/app-frontend/src/components/ui/settings/instances/instances-synced-settings/launch-options.vue @@ -0,0 +1,463 @@ + + + diff --git a/apps/app-frontend/src/components/ui/settings/instances/instances-synced-settings/servers-modal.vue b/apps/app-frontend/src/components/ui/settings/instances/instances-synced-settings/servers-modal.vue new file mode 100644 index 0000000000..98a5aa9534 --- /dev/null +++ b/apps/app-frontend/src/components/ui/settings/instances/instances-synced-settings/servers-modal.vue @@ -0,0 +1,472 @@ + + + diff --git a/apps/app-frontend/src/components/ui/sync-instances-update-modal/index.vue b/apps/app-frontend/src/components/ui/sync-instances-update-modal/index.vue new file mode 100644 index 0000000000..4ce27b3cfb --- /dev/null +++ b/apps/app-frontend/src/components/ui/sync-instances-update-modal/index.vue @@ -0,0 +1,391 @@ + + + diff --git a/apps/app-frontend/src/components/ui/sync-instances-update-modal/use-sync.ts b/apps/app-frontend/src/components/ui/sync-instances-update-modal/use-sync.ts new file mode 100644 index 0000000000..facc2f3dd1 --- /dev/null +++ b/apps/app-frontend/src/components/ui/sync-instances-update-modal/use-sync.ts @@ -0,0 +1,227 @@ +import { injectNotificationManager } from '@modrinth/ui' +import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query' +import { computed, ref, watch } from 'vue' + +import { isSyncedOptionAvailable, set_global_synced_option } from '@/helpers/instance' +import { + gameOptionsSyncSourcesQueryOptions, + globalSyncedOptionsQueryOptions, + syncedOptionsKeys, +} from '@/helpers/synced-options' +import { syncedPackKeys } from '@/helpers/synced-packs' +import { instanceKeys, instanceListQueryOptions } from '@/pages/instance/query-options' + +export const syncUpdateOptions = ( + [ + 'game_options', + 'multiplayer_servers', + 'command_history', + 'creative_hotbars', + 'resource_packs', + 'data_packs', + ] as const +).filter(isSyncedOptionAvailable) + +export type SyncUpdateOption = (typeof syncUpdateOptions)[number] + +type SyncUpdateOptionState = Record +type SyncUpdateSourceState = Partial> + +function createOptionState(value = false): SyncUpdateOptionState { + return Object.fromEntries( + syncUpdateOptions.map((option) => [option, value]), + ) as SyncUpdateOptionState +} + +export function useSyncInstancesUpdate() { + const queryClient = useQueryClient() + const { handleError } = injectNotificationManager() + const isOpen = ref(false) + const draftInitialized = ref(false) + const initialOptions = ref(createOptionState()) + const draftOptions = ref(createOptionState()) + const draftSourceInstanceIds = ref({}) + const sourceOptions = ref([]) + const sourceInstanceId = ref('') + const needsGameOptionsSource = computed(() => sourceOptions.value.includes('game_options')) + const globalOptionsQuery = useQuery({ + ...globalSyncedOptionsQueryOptions(), + enabled: isOpen, + }) + const gameSourcesQuery = useQuery({ + ...gameOptionsSyncSourcesQueryOptions(), + enabled: needsGameOptionsSource, + }) + const instancesQuery = useQuery({ + ...instanceListQueryOptions(), + staleTime: 0, + enabled: computed(() => sourceOptions.value.length > 0 && !needsGameOptionsSource.value), + }) + const sources = computed(() => + needsGameOptionsSource.value + ? (gameSourcesQuery.data.value ?? []).map((source) => ({ + id: source.source_id, + name: source.name, + icon_path: source.icon_path, + eligible: source.eligible, + })) + : (instancesQuery.data.value ?? []).map((instance) => ({ + id: instance.id, + name: instance.name, + icon_path: instance.icon_path, + eligible: instance.install_stage === 'installed' && !instance.quarantined, + })), + ) + const sourcesLoading = computed(() => + needsGameOptionsSource.value + ? gameSourcesQuery.isPending.value || gameSourcesQuery.isFetching.value + : instancesQuery.isPending.value || instancesQuery.isFetching.value, + ) + const sourcesError = computed(() => + needsGameOptionsSource.value ? gameSourcesQuery.isError.value : instancesQuery.isError.value, + ) + const allSynced = computed( + () => draftInitialized.value && syncUpdateOptions.every((option) => draftOptions.value[option]), + ) + + function initializeDraft() { + const globalOptions = globalOptionsQuery.data.value + if (!globalOptions) return + + const options = createOptionState() + for (const option of syncUpdateOptions) { + options[option] = globalOptions[option] + } + initialOptions.value = { ...options } + draftOptions.value = options + draftSourceInstanceIds.value = {} + draftInitialized.value = true + } + + watch( + () => globalOptionsQuery.data.value, + () => { + if (isOpen.value && !draftInitialized.value) initializeDraft() + }, + ) + + watch([sources, sourceOptions], ([candidates]) => { + if (!candidates.some((source) => source.id === sourceInstanceId.value && source.eligible)) { + sourceInstanceId.value = candidates.find((source) => source.eligible)?.id ?? '' + } + }) + + const syncMutation = useMutation({ + mutationKey: syncedOptionsKeys.set, + mutationFn: async ( + changes: { + option: SyncUpdateOption + enabled: boolean + baseInstanceId?: string + }[], + ) => { + for (const { option, enabled, baseInstanceId } of changes) { + const updated = await set_global_synced_option(option, enabled, baseInstanceId) + queryClient.setQueryData(syncedOptionsKeys.global, updated) + initialOptions.value[option] = enabled + } + }, + onMutate: () => queryClient.cancelQueries({ queryKey: syncedOptionsKeys.global }), + onError: handleError, + onSettled: () => + Promise.all([ + queryClient.invalidateQueries({ queryKey: syncedOptionsKeys.global }), + queryClient.invalidateQueries({ queryKey: syncedOptionsKeys.initialized }), + queryClient.invalidateQueries({ queryKey: syncedOptionsKeys.gameSources }), + queryClient.invalidateQueries({ queryKey: ['instance-synced-options'] }), + queryClient.invalidateQueries({ queryKey: instanceKeys.all }), + queryClient.invalidateQueries({ queryKey: ['worlds'] }), + queryClient.invalidateQueries({ queryKey: syncedPackKeys.all }), + ]), + }) + + function beginDraft() { + isOpen.value = true + draftInitialized.value = false + sourceOptions.value = [] + sourceInstanceId.value = '' + initializeDraft() + } + + function finishDraft() { + isOpen.value = false + draftInitialized.value = false + draftSourceInstanceIds.value = {} + sourceOptions.value = [] + sourceInstanceId.value = '' + } + + function stageOptions( + options: readonly SyncUpdateOption[], + enabled: boolean, + baseInstanceId?: string, + ) { + for (const option of options.filter(isSyncedOptionAvailable)) { + draftOptions.value[option] = enabled + if (enabled && !initialOptions.value[option] && baseInstanceId) { + draftSourceInstanceIds.value[option] = baseInstanceId + } else { + draftSourceInstanceIds.value[option] = undefined + } + } + } + + function isInitiallyEnabled(option: SyncUpdateOption) { + return initialOptions.value[option] + } + + async function applyDraft() { + if (!draftInitialized.value) return + + const changes = syncUpdateOptions + .filter((option) => draftOptions.value[option] !== initialOptions.value[option]) + .map((option) => ({ + option, + enabled: draftOptions.value[option], + baseInstanceId: draftOptions.value[option] + ? draftSourceInstanceIds.value[option] + : undefined, + })) + if (changes.length === 0) return + + await syncMutation.mutateAsync(changes) + draftSourceInstanceIds.value = {} + } + + function chooseSource(options: readonly SyncUpdateOption[]) { + sourceInstanceId.value = '' + sourceOptions.value = options.filter(isSyncedOptionAvailable) + } + + function retrySources() { + return needsGameOptionsSource.value + ? gameSourcesQuery.refetch({ cancelRefetch: false }) + : instancesQuery.refetch({ cancelRefetch: false }) + } + + return { + isOpen, + globalOptionsQuery, + allSynced, + draftInitialized, + draftOptions, + syncMutation, + sourceOptions, + sourceInstanceId, + sources, + sourcesLoading, + sourcesError, + beginDraft, + finishDraft, + stageOptions, + isInitiallyEnabled, + applyDraft, + chooseSource, + retrySources, + } +} diff --git a/apps/app-frontend/src/components/ui/world/WorldItem.vue b/apps/app-frontend/src/components/ui/world/WorldItem.vue index c226d7be0d..480c133f6e 100644 --- a/apps/app-frontend/src/components/ui/world/WorldItem.vue +++ b/apps/app-frontend/src/components/ui/world/WorldItem.vue @@ -36,6 +36,7 @@ import { useVIntl, } from '@modrinth/ui' import { getPingLevel } from '@modrinth/utils' +import { autoToHTML } from '@sfirew/minecraft-motd-parser' import dayjs from 'dayjs' import { Tooltip } from 'floating-vue' import type { Component } from 'vue' @@ -276,6 +277,15 @@ const messages = defineMessages({ }, }) +const incompatibleVersionTooltip = computed(() => ({ + content: `${autoToHTML( + formatMessage(messages.incompatibleVersion, { + version: props.serverStatus?.version?.name ?? '', + }), + )}`, + html: true, +})) + const cardOptions = useTemplateRef('cardOptions') const showStop = computed( () => @@ -507,16 +517,12 @@ function openContextMenu(event: MouseEvent) { {{ formatMessage(commonMessages.loadingLabel) }}