Refactor AppProvider

This commit is contained in:
Alex Gleason 2025-06-04 10:04:39 -05:00
parent c3310ab485
commit 3ad83cd114
No known key found for this signature in database
GPG Key ID: 7211D1F99744FBB7
9 changed files with 156 additions and 204 deletions

View File

@ -9,6 +9,7 @@ import { TooltipProvider } from "@/components/ui/tooltip";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { NostrLoginProvider } from '@nostrify/react/login';
import { AppProvider } from '@/components/AppProvider';
import { AppConfig } from '@/contexts/AppContext';
import AppRouter from './AppRouter';
const queryClient = new QueryClient({
@ -21,9 +22,14 @@ const queryClient = new QueryClient({
},
});
const defaultConfig: AppConfig = {
theme: "light",
relayUrl: "wss://relay.nostr.band",
};
export function App() {
return (
<AppProvider defaultTheme="light">
<AppProvider storageKey="nostr:app-config" defaultConfig={defaultConfig}>
<QueryClientProvider client={queryClient}>
<NostrLoginProvider storageKey='nostr:login'>
<NostrProvider>

View File

@ -1,189 +0,0 @@
import React, { createContext, useContext, ReactNode, useEffect } from 'react';
import { useLocalStorage } from '@/hooks/useLocalStorage';
export type Theme = "dark" | "light" | "system";
interface AppConfig {
/** Current theme */
theme: Theme;
/** Selected relay URL */
relayUrl: string;
}
interface RelayInfo {
/** Relay URL */
url: string;
/** Display name for the relay */
name: string;
}
// Available relay options
export const RELAY_OPTIONS: RelayInfo[] = [
{ url: 'wss://ditto.pub/relay', name: 'Ditto' },
{ url: 'wss://relay.nostr.band', name: 'Nostr.Band' },
{ url: 'wss://relay.damus.io', name: 'Damus' },
{ url: 'wss://relay.primal.net', name: 'Primal' },
];
// Default application configuration
const DEFAULT_CONFIG: AppConfig = {
theme: 'system',
relayUrl: 'wss://relay.nostr.band',
};
interface AppContextType {
/** Current application configuration */
config: AppConfig;
/** Update configuration using a callback that receives current config and returns new config */
updateConfig: (updater: (currentConfig: AppConfig) => AppConfig) => void;
/** Available relay options */
availableRelays: RelayInfo[];
}
const AppContext = createContext<AppContextType | undefined>(undefined);
const APP_CONFIG_STORAGE_KEY = 'nostr:app-config';
interface AppProviderProps {
children: ReactNode;
/** Default theme for the application */
defaultTheme?: Theme;
}
export function AppProvider({
children,
defaultTheme = 'system'
}: AppProviderProps) {
// App configuration state with localStorage persistence
const [config, setConfig] = useLocalStorage(
APP_CONFIG_STORAGE_KEY,
{ ...DEFAULT_CONFIG, theme: defaultTheme }
);
/**
* Hook to apply theme changes to the document root
*/
function useThemeEffect(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]);
}
// Apply theme effects to document
useThemeEffect(config.theme);
// Generic config updater with callback pattern
const updateConfig = (updater: (currentConfig: AppConfig) => AppConfig) => {
setConfig(updater);
};
const appContextValue: AppContextType = {
config,
updateConfig,
availableRelays: RELAY_OPTIONS,
};
return React.createElement(
AppContext.Provider,
{ value: appContextValue },
children
);
}
/**
* Hook to access and update application configuration
* @returns Application context with config and update methods
*/
export function useAppConfig() {
const context = useContext(AppContext);
if (context === undefined) {
throw new Error('useAppConfig must be used within an AppProvider');
}
return context;
}
/**
* Hook to get and set the active theme
* @returns Theme context with theme and setTheme
*/
export function useTheme() {
const context = useContext(AppContext);
if (context === undefined) {
throw new Error('useTheme must be used within an AppProvider');
}
return {
theme: context.config.theme,
setTheme: (theme: Theme) => context.updateConfig(config => ({ ...config, theme })),
};
}
/**
* Hook to apply theme changes to the document root
*/
export function useThemeEffect(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]);
}

View File

