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'; import { pruneRetiredRelays, ensureCommunityRelay } from '@/lib/relays'; 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(), // These MUST be declared. Zod strips keys it doesn't know, so omitting them // silently wiped the user's app-local settings on every load: their NIP-17 DM // inbox relays (kind 10050), local relays and the relay blocklist. With // `dmRelays` gone, DM reads/writes fell back to the NIP-65 list — which does // NOT include the DM relays — so inbox messages looked like they "never // arrived". localRelays: z.array(z.string()).optional(), blockedRelays: z.array(z.string()).optional(), dmRelays: z.array(z.string()).optional(), }) 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; } } // Reconcile the stored list: drop retired/dead relays and make sure the // community relay is present (unless the user turned it off with the toggle). // Code defaults change over time, but a saved list is otherwise never rewritten. if (!updates.relayMetadata) { let md = config.relayMetadata; md = pruneRetiredRelays(md) ?? md; if (config.useSkateRelay !== false) md = ensureCommunityRelay(md) ?? md; if (md !== config.relayMetadata) { updates.relayMetadata = md; needsUpdate = true; } } // Force dark theme (app is dark-only) if (config.theme !== 'dark') { updates.theme = 'dark'; needsUpdate = true; } if (needsUpdate) { updateConfig((current) => ({ ...current, ...updates })); } // Intentionally run once on mount (seed defaults / force dark theme). // Adding config/updateConfig deps would re-run the effect after it writes. // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // 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]); }