* 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
231 lines
6.6 KiB
TypeScript
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',
|
|
})
|
|
)
|
|
}
|