diff --git a/src/App.tsx b/src/App.tsx index 6ce9efc..b069d40 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,15 +10,9 @@ import { Toaster as Sonner } from "@/components/ui/sonner"; 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 AppRouter from './AppRouter'; -// DO NOT MODIFY THIS RELAY LIST UNLESS EXPLICITLY REQUESTED -const defaultRelays = [ - 'wss://relay.nostr.band/', - // DO NOT ADD ANY RELAY WITHOUT FIRST USING A TOOL TO VERIFY IT IS ONLINE AND FUNCTIONAL - // IF YOU CANNOT VERIFY A RELAY IS ONLINE AND FUNCTIONAL, DO NOT ADD IT HERE -]; - const queryClient = new QueryClient({ defaultOptions: { queries: { @@ -31,20 +25,22 @@ const queryClient = new QueryClient({ export function App() { return ( - - - - - - - - }> - - - - - - + + + + + + + + + }> + + + + + + + ); } diff --git a/src/components/AppProvider.ts b/src/components/AppProvider.ts new file mode 100644 index 0000000..296cfef --- /dev/null +++ b/src/components/AppProvider.ts @@ -0,0 +1,95 @@ +import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react'; + +interface AppConfig { + /** 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.damus.io', name: 'Damus' }, + { url: 'wss://nos.lol', name: 'nos.lol' }, + { url: 'wss://relay.nostr.band', name: 'nostr.band' }, +]; + +// Default application configuration +const DEFAULT_CONFIG: AppConfig = { + relayUrl: 'wss://relay.nostr.band', +}; + +interface AppContextType { + /** Current application configuration */ + config: AppConfig; + /** Update any configuration value */ + updateConfig: (key: K, value: AppConfig[K]) => void; + /** Available relay options */ + availableRelays: RelayInfo[]; +} + +const AppContext = createContext(undefined); + +const STORAGE_KEY = 'nostr:app-config'; + +interface AppProviderProps { + children: ReactNode; +} + +export function AppProvider({ children }: AppProviderProps) { + const [config, setConfig] = useState(DEFAULT_CONFIG); + + // Load saved config from localStorage on mount + useEffect(() => { + try { + const savedConfig = localStorage.getItem(STORAGE_KEY); + if (savedConfig) { + const parsed = JSON.parse(savedConfig); + // Merge with defaults to handle new config options + setConfig(prev => ({ ...prev, ...parsed })); + } + } catch (error) { + console.warn('Failed to load app config from localStorage:', error); + } + }, []); + + // Save config to localStorage when it changes + useEffect(() => { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(config)); + } catch (error) { + console.warn('Failed to save app config to localStorage:', error); + } + }, [config]); + + // Generic config updater + const updateConfig = (key: K, value: AppConfig[K]) => { + setConfig(prev => ({ ...prev, [key]: value })); + }; + + const contextValue: AppContextType = { + config, + updateConfig, + availableRelays: RELAY_OPTIONS, + }; + + return React.createElement(AppContext.Provider, { value: contextValue }, 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; +} diff --git a/src/components/NostrProvider.tsx b/src/components/NostrProvider.tsx index a5aab06..6d60c2a 100644 --- a/src/components/NostrProvider.tsx +++ b/src/components/NostrProvider.tsx @@ -1,28 +1,44 @@ +import React, { useEffect, useRef } from 'react'; import { NostrEvent, NPool, NRelay1 } from '@nostrify/nostrify'; import { NostrContext } from '@nostrify/react'; -import React, { useRef } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { useAppConfig } from './AppProvider'; interface NostrProviderProps { children: React.ReactNode; - relays: string[]; } const NostrProvider: React.FC = (props) => { - const { children, relays } = props; + const { children } = props; + const { config, availableRelays } = useAppConfig(); + + const queryClient = useQueryClient(); // Create NPool instance only once const pool = useRef(undefined); + // Use refs so the pool always has the latest data + const relayUrl = useRef(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]); + + // Initialize NPool only once if (!pool.current) { pool.current = new NPool({ open(url: string) { return new NRelay1(url); }, reqRouter(filters) { - return new Map(relays.map((url) => [url, filters])); + return new Map([[relayUrl.current, filters]]); }, eventRouter(_event: NostrEvent) { - return relays; + return available.current.map((info) => info.url); }, }); } diff --git a/src/components/RelaySelector.tsx b/src/components/RelaySelector.tsx new file mode 100644 index 0000000..ffe07c3 --- /dev/null +++ b/src/components/RelaySelector.tsx @@ -0,0 +1,170 @@ +import { Check, ChevronsUpDown, Wifi, Plus } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { useAppConfig } from "@/components/AppProvider"; +import { useState } from "react"; + +interface RelaySelectorProps { + className?: string; +} + +export function RelaySelector({ className }: RelaySelectorProps) { + const { config, updateConfig, availableRelays } = useAppConfig(); + const [open, setOpen] = useState(false); + const [inputValue, setInputValue] = useState(""); + + const selectedOption = availableRelays.find((option) => option.url === config.relayUrl); + + // Function to normalize relay URL by adding wss:// if no protocol is present + const normalizeRelayUrl = (url: string): string => { + const trimmed = url.trim(); + if (!trimmed) return trimmed; + + // Check if it already has a protocol + if (trimmed.includes('://')) { + return trimmed; + } + + // Add wss:// prefix + return `wss://${trimmed}`; + }; + + // Handle adding a custom relay + const handleAddCustomRelay = (url: string) => { + const normalizedUrl = normalizeRelayUrl(url); + if (normalizedUrl) { + updateConfig("relayUrl", normalizedUrl); + setOpen(false); + setInputValue(""); + } + }; + + // Check if input value looks like a valid relay URL + const isValidRelayInput = (value: string): boolean => { + const trimmed = value.trim(); + if (!trimmed) return false; + + // Basic validation - should contain at least a domain-like structure + const normalized = normalizeRelayUrl(trimmed); + try { + new URL(normalized); + return true; + } catch { + return false; + } + }; + + return ( + + + + + + + + + + {inputValue && isValidRelayInput(inputValue) ? ( + handleAddCustomRelay(inputValue)} + className="cursor-pointer" + > + +
+ Add custom relay + + {normalizeRelayUrl(inputValue)} + +
+
+ ) : ( +
+ {inputValue ? "Invalid relay URL" : "No relay found."} +
+ )} +
+ + {availableRelays + .filter((option) => + !inputValue || + option.name.toLowerCase().includes(inputValue.toLowerCase()) || + option.url.toLowerCase().includes(inputValue.toLowerCase()) + ) + .map((option) => ( + { + updateConfig("relayUrl", currentValue); + setOpen(false); + setInputValue(""); + }} + > + +
+ {option.name} + {option.url} +
+
+ ))} + {inputValue && isValidRelayInput(inputValue) && ( + handleAddCustomRelay(inputValue)} + className="cursor-pointer border-t" + > + +
+ Add custom relay + + {normalizeRelayUrl(inputValue)} + +
+
+ )} +
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/ThemeProvider.tsx b/src/components/ThemeProvider.tsx index 592ae0e..04c778c 100644 --- a/src/components/ThemeProvider.tsx +++ b/src/components/ThemeProvider.tsx @@ -3,14 +3,14 @@ import { type Theme, ThemeContext } from "@/lib/ThemeContext" type ThemeProviderProps = { children: React.ReactNode; - defaultTheme?: Theme; - storageKey?: string; + defaultTheme: Theme; + storageKey: string; } export function ThemeProvider({ children, - defaultTheme = "light", - storageKey = "theme", + defaultTheme, + storageKey, ...props }: ThemeProviderProps) { const [theme, setTheme] = useState( diff --git a/src/components/auth/AccountSwitcher.tsx b/src/components/auth/AccountSwitcher.tsx index 81d2fdf..696ab2b 100644 --- a/src/components/auth/AccountSwitcher.tsx +++ b/src/components/auth/AccountSwitcher.tsx @@ -10,6 +10,7 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu.tsx'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar.tsx'; +import { RelaySelector } from '@/components/RelaySelector'; import { useLoggedInAccounts, type Account } from '@/hooks/useLoggedInAccounts'; import { genUserName } from '@/lib/genUserName'; @@ -39,6 +40,9 @@ export function AccountSwitcher({ onAddAccountClick }: AccountSwitcherProps) { +
Switch Relay
+ +
Switch Account
{otherUsers.map((user) => ( - - - - {children} - - - + + + + + {children} + + + + ); } diff --git a/src/test/setup.ts b/src/test/setup.ts index a88e31c..fb9808b 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -20,4 +20,21 @@ Object.defineProperty(window, 'matchMedia', { Object.defineProperty(window, 'scrollTo', { writable: true, value: vi.fn(), -}); \ No newline at end of file +}); + +// Mock IntersectionObserver +global.IntersectionObserver = vi.fn().mockImplementation((_callback) => ({ + observe: vi.fn(), + unobserve: vi.fn(), + disconnect: vi.fn(), + root: null, + rootMargin: '', + thresholds: [], +})); + +// Mock ResizeObserver +global.ResizeObserver = vi.fn().mockImplementation((_callback) => ({ + observe: vi.fn(), + unobserve: vi.fn(), + disconnect: vi.fn(), +})); \ No newline at end of file