mirror of
https://gitlab.com/soapbox-pub/mkstack.git
synced 2025-08-27 13:09: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 { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { NostrLoginProvider } from '@nostrify/react/login';
|
import { NostrLoginProvider } from '@nostrify/react/login';
|
||||||
|
import { AppProvider } from '@/components/AppProvider';
|
||||||
import AppRouter from './AppRouter';
|
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({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
@ -31,20 +25,22 @@ const queryClient = new QueryClient({
|
|||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
return (
|
return (
|
||||||
<ThemeProvider defaultTheme="system">
|
<ThemeProvider defaultTheme="light" storageKey="theme">
|
||||||
<NostrLoginProvider storageKey='nostr:login'>
|
<AppProvider>
|
||||||
<NostrProvider relays={defaultRelays}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<QueryClientProvider client={queryClient}>
|
<NostrLoginProvider storageKey='nostr:login'>
|
||||||
<TooltipProvider>
|
<NostrProvider>
|
||||||
<Toaster />
|
<TooltipProvider>
|
||||||
<Sonner />
|
<Toaster />
|
||||||
<Suspense fallback={<div className="flex items-center justify-center h-screen"><Spinner /></div>}>
|
<Sonner />
|
||||||
<AppRouter />
|
<Suspense fallback={<div className="bg-black flex items-center justify-center h-screen"><Spinner /></div>}>
|
||||||
</Suspense>
|
<AppRouter />
|
||||||
</TooltipProvider>
|
</Suspense>
|
||||||
</QueryClientProvider>
|
</TooltipProvider>
|
||||||
</NostrProvider>
|
</NostrProvider>
|
||||||
</NostrLoginProvider>
|
</NostrLoginProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</AppProvider>
|
||||||
</ThemeProvider>
|
</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 { NostrEvent, NPool, NRelay1 } from '@nostrify/nostrify';
|
||||||
import { NostrContext } from '@nostrify/react';
|
import { NostrContext } from '@nostrify/react';
|
||||||
import React, { useRef } from 'react';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useAppConfig } from './AppProvider';
|
||||||
|
|
||||||
interface NostrProviderProps {
|
interface NostrProviderProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
relays: string[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const NostrProvider: React.FC<NostrProviderProps> = (props) => {
|
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
|
// Create NPool instance only once
|
||||||
const pool = useRef<NPool | undefined>(undefined);
|
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) {
|
if (!pool.current) {
|
||||||
pool.current = new NPool({
|
pool.current = new NPool({
|
||||||
open(url: string) {
|
open(url: string) {
|
||||||
return new NRelay1(url);
|
return new NRelay1(url);
|
||||||
},
|
},
|
||||||
reqRouter(filters) {
|
reqRouter(filters) {
|
||||||
return new Map(relays.map((url) => [url, filters]));
|
return new Map([[relayUrl.current, filters]]);
|
||||||
},
|
},
|
||||||
eventRouter(_event: NostrEvent) {
|
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 = {
|
type ThemeProviderProps = {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
defaultTheme?: Theme;
|
defaultTheme: Theme;
|
||||||
storageKey?: string;
|
storageKey: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ThemeProvider({
|
export function ThemeProvider({
|
||||||
children,
|
children,
|
||||||
defaultTheme = "light",
|
defaultTheme,
|
||||||
storageKey = "theme",
|
storageKey,
|
||||||
...props
|
...props
|
||||||
}: ThemeProviderProps) {
|
}: ThemeProviderProps) {
|
||||||
const [theme, setTheme] = useState<Theme>(
|
const [theme, setTheme] = useState<Theme>(
|
||||||
|
@ -10,6 +10,7 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu.tsx';
|
} from '@/components/ui/dropdown-menu.tsx';
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar.tsx';
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar.tsx';
|
||||||
|
import { RelaySelector } from '@/components/RelaySelector';
|
||||||
import { useLoggedInAccounts, type Account } from '@/hooks/useLoggedInAccounts';
|
import { useLoggedInAccounts, type Account } from '@/hooks/useLoggedInAccounts';
|
||||||
import { genUserName } from '@/lib/genUserName';
|
import { genUserName } from '@/lib/genUserName';
|
||||||
|
|
||||||
@ -39,6 +40,9 @@ export function AccountSwitcher({ onAddAccountClick }: AccountSwitcherProps) {
|
|||||||
</button>
|
</button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent className='w-56 p-2 animate-scale-in'>
|
<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>
|
<div className='font-medium text-sm px-2 py-1.5'>Switch Account</div>
|
||||||
{otherUsers.map((user) => (
|
{otherUsers.map((user) => (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
|
@ -2,6 +2,7 @@ import { BrowserRouter } from 'react-router-dom';
|
|||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { NostrLoginProvider } from '@nostrify/react/login';
|
import { NostrLoginProvider } from '@nostrify/react/login';
|
||||||
import NostrProvider from '@/components/NostrProvider';
|
import NostrProvider from '@/components/NostrProvider';
|
||||||
|
import { AppProvider } from '@/components/AppProvider';
|
||||||
|
|
||||||
interface TestAppProps {
|
interface TestAppProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@ -17,13 +18,15 @@ export function TestApp({ children }: TestAppProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<NostrLoginProvider storageKey='test-login'>
|
<AppProvider>
|
||||||
<NostrProvider relays={['wss://relay.example.com']}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<QueryClientProvider client={queryClient}>
|
<NostrLoginProvider storageKey='test-login'>
|
||||||
{children}
|
<NostrProvider>
|
||||||
</QueryClientProvider>
|
{children}
|
||||||
</NostrProvider>
|
</NostrProvider>
|
||||||
</NostrLoginProvider>
|
</NostrLoginProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</AppProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -20,4 +20,21 @@ Object.defineProperty(window, 'matchMedia', {
|
|||||||
Object.defineProperty(window, 'scrollTo', {
|
Object.defineProperty(window, 'scrollTo', {
|
||||||
writable: true,
|
writable: true,
|
||||||
value: vi.fn(),
|
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