Files
modeling-app/src/components/SettingsAuthStateProvider.tsx
Frank Noirot 602e7afef6 File based settings (#1361)
* Rename GlobalStateContext to SettingsAuthContext

* Naive initial impl of settings persistence to file system

* Update app identifier in tauri config

* Add "show in folder" tauri command

* Load from and save to file system in Tauri app

* Add documents drive to tauri permission scope

* Add recursive prop to default dir selection dialog

* Add success toast to web restore defaults action

* Add a way to validate read-in settings

* Update imports to use separate settings lib file

* Validate localStorage-loaded settings, combine error message

* Add a e2e test for validation

* Clean up state state bugs

* Reverse validation looping so new users don't error

* update settingsMachine typegen to remove conflicts

* Fmt

* Fix TS errors
2024-02-15 14:14:14 -05:00

231 lines
6.6 KiB
TypeScript

import { useMachine } from '@xstate/react'
import { useNavigate } from 'react-router-dom'
import { paths } from 'lib/paths'
import { authMachine, TOKEN_PERSIST_KEY } from '../machines/authMachine'
import withBaseUrl from '../lib/withBaseURL'
import React, { createContext, useEffect, useRef } from 'react'
import useStateMachineCommands from '../hooks/useStateMachineCommands'
import { settingsMachine } from 'machines/settingsMachine'
import {
initialSettings,
SETTINGS_PERSIST_KEY,
validateSettings,
} from 'lib/settings'
import { toast } from 'react-hot-toast'
import { setThemeClass, Themes } from 'lib/theme'
import {
AnyStateMachine,
ContextFrom,
InterpreterFrom,
Prop,
StateFrom,
} from 'xstate'
import { isTauri } from 'lib/isTauri'
import { settingsCommandBarConfig } from 'lib/commandBarConfigs/settingsCommandConfig'
import { authCommandBarConfig } from 'lib/commandBarConfigs/authCommandConfig'
import { initializeProjectDirectory, readSettingsFile } from 'lib/tauriFS'
type MachineContext<T extends AnyStateMachine> = {
state: StateFrom<T>
context: ContextFrom<T>
send: Prop<InterpreterFrom<T>, 'send'>
}
type SettingsAuthContext = {
auth: MachineContext<typeof authMachine>
settings: MachineContext<typeof settingsMachine>
}
export const SettingsAuthStateContext = createContext({} as SettingsAuthContext)
export const SettingsAuthStateProvider = ({
children,
}: {
children: React.ReactNode
}) => {
const navigate = useNavigate()
// Settings machine setup
// Load settings from local storage
// and validate them
const retrievedSettings = useRef(
validateSettings(
JSON.parse(localStorage?.getItem(SETTINGS_PERSIST_KEY) || '{}')
)
)
const persistedSettings = Object.assign(
{},
initialSettings,
retrievedSettings.current.settings
)
const [settingsState, settingsSend] = useMachine(settingsMachine, {
context: persistedSettings,
actions: {
toastSuccess: (context, event) => {
const truncatedNewValue =
'data' in event && event.data instanceof Object
? (context[Object.keys(event.data)[0] as keyof typeof context]
.toString()
.substring(0, 28) as any)
: undefined
toast.success(
event.type +
(truncatedNewValue
? ` to "${truncatedNewValue}${
truncatedNewValue.length === 28 ? '...' : ''
}"`
: '')
)
},
},
})
// If the app is running in the Tauri context,
// try to read the settings from a file
// after doing some validation on them
useEffect(() => {
async function getFileBasedSettings() {
if (isTauri()) {
const newSettings = await readSettingsFile()
if (newSettings) {
if (newSettings.defaultDirectory) {
const newDefaultDirectory = await initializeProjectDirectory(
newSettings.defaultDirectory || ''
)
if (newDefaultDirectory.error !== null) {
toast.error(newDefaultDirectory.error.message)
}
if (newDefaultDirectory.path !== null) {
newSettings.defaultDirectory = newDefaultDirectory.path
}
}
const { settings: validatedSettings, errors: validationErrors } =
validateSettings(newSettings)
retrievedSettings.current = Object.assign(
{},
initialSettings,
retrievedSettings.current,
validatedSettings
)
settingsSend({
type: 'Set All Settings',
data: validatedSettings,
})
return validationErrors
}
} else {
// If the app is not running in the Tauri context,
// just use the settings from local storage
// after they've been validated to ensure they are correct.
settingsSend({
type: 'Set All Settings',
data: retrievedSettings.current.settings,
})
}
return []
}
// If there were validation errors either from local storage or from the file,
// log them to the console and show a toast message to the user.
void getFileBasedSettings().then((validationErrors: string[]) => {
const combinedErrors = new Set([
...retrievedSettings.current.errors,
...validationErrors,
])
if (combinedErrors.size > 0) {
const errorMessage =
'Error validating persisted settings: ' +
Array.from(combinedErrors).join(', ') +
'. Using defaults.'
console.error(errorMessage)
toast.error(errorMessage)
}
})
}, [settingsSend])
useStateMachineCommands({
machineId: 'settings',
state: settingsState,
send: settingsSend,
commandBarConfig: settingsCommandBarConfig,
})
// Listen for changes to the system theme and update the app theme accordingly
// This is only done if the theme setting is set to 'system'.
// It can't be done in XState (in an invoked callback, for example)
// because there doesn't seem to be a good way to listen to
// events outside of the machine that also depend on the machine's context
useEffect(() => {
const matcher = window.matchMedia('(prefers-color-scheme: dark)')
const listener = (e: MediaQueryListEvent) => {
if (settingsState.context.theme !== 'system') return
setThemeClass(e.matches ? Themes.Dark : Themes.Light)
}
matcher.addEventListener('change', listener)
return () => matcher.removeEventListener('change', listener)
}, [settingsState.context])
// Auth machine setup
const [authState, authSend] = useMachine(authMachine, {
actions: {
goToSignInPage: () => {
navigate(paths.SIGN_IN)
logout()
},
goToIndexPage: () => {
if (window.location.pathname.includes(paths.SIGN_IN)) {
navigate(paths.INDEX)
}
},
},
})
useStateMachineCommands({
machineId: 'auth',
state: authState,
send: authSend,
commandBarConfig: authCommandBarConfig,
})
return (
<SettingsAuthStateContext.Provider
value={{
auth: {
state: authState,
context: authState.context,
send: authSend,
},
settings: {
state: settingsState,
context: settingsState.context,
send: settingsSend,
},
}}
>
{children}
</SettingsAuthStateContext.Provider>
)
}
export default SettingsAuthStateProvider
export function logout() {
localStorage.removeItem(TOKEN_PERSIST_KEY)
return (
!isTauri() &&
fetch(withBaseUrl('/logout'), {
method: 'POST',
credentials: 'include',
})
)
}