import { ReactNode, useEffect } from 'react'; import { z } from 'zod'; import { useLocalStorage } from '@/hooks/useLocalStorage'; import { safeJson } from '@/lib/safeJson'; import { AppContext, type AppConfig, type AppContextType, type Theme, type RelayMetadata } from '@/contexts/AppContext'; interface AppProviderProps { children: ReactNode; /** Application storage key */ storageKey: string; /** Default app configuration */ defaultConfig: AppConfig; } // Zod schema for RelayMetadata validation const RelayMetadataSchema = z.object({ relays: z.array(z.object({ url: z.string().url(), read: z.boolean(), write: z.boolean(), })), updatedAt: z.number(), }) satisfies z.ZodType; // Zod schema for AppConfig validation const AppConfigSchema = z.object({ theme: z.enum(['dark', 'light', 'system']), relayMetadata: RelayMetadataSchema, useSkateRelay: z.boolean().optional(), useCitrineRelay: z.boolean().optional(), }) satisfies z.ZodType>; export function AppProvider(props: AppProviderProps) { const { children, storageKey, defaultConfig, } = props; // App configuration state with localStorage persistence const [rawConfig, setConfig] = useLocalStorage>( storageKey, {}, { serialize: JSON.stringify, deserialize: (value: string) => { // Guard the parse: a corrupted localStorage blob must not throw before // the Zod schema runs (that would crash-loop the app on boot). const parsed = safeJson(value, {}); return AppConfigSchema.partial().parse(parsed); } } ); // Generic config updater with callback pattern const updateConfig = (updater: (currentConfig: Partial) => Partial) => { setConfig(updater); }; // Merge configs with special handling for relay metadata const config = { ...defaultConfig, ...rawConfig }; // Force dark theme and seed default relays only for brand-new users useEffect(() => { let needsUpdate = false; const updates: Partial = {}; // Only reset relays to defaults if the user has never synced their own NIP-65 // relay list (updatedAt === 0 means no NIP-65 event has ever been fetched). // If updatedAt > 0, the user has their own relay list — never overwrite it. if (!config.relayMetadata.relays || config.relayMetadata.relays.length === 0) { if (config.relayMetadata.updatedAt === 0) { updates.relayMetadata = defaultConfig.relayMetadata; needsUpdate = true; } } // Force dark theme (app is dark-only) if (config.theme !== 'dark') { updates.theme = 'dark'; needsUpdate = true; } if (needsUpdate) { updateConfig((current) => ({ ...current, ...updates })); } }, []); // Ensure relay list is never empty - fallback to default if no relays configured if (!config.relayMetadata.relays || config.relayMetadata.relays.length === 0) { config.relayMetadata = defaultConfig.relayMetadata; } const appContextValue: AppContextType = { config, updateConfig, }; // Apply theme effects to document useApplyTheme(config.theme); return ( {children} ); } /** * Hook to apply theme changes to the document root */ function useApplyTheme(theme: Theme) { useEffect(() => { const root = window.document.documentElement; root.classList.remove('light', 'dark'); if (theme === 'system') { const systemTheme = window.matchMedia('(prefers-color-scheme: dark)') .matches ? 'dark' : 'light'; root.classList.add(systemTheme); return; } root.classList.add(theme); }, [theme]); // Handle system theme changes when theme is set to "system" useEffect(() => { if (theme !== 'system') return; const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); const handleChange = () => { const root = window.document.documentElement; root.classList.remove('light', 'dark'); const systemTheme = mediaQuery.matches ? 'dark' : 'light'; root.classList.add(systemTheme); }; mediaQuery.addEventListener('change', handleChange); return () => mediaQuery.removeEventListener('change', handleChange); }, [theme]); }