Add RelaySelector

This commit is contained in:
Alex Gleason 2025-06-02 21:44:52 -05:00
parent 190f0cd791
commit a8163d7a6c
No known key found for this signature in database
GPG Key ID: 7211D1F99744FBB7
8 changed files with 339 additions and 38 deletions

View File

@ -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 (
<ThemeProvider defaultTheme="system">
<NostrLoginProvider storageKey='nostr:login'>
<NostrProvider relays={defaultRelays}>
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<Toaster />
<Sonner />
<Suspense fallback={<div className="flex items-center justify-center h-screen"><Spinner /></div>}>
<AppRouter />
</Suspense>
</TooltipProvider>
</QueryClientProvider>
</NostrProvider>
</NostrLoginProvider>
<ThemeProvider defaultTheme="light" storageKey="theme">
<AppProvider>
<QueryClientProvider client={queryClient}>
<NostrLoginProvider storageKey='nostr:login'>
<NostrProvider>
<TooltipProvider>
<Toaster />
<Sonner />
<Suspense fallback={<div className="bg-black flex items-center justify-center h-screen"><Spinner /></div>}>
<AppRouter />
</Suspense>
</TooltipProvider>
</NostrProvider>
</NostrLoginProvider>
</QueryClientProvider>
</AppProvider>
</ThemeProvider>
);
}

View File

@ -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: <K extends keyof AppConfig>(key: K, value: AppConfig[K]) => void;
/** Available relay options */
availableRelays: RelayInfo[];
}
const AppContext = createContext<AppContextType | undefined>(undefined);
const STORAGE_KEY = 'nostr:app-config';
interface AppProviderProps {
children: ReactNode;
}
export function AppProvider({ children }: AppProviderProps) {
const [config, setConfig] = useState<AppConfig>(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 = <K extends keyof AppConfig>(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;
}

View File

@ -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<NostrProviderProps> = (props) => {
const { children, relays } = props;
const { children } = props;
const { config, availableRelays } = useAppConfig();
const queryClient = useQueryClient();
// Create NPool instance only once
const pool = useRef<NPool | undefined>(undefined);
// 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]);
// 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);
},
});
}

View File

@ -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 (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className={cn("justify-between", className)}
>
<div className="flex items-center gap-2">
<Wifi className="h-4 w-4" />
<span className="truncate">
{selectedOption
? selectedOption.name
: config.relayUrl
? config.relayUrl.replace(/^wss?:\/\//, '')
: "Select relay..."
}
</span>
</div>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[300px] p-0">
<Command>
<CommandInput
placeholder="Search relays or type URL..."
value={inputValue}
onValueChange={setInputValue}
/>
<CommandList>
<CommandEmpty>
{inputValue && isValidRelayInput(inputValue) ? (
<CommandItem
onSelect={() => handleAddCustomRelay(inputValue)}
className="cursor-pointer"
>
<Plus className="mr-2 h-4 w-4" />
<div className="flex flex-col">
<span className="font-medium">Add custom relay</span>
<span className="text-xs text-muted-foreground">
{normalizeRelayUrl(inputValue)}
</span>
</div>
</CommandItem>
) : (
<div className="py-6 text-center text-sm text-muted-foreground">
{inputValue ? "Invalid relay URL" : "No relay found."}
</div>
)}
</CommandEmpty>
<CommandGroup>
{availableRelays
.filter((option) =>
!inputValue ||
option.name.toLowerCase().includes(inputValue.toLowerCase()) ||
option.url.toLowerCase().includes(inputValue.toLowerCase())
)
.map((option) => (
<CommandItem
key={option.url}
value={option.url}
onSelect={(currentValue) => {
updateConfig("relayUrl", currentValue);
setOpen(false);
setInputValue("");
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
config.relayUrl === option.url ? "opacity-100" : "opacity-0"
)}
/>
<div className="flex flex-col">
<span className="font-medium">{option.name}</span>
<span className="text-xs text-muted-foreground">{option.url}</span>
</div>
</CommandItem>
))}
{inputValue && isValidRelayInput(inputValue) && (
<CommandItem
onSelect={() => handleAddCustomRelay(inputValue)}
className="cursor-pointer border-t"
>
<Plus className="mr-2 h-4 w-4" />
<div className="flex flex-col">
<span className="font-medium">Add custom relay</span>
<span className="text-xs text-muted-foreground">
{normalizeRelayUrl(inputValue)}
</span>
</div>
</CommandItem>
)}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}

View File

@ -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<Theme>(

View File

@ -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) {
</button>
</DropdownMenuTrigger>
<DropdownMenuContent className='w-56 p-2 animate-scale-in'>
<div className='font-medium text-sm px-2 py-1.5'>Switch Relay</div>
<RelaySelector className="w-full" />
<DropdownMenuSeparator />
<div className='font-medium text-sm px-2 py-1.5'>Switch Account</div>
{otherUsers.map((user) => (
<DropdownMenuItem

View File

@ -2,6 +2,7 @@ import { BrowserRouter } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { NostrLoginProvider } from '@nostrify/react/login';
import NostrProvider from '@/components/NostrProvider';
import { AppProvider } from '@/components/AppProvider';
interface TestAppProps {
children: React.ReactNode;
@ -17,13 +18,15 @@ export function TestApp({ children }: TestAppProps) {
return (
<BrowserRouter>
<NostrLoginProvider storageKey='test-login'>
<NostrProvider relays={['wss://relay.example.com']}>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</NostrProvider>
</NostrLoginProvider>
<AppProvider>
<QueryClientProvider client={queryClient}>
<NostrLoginProvider storageKey='test-login'>
<NostrProvider>
{children}
</NostrProvider>
</NostrLoginProvider>
</QueryClientProvider>
</AppProvider>
</BrowserRouter>
);
}

View File

@ -21,3 +21,20 @@ Object.defineProperty(window, 'scrollTo', {
writable: true,
value: vi.fn(),
});
// 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(),
}));