mirror of
https://gitlab.com/soapbox-pub/mkstack.git
synced 2025-08-27 04:59:22 +00:00
Add RelaySelector
This commit is contained in:
parent
190f0cd791
commit
a8163d7a6c
38
src/App.tsx
38
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 (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
95
src/components/AppProvider.ts
Normal file
95
src/components/AppProvider.ts
Normal 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;
|
||||
}
|
@ -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);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
170
src/components/RelaySelector.tsx
Normal file
170
src/components/RelaySelector.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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>(
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -20,4 +20,21 @@ Object.defineProperty(window, 'matchMedia', {
|
||||
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(),
|
||||
}));
|
Loading…
x
Reference in New Issue
Block a user