From df1a29702cbc24eee68806fd5df9832d9251a444 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 3 Jul 2025 17:42:50 -0500 Subject: [PATCH 1/2] Add a relay selector --- src/App.tsx | 50 +++++---- src/components/AppProvider.tsx | 86 +++++++++++++++ src/components/NostrProvider.tsx | 42 +++++-- src/components/RelaySelector.tsx | 182 +++++++++++++++++++++++++++++++ src/contexts/AppContext.ts | 21 ++++ src/hooks/useAppContext.ts | 14 +++ src/hooks/useLocalStorage.ts | 54 +++++++++ src/pages/settings/Settings.tsx | 30 ++++- 8 files changed, 451 insertions(+), 28 deletions(-) create mode 100644 src/components/AppProvider.tsx create mode 100644 src/components/RelaySelector.tsx create mode 100644 src/contexts/AppContext.ts create mode 100644 src/hooks/useAppContext.ts create mode 100644 src/hooks/useLocalStorage.ts diff --git a/src/App.tsx b/src/App.tsx index 16f5ccf3..38f7a8ce 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,11 +11,8 @@ import AppRouter from './AppRouter'; import { useSystemTheme } from '@/hooks/useSystemTheme'; import { JoinDialogProvider } from '@/components/groups/JoinDialogProvider'; import { WalletLoader } from '@/components/WalletLoader'; - -// DO NOT MODIFY THIS LIST UNLESS YOU ARE ABSOLUTELY CERTAIN EACH RELAY URL YOU ARE ADDING IS VALID AND THE RELAY IS CURRENTLY ONLINE AND CONFIRMED TO BE FULLY FUNCTIONAL AND WORKING. -const defaultRelays = [ - 'wss://relay.chorus.community/', // DO NOT MODIFY THIS UNLESS EXPLICITLY REQUESTED -]; +import { AppProvider } from '@/components/AppProvider'; +import { AppConfig } from '@/contexts/AppContext'; const queryClient = new QueryClient({ defaultOptions: { @@ -27,25 +24,40 @@ const queryClient = new QueryClient({ }, }); +const defaultConfig: AppConfig = { + theme: "system", + relayUrl: "wss://relay.chorus.community", // DO NOT MODIFY THIS UNLESS EXPLICITLY REQUESTED +}; + +const presetRelays = [ + { url: 'wss://relay.chorus.community', name: 'Chorus' }, + { 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' }, +]; + export function App() { // Use the enhanced theme hook useSystemTheme(); return ( - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + ); } diff --git a/src/components/AppProvider.tsx b/src/components/AppProvider.tsx new file mode 100644 index 00000000..d571e246 --- /dev/null +++ b/src/components/AppProvider.tsx @@ -0,0 +1,86 @@ +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; + /** Optional list of preset relays to display in the RelaySelector */ + presetRelays?: { name: string; url: string }[]; +} + +export function AppProvider(props: AppProviderProps) { + const { + children, + storageKey, + defaultConfig, + presetRelays, + } = props; + + // App configuration state with localStorage persistence + const [config, setConfig] = useLocalStorage(storageKey, defaultConfig); + + // Generic config updater with callback pattern + const updateConfig = (updater: (currentConfig: AppConfig) => AppConfig) => { + setConfig(updater); + }; + + const appContextValue: AppContextType = { + config, + updateConfig, + presetRelays, + }; + + // 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]); +} \ No newline at end of file diff --git a/src/components/NostrProvider.tsx b/src/components/NostrProvider.tsx index 437d0b27..888c8538 100644 --- a/src/components/NostrProvider.tsx +++ b/src/components/NostrProvider.tsx @@ -1,11 +1,12 @@ -import { NostrEvent, NPool, NRelay1 } from "@nostrify/nostrify"; -import { NostrContext } from "@nostrify/react"; -import React, { useRef } from "react"; -import { storeEventTimestamp } from "@/lib/nostrTimestamps"; +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 { useAppContext } from '@/hooks/useAppContext'; +import { storeEventTimestamp } from '@/lib/nostrTimestamps'; interface NostrProviderProps { children: React.ReactNode; - relays: string[]; } /** @@ -25,21 +26,46 @@ class TimestampTrackingNPool extends NPool { } const NostrProvider: React.FC = (props) => { - const { children, relays } = props; + const { children } = props; + const { config, presetRelays } = useAppContext(); + + 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); + + // Update refs when config changes + useEffect(() => { + relayUrl.current = config.relayUrl; + queryClient.resetQueries(); + }, [config.relayUrl, queryClient]); + + // Initialize NPool only once if (!pool.current) { pool.current = new TimestampTrackingNPool({ 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; + // Publish to the selected relay + const allRelays = new Set([relayUrl.current]); + + // Also publish to the preset relays, capped to 5 + for (const { url } of (presetRelays ?? [])) { + allRelays.add(url); + + if (allRelays.size >= 5) { + break; + } + } + + return [...allRelays]; }, }); } diff --git a/src/components/RelaySelector.tsx b/src/components/RelaySelector.tsx new file mode 100644 index 00000000..cc2f710d --- /dev/null +++ b/src/components/RelaySelector.tsx @@ -0,0 +1,182 @@ +import { Check, ChevronsUpDown, Wifi, Plus, Globe } 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 { useState } from "react"; +import { useAppContext } from "@/hooks/useAppContext"; + +interface RelaySelectorProps { + className?: string; +} + +export function RelaySelector(props: RelaySelectorProps) { + const { className } = props; + const { config, updateConfig, presetRelays = [] } = useAppContext(); + + const selectedRelay = config.relayUrl; + const setSelectedRelay = (relay: string) => { + updateConfig((current) => ({ ...current, relayUrl: relay })); + }; + + const [open, setOpen] = useState(false); + const [inputValue, setInputValue] = useState(""); + + const selectedOption = presetRelays.find((option) => option.url === selectedRelay); + + // 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) => { + setSelectedRelay?.(normalizeRelayUrl(url)); + 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 format" : "No relays found. Try typing a custom URL."} +
+ )} +
+ + {presetRelays + .filter((option) => + !inputValue || + option.name.toLowerCase().includes(inputValue.toLowerCase()) || + option.url.toLowerCase().includes(inputValue.toLowerCase()) + ) + .map((option) => ( + { + setSelectedRelay(normalizeRelayUrl(currentValue)); + setOpen(false); + setInputValue(""); + }} + className="py-3" + > +
+ + +
+ {option.name} + {option.url} +
+
+
+ ))} + {inputValue && isValidRelayInput(inputValue) && ( + handleAddCustomRelay(inputValue)} + className="cursor-pointer border-t py-3" + > +
+ + +
+ Add custom relay + + {normalizeRelayUrl(inputValue)} + +
+
+
+ )} +
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/src/contexts/AppContext.ts b/src/contexts/AppContext.ts new file mode 100644 index 00000000..e6cfa261 --- /dev/null +++ b/src/contexts/AppContext.ts @@ -0,0 +1,21 @@ +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; + /** Optional list of preset relays to display in the RelaySelector */ + presetRelays?: { name: string; url: string }[]; +} + +export const AppContext = createContext(undefined); diff --git a/src/hooks/useAppContext.ts b/src/hooks/useAppContext.ts new file mode 100644 index 00000000..7554806c --- /dev/null +++ b/src/hooks/useAppContext.ts @@ -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 useAppContext(): AppContextType { + const context = useContext(AppContext); + if (context === undefined) { + throw new Error('useAppContext must be used within an AppProvider'); + } + return context; +} \ No newline at end of file diff --git a/src/hooks/useLocalStorage.ts b/src/hooks/useLocalStorage.ts new file mode 100644 index 00000000..73c29593 --- /dev/null +++ b/src/hooks/useLocalStorage.ts @@ -0,0 +1,54 @@ +import { useState, useEffect } from 'react'; + +/** + * Generic hook for managing localStorage state + */ +export function useLocalStorage( + key: string, + defaultValue: T, + serializer?: { + serialize: (value: T) => string; + deserialize: (value: string) => T; + } +) { + const serialize = serializer?.serialize || JSON.stringify; + const deserialize = serializer?.deserialize || JSON.parse; + + const [state, setState] = useState(() => { + try { + const item = localStorage.getItem(key); + return item ? deserialize(item) : defaultValue; + } catch (error) { + console.warn(`Failed to load ${key} from localStorage:`, error); + return defaultValue; + } + }); + + const setValue = (value: T | ((prev: T) => T)) => { + try { + const valueToStore = value instanceof Function ? value(state) : value; + setState(valueToStore); + localStorage.setItem(key, serialize(valueToStore)); + } catch (error) { + console.warn(`Failed to save ${key} to localStorage:`, error); + } + }; + + // Sync with localStorage changes from other tabs + useEffect(() => { + const handleStorageChange = (e: StorageEvent) => { + if (e.key === key && e.newValue !== null) { + try { + setState(deserialize(e.newValue)); + } catch (error) { + console.warn(`Failed to sync ${key} from localStorage:`, error); + } + } + }; + + window.addEventListener('storage', handleStorageChange); + return () => window.removeEventListener('storage', handleStorageChange); + }, [key, deserialize]); + + return [state, setValue] as const; +} \ No newline at end of file diff --git a/src/pages/settings/Settings.tsx b/src/pages/settings/Settings.tsx index d4253aed..517e1238 100644 --- a/src/pages/settings/Settings.tsx +++ b/src/pages/settings/Settings.tsx @@ -1,6 +1,6 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import Header from "@/components/ui/Header"; -import { Eye, EyeOff, Copy, Check, Smartphone } from "lucide-react"; +import { Eye, EyeOff, Copy, Check, Smartphone, Wifi } from "lucide-react"; import { useCurrentUser } from "@/hooks/useCurrentUser"; import { Navigate } from "react-router-dom"; import { useState, useEffect, useRef } from "react"; @@ -12,6 +12,7 @@ import { nip19 } from 'nostr-tools'; import { PWAInstallButton } from "@/components/PWAInstallButton"; import { PushNotificationSettings } from "@/components/settings/PushNotificationSettings"; +import { RelaySelector } from "@/components/RelaySelector"; export default function Settings() { const { user } = useCurrentUser(); @@ -113,6 +114,33 @@ export default function Settings() { {/* Push Notifications Section */} + {/* Relay Settings Section */} + + + + + Relay Settings + + + Choose which Nostr relay to connect to. Relays are servers that store and distribute your posts and messages across the Nostr network. + + + +
+
+ + +
+ +

+ You can select from popular relays or add your own custom relay URL. Changes take effect immediately. +

+
+
+
+ {/* Keys Section */} From 03f9d349f0351ac1bc202ab5f43d4c379e8b378b Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 3 Jul 2025 17:52:35 -0500 Subject: [PATCH 2/2] Remove useApplyTheme in favor of the app's existing theme system --- src/App.tsx | 1 - src/components/AppProvider.tsx | 48 ++-------------------------------- src/contexts/AppContext.ts | 4 --- 3 files changed, 2 insertions(+), 51 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 38f7a8ce..baaa3e5d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -25,7 +25,6 @@ const queryClient = new QueryClient({ }); const defaultConfig: AppConfig = { - theme: "system", relayUrl: "wss://relay.chorus.community", // DO NOT MODIFY THIS UNLESS EXPLICITLY REQUESTED }; diff --git a/src/components/AppProvider.tsx b/src/components/AppProvider.tsx index d571e246..0cd2a334 100644 --- a/src/components/AppProvider.tsx +++ b/src/components/AppProvider.tsx @@ -1,6 +1,6 @@ -import { ReactNode, useEffect } from 'react'; +import { ReactNode } from 'react'; import { useLocalStorage } from '@/hooks/useLocalStorage'; -import { AppContext, type AppConfig, type AppContextType, type Theme } from '@/contexts/AppContext'; +import { AppContext, type AppConfig, type AppContextType } from '@/contexts/AppContext'; interface AppProviderProps { children: ReactNode; @@ -34,53 +34,9 @@ export function AppProvider(props: AppProviderProps) { presetRelays, }; - // 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]); -} \ No newline at end of file diff --git a/src/contexts/AppContext.ts b/src/contexts/AppContext.ts index e6cfa261..64203e9e 100644 --- a/src/contexts/AppContext.ts +++ b/src/contexts/AppContext.ts @@ -1,10 +1,6 @@ import { createContext } from "react"; -export type Theme = "dark" | "light" | "system"; - export interface AppConfig { - /** Current theme */ - theme: Theme; /** Selected relay URL */ relayUrl: string; }