feat: preferences syncing frontend (#7192)

* feat: prefs frontend

* fix: DI issue

* fix: lint

* feat: appearance settings cleanup + fix settings, remove pinia

* fix: sync btn when logged out

* fix: prepr

* fix: sidebar issue

* feat: language coverage + cleanup

* feat: cleanup lang settings

* fix: fmt

* fix: CI

* fix: ci

* fix: storybook

* feat: bring back loader/game version sort

---------

Co-authored-by: tdgao <mr.trumgao@gmail.com>
This commit is contained in:
Calum H.
2026-08-20 07:23:01 +00:00
committed by GitHub
co-authored by tdgao
parent 79fcf41316
commit 3e07267e10
191 changed files with 3356 additions and 4372 deletions
@@ -107,6 +107,7 @@ import type { Ref } from 'vue'
import { computed, ref } from 'vue'
import { useAppEvent } from '@/composables/use-app-event'
import { handleSevereError } from '@/composables/use-error.js'
import { trackEvent } from '@/helpers/analytics'
import {
get_default_user,
@@ -118,7 +119,6 @@ import {
import { getPlayerHeadUrl } from '@/helpers/rendering/batch-skin-renderer.ts'
import type { Skin } from '@/helpers/skins'
import { get_available_skins } from '@/helpers/skins'
import { handleSevereError } from '@/store/error.js'
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
@@ -20,11 +20,11 @@ import { computed, ref } from 'vue'
import { ChatIcon } from '@/assets/icons'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { handleSevereError } from '@/composables/use-error.js'
import { trackEvent } from '@/helpers/analytics'
import { login as login_flow, set_default_user } from '@/helpers/auth.js'
import { install_existing_instance } from '@/helpers/install'
import { cancel_directory_change } from '@/helpers/settings.ts'
import { handleSevereError } from '@/store/error.js'
const { handleError } = injectNotificationManager()
@@ -20,12 +20,12 @@ import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAppEvent } from '@/composables/use-app-event'
import { handleSevereError } from '@/composables/use-error.js'
import { trackEvent } from '@/helpers/analytics'
import { install_existing_instance, install_pack_to_existing_instance } from '@/helpers/install'
import { getInstanceIconUrl, kill, run } from '@/helpers/instance'
import { get_by_instance_id } from '@/helpers/process'
import { showInstanceInFolder } from '@/helpers/utils.js'
import { handleSevereError } from '@/store/error.js'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
@@ -39,17 +39,17 @@ import { getCurrentWindow } from '@tauri-apps/api/window'
import { saveWindowState, StateFlags } from '@tauri-apps/plugin-window-state'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useAppSettings } from '@/composables/use-app-settings.ts'
import { get as getSettings } from '@/helpers/settings.ts'
import { getOS } from '@/helpers/utils.js'
import { useTheming } from '@/store/state'
const themeStore = useTheming()
const appSettings = useAppSettings()
const nativeDecorations = ref(true)
const isMaximized = ref(false)
const os = ref('')
const alwaysShowAppControls = computed(() => themeStore.getFeatureFlag('always_show_app_controls'))
const alwaysShowAppControls = computed(() => appSettings.getFeatureFlag('always_show_app_controls'))
const showControls = computed(
() =>
@@ -15,17 +15,17 @@ import { computed, ref } from 'vue'
import FriendsSection from '@/components/ui/friends/FriendsSection.vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { useAppSettings } from '@/composables/use-app-settings.ts'
import { useFriends } from '@/composables/use-friends'
import type { FriendWithUserData } from '@/helpers/friends.ts'
import type { ModrinthCredentials } from '@/helpers/mr_auth'
import { get as getSettings, set as setSettings } from '@/helpers/settings.ts'
import { useTheming } from '@/store/state'
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const formatRelativeTime = useRelativeTime()
const themeStore = useTheming()
const appSettings = useAppSettings()
const props = defineProps<{
credentials: ModrinthCredentials | null
@@ -39,11 +39,11 @@ type FriendsSectionCollapsedFlag =
| 'friends_pending_collapsed'
function isFriendsSectionCollapsed(flag: FriendsSectionCollapsedFlag) {
return themeStore.getFeatureFlag(flag)
return appSettings.getFeatureFlag(flag)
}
function setFriendsSectionCollapsed(flag: FriendsSectionCollapsedFlag, collapsed: boolean) {
themeStore.featureFlags[flag] = collapsed
appSettings.featureFlags[flag] = collapsed
getSettings()
.then((settings) => {
settings.feature_flags[flag] = collapsed
@@ -13,11 +13,10 @@
import { UnknownFileWarningModal } from '@modrinth/ui'
import { ref, useTemplateRef } from 'vue'
import { type FeatureFlag, useAppSettings } from '@/composables/use-app-settings.ts'
import { get as getSettings, set as setSettings } from '@/helpers/settings'
import { useTheming } from '@/store/state'
import type { FeatureFlag } from '@/store/theme.ts'
const themeStore = useTheming()
const appSettings = useAppSettings()
const skipUnknownPackWarningFeatureFlag = 'skip_unknown_pack_warning' as FeatureFlag
const modal = useTemplateRef('modal')
@@ -34,7 +33,7 @@ function show(
fileName.value = selectedFileName
externalFilesInModpack.value = selectedExternalFiles
if (themeStore.getFeatureFlag(skipUnknownPackWarningFeatureFlag)) {
if (appSettings.getFeatureFlag(skipUnknownPackWarningFeatureFlag)) {
void createInstance()
return
}
@@ -50,7 +49,7 @@ function reset() {
async function proceed(dontShowAgain: boolean) {
if (dontShowAgain) {
themeStore.featureFlags[skipUnknownPackWarningFeatureFlag] = true
appSettings.featureFlags[skipUnknownPackWarningFeatureFlag] = true
const settings = await getSettings()
settings.feature_flags[skipUnknownPackWarningFeatureFlag] = true
await setSettings(settings)
@@ -9,13 +9,13 @@ import { useRouter } from 'vue-router'
import InstanceCardView from '@/components/ui/library/instance-group/instance-card-view.vue'
import { getLibraryInstanceSelectionKey, useLibrary } from '@/components/ui/library/use-library'
import { useAppEvent } from '@/composables/use-app-event'
import { handleSevereError } from '@/composables/use-error.js'
import { trackEvent } from '@/helpers/analytics'
import { install_existing_instance, install_pack_to_existing_instance } from '@/helpers/install'
import { kill, run } from '@/helpers/instance'
import { get_by_instance_id } from '@/helpers/process'
import type { GameInstance } from '@/helpers/types'
import { showInstanceInFolder } from '@/helpers/utils.js'
import { handleSevereError } from '@/store/error.js'
type ProcessEvent = 'installing' | 'launched' | 'finished'
@@ -15,6 +15,8 @@ const { formatMessage } = useVIntl()
const messages = defineMessages({
name: { id: 'app.library.sort.name', defaultMessage: 'Name' },
loaderSort: { id: 'app.library.sort.loader', defaultMessage: 'Loader' },
gameVersionSort: { id: 'app.library.sort.game-version', defaultMessage: 'Game version' },
lastPlayed: { id: 'app.library.sort.last-played', defaultMessage: 'Last played' },
hoursPlayed: { id: 'app.library.sort.hours-played', defaultMessage: 'Hours played' },
dateCreated: { id: 'app.library.sort.date-created', defaultMessage: 'Date created' },
@@ -34,6 +36,8 @@ const sortLabels = {
'Hours played': messages.hoursPlayed,
'Date created': messages.dateCreated,
'Date modified': messages.dateModified,
Loader: messages.loaderSort,
'Game version': messages.gameVersionSort,
}
const groupLabels = {
@@ -60,6 +64,7 @@ const groupOptions: ComboboxOption<LibraryGroupBy>[] = libraryGroupOptions.map((
class="w-max"
:options="sortOptions"
:show-icon-in-selected="false"
:max-height="320"
dropdown-min-width="160px"
>
<template #prefix>
@@ -36,6 +36,8 @@ export const librarySortOptions = [
'Hours played',
'Date created',
'Date modified',
'Loader',
'Game version',
] as const
export const libraryGroupOptions = [
@@ -328,6 +330,18 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
case 'Name':
visibleInstances.sort((a, b) => a.name.localeCompare(b.name))
break
case 'Loader':
visibleInstances.sort((a, b) =>
formatLoader(formatMessage, a.loader).localeCompare(
formatLoader(formatMessage, b.loader),
),
)
break
case 'Game version':
visibleInstances.sort((a, b) =>
a.game_version.localeCompare(b.game_version, undefined, { numeric: true }),
)
break
case 'Last played':
visibleInstances.sort((a, b) => dayjs(b.last_played ?? 0).diff(dayjs(a.last_played ?? 0)))
break
@@ -10,8 +10,8 @@ import {
import { Admonition, Button, ButtonLink, Collapsible, IconButton, NewModal } from '@modrinth/ui'
import { computed, ref } from 'vue'
import { handleSevereError } from '@/composables/use-error.js'
import { login as login_flow, set_default_user } from '@/helpers/auth.js'
import { handleSevereError } from '@/store/error.js'
import { findMinecraftAuthError, type MinecraftAuthError } from './minecraft-auth-errors'
@@ -63,9 +63,9 @@ import { inject, type Ref, ref } from 'vue'
import steveImage from '@/assets/steve-look-up-left.webp'
import type AccountsCard from '@/components/ui/AccountsCard.vue'
import { handleSevereError } from '@/composables/use-error.js'
import { trackEvent } from '@/helpers/analytics'
import { login as loginFlow, set_default_user } from '@/helpers/auth.js'
import { handleSevereError } from '@/store/error.js'
const { formatMessage } = useVIntl()
const accountsCard = inject('accountsCard') as Ref<InstanceType<typeof AccountsCard> | null>
@@ -36,16 +36,16 @@ import LanguageSettings from '@/components/ui/settings/display/LanguageSettings.
import DefaultInstanceSettings from '@/components/ui/settings/instances/DefaultInstanceSettings.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'
import { get, set } from '@/helpers/settings.ts'
import {
appSettingsModalContextKey,
type UnsavedChangesController,
} from '@/providers/app-settings-modal'
import { injectAppUpdateDownloadProgress } from '@/providers/download-progress.ts'
import { useTheming } from '@/store/state'
// TODO: Apply COMPONENT_STRUCTURE.md here and extract out common setting option components
const themeStore = useTheming()
const appSettings = useAppSettings()
const { formatMessage } = useVIntl()
@@ -157,7 +157,9 @@ const tabs = [
},
]
const availableTabs = computed(() => tabs.filter((tab) => !tab.developerOnly || themeStore.devMode))
const availableTabs = computed(() =>
tabs.filter((tab) => !tab.developerOnly || appSettings.devMode),
)
const modal = ref<InstanceType<typeof TabbedModal> | null>(null)
const unsavedChangesPopup = ref<{ nudge: () => void } | null>(null)
@@ -170,10 +172,19 @@ const modifiedUnsavedChangesState = computed(
() => unsavedChangesController.value?.getModified() ?? emptyUnsavedChangesState,
)
const savingUnsavedChanges = computed(() => unsavedChangesController.value?.isSaving() ?? false)
const hasUnsavedChanges = computed(() => unsavedChangesController.value?.hasChanges() ?? false)
const hasUnsavedChanges = computed(
() =>
(unsavedChangesController.value?.hasChanges() ?? false) ||
(unsavedChangesController.value?.isSaving() ?? false),
)
function canLeaveCurrentTab(): boolean {
if (!unsavedChangesController.value?.hasChanges()) return true
if (
!unsavedChangesController.value?.hasChanges() &&
!unsavedChangesController.value?.isSaving()
) {
return true
}
unsavedChangesPopup.value?.nudge()
return false
}
@@ -233,8 +244,8 @@ function devModeCount() {
if (devModeCounter.value > 5) {
const selectedTab = modal.value ? availableTabs.value[modal.value.selectedTab] : undefined
themeStore.devMode = !themeStore.devMode
settings.value.developer_mode = !!themeStore.devMode
appSettings.devMode = !appSettings.devMode
settings.value.developer_mode = !!appSettings.devMode
devModeCounter.value = 0
if (modal.value) {
@@ -298,7 +309,7 @@ const messages = defineMessages({
<ProgressBar :progress="progress" />
</template>
</div>
<p v-if="themeStore.devMode" class="text-brand font-semibold m-0 mb-2">
<p v-if="appSettings.devMode" class="text-brand font-semibold m-0 mb-2">
{{ formatMessage(developerModeEnabled) }}
</p>
<div class="flex items-center gap-3">
@@ -306,8 +317,8 @@ const messages = defineMessages({
:aria-label="formatMessage(messages.developerModeButtonLabel)"
class="p-0 m-0 bg-transparent border-none cursor-pointer button-animation"
:class="{
'text-brand': themeStore.devMode,
'text-secondary': !themeStore.devMode,
'text-brand': appSettings.devMode,
'text-secondary': !appSettings.devMode,
}"
@click="devModeCount"
>
@@ -1,5 +1,6 @@
<template>
<AccountSocialSettings
ref="socialSettings"
:get-blocked-users="get_blocked_users"
:get-users="getUsers"
:unblock-user="unblock_user"
@@ -9,10 +10,29 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { AccountSocialSettings, injectModrinthClient } from '@modrinth/ui'
import { inject, onBeforeUnmount, onMounted, ref } from 'vue'
import { get_blocked_users, unblock_user } from '@/helpers/users'
import { appSettingsModalContextKey } from '@/providers/app-settings-modal'
const client = injectModrinthClient()
const settingsModal = inject(appSettingsModalContextKey, null)
const socialSettings = ref<InstanceType<typeof AccountSocialSettings> | null>(null)
onMounted(() => {
settingsModal?.registerUnsavedChangesController({
hasChanges: () => socialSettings.value?.hasChanges ?? false,
getOriginal: () => socialSettings.value?.originalState ?? {},
getModified: () => socialSettings.value?.modifiedState ?? {},
isSaving: () => socialSettings.value?.saving ?? false,
reset: () => socialSettings.value?.reset(),
save: () => socialSettings.value?.save(),
})
})
onBeforeUnmount(() => {
settingsModal?.registerUnsavedChangesController(null)
})
function getUsers(userIds: string[]): Promise<Labrinth.Users.v2.User[]> {
return client.labrinth.users_v2.getMultiple(userIds)
@@ -1,108 +1,154 @@
<script setup lang="ts">
import { defineMessages, ThemeSelector, Toggle, useVIntl } from '@modrinth/ui'
import { computed, ref, watch } from 'vue'
import {
AppearanceSettingsLayout,
injectAuth,
injectUserPreferences,
provideAppearanceSettings,
useSavable,
} from '@modrinth/ui'
import { computed, inject, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { get, set } from '@/helpers/settings.ts'
import { type ColorTheme, useTheme } from '@/composables/use-theme.ts'
import { type AppSettings, get, set } from '@/helpers/settings.ts'
import { getOS } from '@/helpers/utils'
import { useTheming } from '@/store/state'
import type { ColorTheme } from '@/store/theme.ts'
import { appSettingsModalContextKey } from '@/providers/app-settings-modal'
const themeStore = useTheming()
const { formatMessage } = useVIntl()
const messages = defineMessages({
colorThemeTitle: {
id: 'app.appearance-settings.color-theme.title',
defaultMessage: 'Color theme',
},
colorThemeDescription: {
id: 'app.appearance-settings.color-theme.description',
defaultMessage: 'Choose the color theme used by Modrinth App.',
},
advancedRenderingTitle: {
id: 'app.appearance-settings.advanced-rendering.title',
defaultMessage: 'Advanced rendering',
},
advancedRenderingDescription: {
id: 'app.appearance-settings.advanced-rendering.description',
defaultMessage:
'Enable visual effects such as background blur. This may reduce performance without hardware acceleration.',
},
nativeDecorationsTitle: {
id: 'app.appearance-settings.native-decorations.title',
defaultMessage: 'System window frame',
},
nativeDecorationsDescription: {
id: 'app.appearance-settings.native-decorations.description',
defaultMessage:
"Use your operating system's title bar and window controls. Requires an app restart.",
},
})
const os = ref(await getOS())
const theme = useTheme()
const auth = injectAuth()
const { updatePreferences } = injectUserPreferences()
const settingsModal = inject(appSettingsModalContextKey, null)
const os = await getOS()
const settings = ref(await get())
const themeOptions = computed(() =>
themeStore
.getThemeOptions()
.filter((theme) => theme !== 'retro' || themeStore.devMode || settings.value.theme === 'retro'),
type AppearanceSettingsState = {
theme: ColorTheme
syncAcrossDevices: boolean
advancedRendering: boolean
nativeDecorations: boolean
}
function getAppearanceSettingsState(settings: AppSettings): AppearanceSettingsState {
return {
theme: settings.theme,
syncAcrossDevices: settings.sync_theme_across_devices,
advancedRendering: settings.advanced_rendering,
nativeDecorations: settings.native_decorations,
}
}
const { saved, current, changes, saving, hasChanges, reset, save } = useSavable(
() => getAppearanceSettingsState(settings.value),
async (appearanceChanges) => {
const value = current.value
if (
value.syncAcrossDevices &&
auth.user.value &&
(appearanceChanges.theme !== undefined || appearanceChanges.syncAcrossDevices !== undefined)
) {
await updatePreferences({
appearance: value.theme === 'system' ? { auto: true } : { auto: false, theme: value.theme },
})
}
const nextSettings: AppSettings = {
...settings.value,
theme: value.theme,
sync_theme_across_devices: value.syncAcrossDevices,
advanced_rendering: value.advancedRendering,
native_decorations: value.nativeDecorations,
}
await set(nextSettings)
settings.value = nextSettings
theme.preferred = value.theme
theme.syncAcrossDevices = value.syncAcrossDevices
theme.advancedRendering = value.advancedRendering
},
)
const themeOptions = computed(() =>
theme.options.filter(
(option) =>
option !== 'retro' || settings.value.developer_mode || current.value.theme === 'retro',
),
)
function setTheme(value: ColorTheme): void {
current.value.theme = value
}
function setSyncAcrossDevices(enabled: boolean): void {
current.value.syncAcrossDevices = enabled
}
function setAdvancedRendering(enabled: boolean): void {
current.value.advancedRendering = enabled
}
function setNativeDecorations(enabled: boolean): void {
current.value.nativeDecorations = enabled
}
watch(
settings,
async () => {
await set(settings.value)
[() => current.value.theme, () => saved.value.theme],
([selectedTheme, savedTheme]) => {
theme.preview = selectedTheme === savedTheme ? null : selectedTheme
},
{ deep: true },
{ immediate: true },
)
</script>
<template>
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.colorThemeTitle) }}
</h2>
<p class="m-0 mt-1">{{ formatMessage(messages.colorThemeDescription) }}</p>
async function saveAppearanceSettings(): Promise<void> {
try {
await save()
} catch {
return
}
}
<ThemeSelector
:update-color-theme="
(theme: ColorTheme) => {
themeStore.setThemeState(theme)
settings.theme = theme
}
"
:current-theme="settings.theme"
:theme-options="themeOptions"
system-theme-color="system"
/>
onMounted(() => {
settingsModal?.registerUnsavedChangesController({
hasChanges: () => hasChanges.value,
getOriginal: () => saved.value,
getModified: () => changes.value,
isSaving: () => saving.value,
reset,
save: saveAppearanceSettings,
})
})
<div class="mt-6 flex items-center justify-between">
<div>
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.advancedRenderingTitle) }}
</h2>
<p class="m-0 mt-1">
{{ formatMessage(messages.advancedRenderingDescription) }}
</p>
</div>
onBeforeUnmount(() => {
theme.preview = null
settingsModal?.registerUnsavedChangesController(null)
})
<Toggle
id="advanced-rendering"
:model-value="themeStore.advancedRendering"
@update:model-value="
(e) => {
themeStore.advancedRendering = !!e
settings.advanced_rendering = themeStore.advancedRendering
provideAppearanceSettings({
deferPersistence: true,
theme: {
current: computed(() => current.value.theme),
options: themeOptions,
system: computed(() => theme.native),
set: setTheme,
syncAcrossDevices: {
value: computed(() => current.value.syncAcrossDevices),
set: setSyncAcrossDevices,
},
syncDisabled: computed(() => !auth.user.value),
},
advancedRendering: {
value: computed(() => current.value.advancedRendering),
set: setAdvancedRendering,
},
nativeDecorations:
os !== 'MacOS'
? {
value: computed(() => current.value.nativeDecorations),
set: setNativeDecorations,
}
"
/>
</div>
: undefined,
updatePreferences,
})
</script>
<div v-if="os !== 'MacOS'" class="mt-6 flex items-center justify-between gap-4">
<div>
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.nativeDecorationsTitle) }}
</h2>
<p class="m-0 mt-1">{{ formatMessage(messages.nativeDecorationsDescription) }}</p>
</div>
<Toggle id="native-decorations" v-model="settings.native_decorations" />
</div>
<template>
<AppearanceSettingsLayout />
</template>
@@ -1,13 +1,27 @@
<script setup lang="ts">
import { defineMessages, Toggle, useVIntl } from '@modrinth/ui'
import { ref, watch } from 'vue'
import {
defineMessages,
injectAuth,
injectUserPreferences,
Toggle,
useSavable,
useVIntl,
} from '@modrinth/ui'
import { inject, onBeforeUnmount, onMounted, ref } from 'vue'
import { get, set } from '@/helpers/settings.ts'
import { useTheming } from '@/store/state'
import type { FeatureFlag } from '@/store/theme.ts'
import {
DEFAULT_FEATURE_FLAGS,
type FeatureFlag,
useAppSettings,
} from '@/composables/use-app-settings.ts'
import { type AppSettings, get, set } from '@/helpers/settings.ts'
import { appSettingsModalContextKey } from '@/providers/app-settings-modal'
const themeStore = useTheming()
const appSettings = useAppSettings()
const { formatMessage } = useVIntl()
const auth = injectAuth()
const { updatePreferences } = injectUserPreferences()
const settingsModal = inject(appSettingsModalContextKey, null)
const worldsInHomeFlag: FeatureFlag = 'worlds_in_home'
const skipNonEssentialWarningsFlag: FeatureFlag = 'skip_non_essential_warnings'
@@ -15,6 +29,19 @@ const skipUnknownPackWarningFlag: FeatureFlag = 'skip_unknown_pack_warning'
const showPlayTimeFlag: FeatureFlag = 'show_instance_play_time'
const messages = defineMessages({
syncAcrossDevicesTitle: {
id: 'app.behavior-settings.sync-across-devices.title',
defaultMessage: 'Sync behavior across devices',
},
syncAcrossDevicesDescription: {
id: 'app.behavior-settings.sync-across-devices.description',
defaultMessage:
"Use these behavior settings everywhere you're signed in. Turn this off to keep separate settings on this device.",
},
syncAcrossDevicesSignedOutTooltip: {
id: 'app.behavior-settings.sync-across-devices.signed-out-tooltip',
defaultMessage: 'Sign into a Modrinth account to sync settings.',
},
startupAndNavigationTitle: {
id: 'app.behavior-settings.startup-and-navigation.title',
defaultMessage: 'Startup and navigation',
@@ -96,18 +123,136 @@ const messages = defineMessages({
},
})
const settings = ref(await get())
type BehaviorSettingsState = {
syncBehaviorAcrossDevices: boolean
minimizeApp: boolean
hideRightSidebar: boolean
showJumpIn: boolean
showPlayTime: boolean
hideNametag: boolean
warnOnUnknownModpacks: boolean
skipNonEssentialWarnings: boolean
}
watch(
settings,
const persistedSettings = ref(await get())
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],
showPlayTime:
settings.feature_flags[showPlayTimeFlag] ?? DEFAULT_FEATURE_FLAGS[showPlayTimeFlag],
hideNametag: settings.hide_nametag_skins_page,
warnOnUnknownModpacks: !(
settings.feature_flags[skipUnknownPackWarningFlag] ??
DEFAULT_FEATURE_FLAGS[skipUnknownPackWarningFlag]
),
skipNonEssentialWarnings:
settings.feature_flags[skipNonEssentialWarningsFlag] ??
DEFAULT_FEATURE_FLAGS[skipNonEssentialWarningsFlag],
}
}
const { saved, current, changes, saving, hasChanges, reset, save } = useSavable(
() => getBehaviorSettingsState(persistedSettings.value),
async () => {
await set(settings.value)
const value = current.value
if (value.syncBehaviorAcrossDevices && auth.user.value) {
await updatePreferences({
behavior: {
minimize_app: value.minimizeApp,
hide_right_sidebar: value.hideRightSidebar,
show_jump_in: value.showJumpIn,
show_play_time: value.showPlayTime,
hide_nametag: value.hideNametag,
warn_on_unknown_modpacks: value.warnOnUnknownModpacks,
skip_non_essential_warnings: value.skipNonEssentialWarnings,
},
})
}
const nextSettings: AppSettings = {
...persistedSettings.value,
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,
[showPlayTimeFlag]: value.showPlayTime,
[skipUnknownPackWarningFlag]: !value.warnOnUnknownModpacks,
[skipNonEssentialWarningsFlag]: value.skipNonEssentialWarnings,
},
}
await set(nextSettings)
persistedSettings.value = nextSettings
appSettings.setBehaviorSyncAcrossDevices(value.syncBehaviorAcrossDevices)
appSettings.toggleSidebar = value.hideRightSidebar
appSettings.hideNametagSkinsPage = value.hideNametag
appSettings.featureFlags[worldsInHomeFlag] = value.showJumpIn
appSettings.featureFlags[showPlayTimeFlag] = value.showPlayTime
appSettings.featureFlags[skipUnknownPackWarningFlag] = !value.warnOnUnknownModpacks
appSettings.featureFlags[skipNonEssentialWarningsFlag] = value.skipNonEssentialWarnings
},
{ deep: true },
)
async function saveBehaviorSettings(): Promise<void> {
try {
await save()
} catch {
return
}
}
onMounted(() => {
settingsModal?.registerUnsavedChangesController({
hasChanges: () => hasChanges.value,
getOriginal: () => saved.value,
getModified: () => changes.value,
isSaving: () => saving.value,
reset,
save: saveBehaviorSettings,
})
})
onBeforeUnmount(() => {
settingsModal?.registerUnsavedChangesController(null)
})
</script>
<template>
<section>
<section class="border-0 border-b border-solid border-divider pb-6">
<div class="flex items-center justify-between gap-4">
<div>
<h2 id="sync-behavior-across-devices-label" class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.syncAcrossDevicesTitle) }}
</h2>
<p class="m-0 mt-1 text-secondary">
{{ formatMessage(messages.syncAcrossDevicesDescription) }}
</p>
</div>
<span
v-tooltip="
!auth.user.value ? formatMessage(messages.syncAcrossDevicesSignedOutTooltip) : undefined
"
class="inline-flex shrink-0"
>
<Toggle
id="sync-behavior-across-devices"
:model-value="Boolean(auth.user.value) && current.syncBehaviorAcrossDevices"
:disabled="!auth.user.value"
aria-labelledby="sync-behavior-across-devices-label"
@update:model-value="current.syncBehaviorAcrossDevices = $event"
/>
</span>
</div>
</section>
<section class="mt-6">
<h2 class="m-0 text-xl font-semibold text-contrast">
{{ formatMessage(messages.startupAndNavigationTitle) }}
</h2>
@@ -121,7 +266,7 @@ watch(
{{ formatMessage(messages.minimizeLauncherDescription) }}
</p>
</div>
<Toggle id="minimize-launcher" v-model="settings.hide_on_process_start" />
<Toggle id="minimize-launcher" v-model="current.minimizeApp" />
</div>
<div class="flex items-center justify-between gap-4">
@@ -131,16 +276,7 @@ watch(
</h3>
<p class="m-0 mt-1">{{ formatMessage(messages.toggleSidebarDescription) }}</p>
</div>
<Toggle
id="toggle-sidebar"
:model-value="settings.toggle_sidebar"
@update:model-value="
(e) => {
settings.toggle_sidebar = !!e
themeStore.toggleSidebar = settings.toggle_sidebar
}
"
/>
<Toggle id="toggle-sidebar" v-model="current.hideRightSidebar" />
</div>
</div>
</section>
@@ -159,17 +295,7 @@ watch(
{{ formatMessage(messages.jumpBackIntoWorldsDescription) }}
</p>
</div>
<Toggle
id="jump-back-into-worlds"
:model-value="themeStore.getFeatureFlag(worldsInHomeFlag)"
@update:model-value="
() => {
const newValue = !themeStore.getFeatureFlag(worldsInHomeFlag)
themeStore.featureFlags[worldsInHomeFlag] = newValue
settings.feature_flags[worldsInHomeFlag] = newValue
}
"
/>
<Toggle id="jump-back-into-worlds" v-model="current.showJumpIn" />
</div>
<div class="flex items-center justify-between gap-4">
@@ -179,17 +305,7 @@ watch(
</h3>
<p class="m-0 mt-1">{{ formatMessage(messages.showPlayTimeDescription) }}</p>
</div>
<Toggle
id="show-play-time"
:model-value="themeStore.getFeatureFlag(showPlayTimeFlag)"
@update:model-value="
() => {
const newValue = !themeStore.getFeatureFlag(showPlayTimeFlag)
themeStore.featureFlags[showPlayTimeFlag] = newValue
settings.feature_flags[showPlayTimeFlag] = newValue
}
"
/>
<Toggle id="show-play-time" v-model="current.showPlayTime" />
</div>
<div class="flex items-center justify-between gap-4">
@@ -199,16 +315,7 @@ watch(
</h3>
<p class="m-0 mt-1">{{ formatMessage(messages.hideNametagDescription) }}</p>
</div>
<Toggle
id="hide-nametag-skins-page"
:model-value="themeStore.hideNametagSkinsPage"
@update:model-value="
(e) => {
themeStore.hideNametagSkinsPage = !!e
settings.hide_nametag_skins_page = themeStore.hideNametagSkinsPage
}
"
/>
<Toggle id="hide-nametag-skins-page" v-model="current.hideNametag" />
</div>
</div>
</section>
@@ -229,15 +336,7 @@ watch(
</div>
<Toggle
id="warn-before-installing-unknown-modpacks"
:model-value="!themeStore.getFeatureFlag(skipUnknownPackWarningFlag)"
@update:model-value="
(e) => {
const warnBeforeUnknownPackInstall = !!e
const skipUnknownPackWarning = !warnBeforeUnknownPackInstall
themeStore.featureFlags[skipUnknownPackWarningFlag] = skipUnknownPackWarning
settings.feature_flags[skipUnknownPackWarningFlag] = skipUnknownPackWarning
}
"
v-model="current.warnOnUnknownModpacks"
/>
</div>
@@ -250,17 +349,7 @@ watch(
{{ formatMessage(messages.skipNonEssentialWarningsDescription) }}
</p>
</div>
<Toggle
id="skip-non-essential-warnings"
:model-value="themeStore.getFeatureFlag(skipNonEssentialWarningsFlag)"
@update:model-value="
() => {
const newValue = !themeStore.getFeatureFlag(skipNonEssentialWarningsFlag)
themeStore.featureFlags[skipNonEssentialWarningsFlag] = newValue
settings.feature_flags[skipNonEssentialWarningsFlag] = newValue
}
"
/>
<Toggle id="skip-non-essential-warnings" v-model="current.skipNonEssentialWarnings" />
</div>
</div>
</section>
@@ -2,17 +2,20 @@
import { Button, Toggle } from '@modrinth/ui'
import { ref, watch } from 'vue'
import {
DEFAULT_FEATURE_FLAGS,
type FeatureFlag,
useAppSettings,
} from '@/composables/use-app-settings.ts'
import { get as getSettings, set as setSettings } from '@/helpers/settings.ts'
import { useTheming } from '@/store/state'
import { DEFAULT_FEATURE_FLAGS, type FeatureFlag } from '@/store/theme.ts'
const themeStore = useTheming()
const appSettings = useAppSettings()
const settings = ref(await getSettings())
const options = ref<FeatureFlag[]>(Object.keys(DEFAULT_FEATURE_FLAGS))
const options = ref(Object.keys(DEFAULT_FEATURE_FLAGS) as FeatureFlag[])
function setFeatureFlag(key: string, value: boolean) {
themeStore.featureFlags[key] = value
function setFeatureFlag(key: FeatureFlag, value: boolean) {
appSettings.featureFlags[key] = value
settings.value.feature_flags[key] = value
}
@@ -35,15 +38,15 @@ watch(
<div class="flex items-center gap-2">
<Button
type="quiet"
:disabled="themeStore.getFeatureFlag(option) === DEFAULT_FEATURE_FLAGS[option]"
:disabled="appSettings.getFeatureFlag(option) === DEFAULT_FEATURE_FLAGS[option]"
@click="setFeatureFlag(option, DEFAULT_FEATURE_FLAGS[option])"
>
Reset to default
</Button>
<Toggle
id="advanced-rendering"
:model-value="themeStore.getFeatureFlag(option)"
@update:model-value="() => setFeatureFlag(option, !themeStore.getFeatureFlag(option))"
:model-value="appSettings.getFeatureFlag(option)"
@update:model-value="() => setFeatureFlag(option, !appSettings.getFeatureFlag(option))"
/>
</div>
</div>
@@ -1,74 +1,35 @@
<template>
<SharedLanguageSettings ref="languageSettings" product="app" :persist-locale="persistLocale" />
</template>
<script setup lang="ts">
import {
Admonition,
AutoLink,
commonSettingsMessages,
IntlFormatted,
LanguageSelector,
languageSelectorMessages,
LOCALES,
useVIntl,
} from '@modrinth/ui'
import { computed, ref, watch } from 'vue'
import { LanguageSettings as SharedLanguageSettings } from '@modrinth/ui'
import { inject, onBeforeUnmount, onMounted, ref } from 'vue'
import { get, set } from '@/helpers/settings.ts'
import i18n from '@/i18n.config'
import { appSettingsModalContextKey } from '@/providers/app-settings-modal'
const { formatMessage } = useVIntl()
const settingsModal = inject(appSettingsModalContextKey, null)
const languageSettings = ref<InstanceType<typeof SharedLanguageSettings> | null>(null)
const platform = computed(() => formatMessage(languageSelectorMessages.platformApp))
onMounted(() => {
settingsModal?.registerUnsavedChangesController({
hasChanges: () => languageSettings.value?.hasChanges ?? false,
getOriginal: () => languageSettings.value?.originalState ?? {},
getModified: () => languageSettings.value?.modifiedState ?? {},
isSaving: () => languageSettings.value?.saving ?? false,
reset: () => languageSettings.value?.reset(),
save: () => languageSettings.value?.save(),
})
})
const settings = ref(await get())
onBeforeUnmount(() => {
settingsModal?.registerUnsavedChangesController(null)
})
watch(
settings,
async () => {
await set(settings.value)
},
{ deep: true },
)
const $isChanging = ref(false)
async function onLocaleChange(newLocale: string) {
if (settings.value.locale === newLocale) return
$isChanging.value = true
try {
i18n.global.locale.value = newLocale
settings.value.locale = newLocale
} finally {
$isChanging.value = false
}
async function persistLocale(locale: string): Promise<void> {
const settings = await get()
if (settings.locale === locale) return
await set({ ...settings, locale })
}
</script>
<template>
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(commonSettingsMessages.language) }}
</h2>
<Admonition type="warning" class="mt-2 mb-4">
{{ formatMessage(languageSelectorMessages.languageWarning, { platform }) }}
</Admonition>
<p class="m-0 mb-4">
<IntlFormatted
:message-id="languageSelectorMessages.languagesDescription"
:values="{ platform }"
>
<template #~crowdin-link="{ children }">
<AutoLink to="https://translate.modrinth.com">
<component :is="() => children" />
</AutoLink>
</template>
</IntlFormatted>
</p>
<LanguageSelector
:current-locale="settings.locale"
:locales="LOCALES"
:on-locale-change="onLocaleChange"
:is-changing="$isChanging"
/>
</template>
@@ -14,14 +14,14 @@ import { open } from '@tauri-apps/plugin-dialog'
import { ref, watch } from 'vue'
import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue'
import { useAppSettings } from '@/composables/use-app-settings.ts'
import { purge_cache_types } from '@/helpers/cache.js'
import { get, set } from '@/helpers/settings.ts'
import { showAppDbBackupsFolder } from '@/helpers/utils.js'
import { useTheming } from '@/store/state'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const themeStore = useTheming()
const appSettings = useAppSettings()
const settings = ref(await get())
const purgeCacheConfirmModal = ref(null)
const alwaysShowCopyDetailsFlag = 'always_show_copy_details'
@@ -145,7 +145,7 @@ async function purgeCache() {
}
function handlePurgeCacheClick() {
if (themeStore.getFeatureFlag('skip_non_essential_warnings')) {
if (appSettings.getFeatureFlag('skip_non_essential_warnings')) {
void purgeCache()
return
}
@@ -210,11 +210,11 @@ async function findLauncherDir() {
</div>
<Toggle
id="always-show-copy-details"
:model-value="themeStore.getFeatureFlag(alwaysShowCopyDetailsFlag)"
:model-value="appSettings.getFeatureFlag(alwaysShowCopyDetailsFlag)"
@update:model-value="
() => {
const newValue = !themeStore.getFeatureFlag(alwaysShowCopyDetailsFlag)
themeStore.featureFlags[alwaysShowCopyDetailsFlag] = newValue
const newValue = !appSettings.getFeatureFlag(alwaysShowCopyDetailsFlag)
appSettings.featureFlags[alwaysShowCopyDetailsFlag] = newValue
settings.feature_flags[alwaysShowCopyDetailsFlag] = newValue
}
"
@@ -9,6 +9,7 @@ import { useQueryClient } from '@tanstack/vue-query'
import { type Ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useAppSettings } from '@/composables/use-app-settings.ts'
import { get_user } from '@/helpers/cache'
import { toError } from '@/helpers/errors'
import {
@@ -18,7 +19,6 @@ import {
} from '@/helpers/install'
import { list } from '@/helpers/instance'
import { useSharedInstanceErrors } from '@/helpers/shared-instance-errors'
import { useTheming } from '@/store/state'
import { parseSharedInstanceInviteNotification } from './shared-instance-invite-parser'
import type { AppNotification, SharedInstanceInvite } from './shared-instance-invite-types'
@@ -58,7 +58,7 @@ export function useSharedInstanceInviteHandler(
const popupNotificationManager = injectPopupNotificationManager()
const queryClient = useQueryClient()
const router = useRouter()
const themeStore = useTheming()
const appSettings = useAppSettings()
const displayedNotifications = new Set<string | number>()
const displayedNotificationKeys = new Set<string>()
const popupNotificationIds = new Set<string | number>()
@@ -121,7 +121,7 @@ export function useSharedInstanceInviteHandler(
(instance) => instance.shared_instance?.id === sharedInstanceId,
)
if (!existingInstance || themeStore.getFeatureFlag('skip_non_essential_warnings')) {
if (!existingInstance || appSettings.getFeatureFlag('skip_non_essential_warnings')) {
showInstall(preview, install, creator)
return
}
@@ -28,12 +28,12 @@ import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAppEvent } from '@/composables/use-app-event'
import { handleSevereError } from '@/composables/use-error.js'
import { trackEvent } from '@/helpers/analytics'
import { getInstanceIconUrl, kill, run } from '@/helpers/instance'
import { get_by_instance_id } from '@/helpers/process'
import type { GameInstance } from '@/helpers/types'
import { showInstanceInFolder } from '@/helpers/utils'
import { handleSevereError } from '@/store/error'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
@@ -10,6 +10,8 @@ import { computed, onMounted, ref, watch } from 'vue'
import InstanceItem from '@/components/ui/world/InstanceItem.vue'
import WorldItem from '@/components/ui/world/WorldItem.vue'
import { useAppEvent } from '@/composables/use-app-event'
import { useAppSettings } from '@/composables/use-app-settings.ts'
import { handleSevereError } from '@/composables/use-error.js'
import { trackEvent } from '@/helpers/analytics'
import { kill, run } from '@/helpers/instance'
import { get_all } from '@/helpers/process'
@@ -29,8 +31,6 @@ import {
start_join_singleplayer_world,
type WorldWithInstance,
} from '@/helpers/worlds.ts'
import { handleSevereError } from '@/store/error'
import { useTheming } from '@/store/theme.ts'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
@@ -42,7 +42,7 @@ const props = defineProps<{
recentInstances: GameInstance[]
}>()
const theme = useTheming()
const appSettings = useAppSettings()
const jumpBackInItems = ref<JumpBackInItem[]>([])
const loading = ref(true)
@@ -76,7 +76,7 @@ type WorldJumpBackInItem = BaseJumpBackInItem & {
type JumpBackInItem = InstanceJumpBackInItem | WorldJumpBackInItem
const showWorlds = computed(() => theme.getFeatureFlag('worlds_in_home'))
const showWorlds = computed(() => appSettings.getFeatureFlag('worlds_in_home'))
watch([() => props.recentInstances, () => showWorlds.value], async () => {
await populateJumpBackIn().catch(() => {