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 = { state: StateFrom context: ContextFrom send: Prop, 'send'> } type SettingsAuthContext = { auth: MachineContext settings: MachineContext } 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 ( {children} ) } export default SettingsAuthStateProvider export function logout() { localStorage.removeItem(TOKEN_PERSIST_KEY) return ( !isTauri() && fetch(withBaseUrl('/logout'), { method: 'POST', credentials: 'include', }) ) }