@ -0,0 +1,82 @@
import { ReactNode, useEffect } from 'react';
import { useLocalStorage } from '@/hooks/useLocalStorage';
import { AppContext, type AppConfig, type AppContextType, type Theme } from '@/contexts/AppContext';
interface AppProviderProps {
children: ReactNode;
/** Application storage key */
storageKey: string;
/** Default app configuration */
defaultConfig: AppConfig;
}
export function AppProvider(props: AppProviderProps) {
const {
children,
storageKey,
defaultConfig
} = props;
// App configuration state with localStorage persistence
const [config, setConfig] = useLocalStorage<AppConfig>(storageKey, defaultConfig);
// Generic config updater with callback pattern
const updateConfig = (updater: (currentConfig: AppConfig) => AppConfig) => {
setConfig(updater);
};
const appContextValue: AppContextType = {
config,
updateConfig,
};
// Apply theme effects to document
useApplyTheme(config.theme);
return (
<AppContext.Provider value={appContextValue}>
{children}
</AppContext.Provider>
);
}
/**
* 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]);
}

View File

@ -2,7 +2,7 @@ import React, { useEffect, useRef } from 'react';
import { NostrEvent, NPool, NRelay1 } from '@nostrify/nostrify';
import { NostrContext } from '@nostrify/react';
import { useQueryClient } from '@tanstack/react-query';
import { useAppConfig } from './AppProvider';
import { useAppConfig } from '@/hooks/useAppConfig';
interface NostrProviderProps {
children: React.ReactNode;
@ -10,7 +10,7 @@ interface NostrProviderProps {
const NostrProvider: React.FC<NostrProviderProps> = (props) => {
const { children } = props;
const { config, availableRelays } = useAppConfig();
const { config } = useAppConfig();
const queryClient = useQueryClient();
@ -19,14 +19,12 @@ const NostrProvider: React.FC<NostrProviderProps> = (props) => {
// Use refs so the pool always has the latest data
const relayUrl = useRef<string>(config.relayUrl);
const available = useRef(availableRelays);
// Update refs when config changes
useEffect(() => {
relayUrl.current = config.relayUrl;
available.current = availableRelays;
queryClient.resetQueries();
}, [config.relayUrl, availableRelays, queryClient]);
}, [config.relayUrl, queryClient]);
// Initialize NPool only once
if (!pool.current) {
@ -38,7 +36,7 @@ const NostrProvider: React.FC<NostrProviderProps> = (props) => {
return new Map([[relayUrl.current, filters]]);
},
eventRouter(_event: NostrEvent) {
return available.current.map((info) => info.url);
return [relayUrl.current];
},
});
}

View File

@ -14,15 +14,17 @@ import {
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { useAppConfig } from "@/components/AppProvider";
import { useAppConfig } from "@/hooks/useAppConfig";
import { useState } from "react";
interface RelaySelectorProps {
className?: string;
availableRelays?: { name: string; url: string }[];
}
export function RelaySelector({ className }: RelaySelectorProps) {
const { config, updateConfig, availableRelays } = useAppConfig();
export function RelaySelector({ className, availableRelays = [] }: RelaySelectorProps) {
const { config, updateConfig } = useAppConfig();
const [open, setOpen] = useState(false);
const [inputValue, setInputValue] = useState("");

View File

@ -0,0 +1,19 @@
import { createContext } from "react";
export type Theme = "dark" | "light" | "system";
export interface AppConfig {
/** Current theme */
theme: Theme;
/** Selected relay URL */
relayUrl: string;
}
export interface AppContextType {
/** Current application configuration */
config: AppConfig;
/** Update configuration using a callback that receives current config and returns new config */
updateConfig: (updater: (currentConfig: AppConfig) => AppConfig) => void;
}
export const AppContext = createContext<AppContextType | undefined>(undefined);

14
src/hooks/useAppConfig.ts Normal file
View File

@ -0,0 +1,14 @@
import { useContext } from "react";
import { AppContext, type AppContextType } from "@/contexts/AppContext";
/**
* Hook to access and update application configuration
* @returns Application context with config and update methods
*/
export function useAppConfig(): AppContextType {
const context = useContext(AppContext);
if (context === undefined) {
throw new Error('useAppConfig must be used within an AppProvider');
}
return context;
}

View File

@ -1,6 +1,20 @@
import { useTheme as useThemeFromProvider } from "@/components/AppProvider";
import { type Theme } from "@/contexts/AppContext";
import { useAppConfig } from "@/hooks/useAppConfig";
/** Hook to get and set the active theme. */
export function useTheme() {
return useThemeFromProvider();
/**
* Hook to get and set the active theme
* @returns Theme context with theme and setTheme
*/
export function useTheme(): { theme: Theme; setTheme: (theme: Theme) => void } {
const { config, updateConfig } = useAppConfig();
return {
theme: config.theme,
setTheme: (theme: Theme) => {
updateConfig((currentConfig) => ({
...currentConfig,
theme,
}));
}
}
}

View File

@ -3,6 +3,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { NostrLoginProvider } from '@nostrify/react/login';
import NostrProvider from '@/components/NostrProvider';
import { AppProvider } from '@/components/AppProvider';
import { AppConfig } from '@/contexts/AppContext';
interface TestAppProps {
children: React.ReactNode;
@ -16,9 +17,14 @@ export function TestApp({ children }: TestAppProps) {
},
});
const defaultConfig: AppConfig = {
theme: 'light',
relayUrl: 'wss://relay.nostr.band',
};
return (
<BrowserRouter>
<AppProvider>
<AppProvider storageKey='test-app-config' defaultConfig={defaultConfig}>
<QueryClientProvider client={queryClient}>
<NostrLoginProvider storageKey='test-login'>
<NostrProvider>