Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 30 additions & 19 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -27,25 +24,39 @@ const queryClient = new QueryClient({
},
});

const defaultConfig: AppConfig = {
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 (
<NostrLoginProvider storageKey='nostr:login'>
<NostrProvider relays={defaultRelays}>
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<JoinDialogProvider>
<WalletLoader />
<Toaster />
<Sonner />
<AppRouter />
</JoinDialogProvider>
</TooltipProvider>
</QueryClientProvider>
</NostrProvider>
</NostrLoginProvider>
<AppProvider storageKey="nostr:app-config" defaultConfig={defaultConfig} presetRelays={presetRelays}>
<QueryClientProvider client={queryClient}>
<NostrLoginProvider storageKey='nostr:login'>
<NostrProvider>
<TooltipProvider>
<JoinDialogProvider>
<WalletLoader />
<Toaster />
<Sonner />
<AppRouter />
</JoinDialogProvider>
</TooltipProvider>
</NostrProvider>
</NostrLoginProvider>
</QueryClientProvider>
</AppProvider>
);
}

Expand Down
42 changes: 42 additions & 0 deletions src/components/AppProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { ReactNode } from 'react';
import { useLocalStorage } from '@/hooks/useLocalStorage';
import { AppContext, type AppConfig, type AppContextType } 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<AppConfig>(storageKey, defaultConfig);

// Generic config updater with callback pattern
const updateConfig = (updater: (currentConfig: AppConfig) => AppConfig) => {
setConfig(updater);
};

const appContextValue: AppContextType = {
config,
updateConfig,
presetRelays,
};

return (
<AppContext.Provider value={appContextValue}>
{children}
</AppContext.Provider>
);
}
42 changes: 34 additions & 8 deletions src/components/NostrProvider.tsx
Original file line number Diff line number Diff line change
@@ -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[];
}

/**
Expand All @@ -25,21 +26,46 @@ class TimestampTrackingNPool extends NPool {
}

const NostrProvider: React.FC<NostrProviderProps> = (props) => {
const { children, relays } = props;
const { children } = props;
const { config, presetRelays } = useAppContext();

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);

// 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<string>([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];
},
});
}
Expand Down
182 changes: 182 additions & 0 deletions src/components/RelaySelector.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className={cn("justify-between h-10", className)}
>
<div className="flex items-center gap-2 min-w-0">
<Wifi className="h-4 w-4 text-muted-foreground" />
<span className="truncate">
{selectedOption
? selectedOption.name
: selectedRelay
? selectedRelay.replace(/^wss?:\/\//, '')
: "Select relay..."
}
</span>
</div>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[400px] p-0" align="start">
<Command>
<CommandInput
placeholder="Search relays or enter custom URL..."
value={inputValue}
onValueChange={setInputValue}
className="h-9"
/>
<CommandList>
<CommandEmpty>
{inputValue && isValidRelayInput(inputValue) ? (
<CommandItem
onSelect={() => handleAddCustomRelay(inputValue)}
className="cursor-pointer"
>
<Plus className="mr-2 h-4 w-4 text-green-600" />
<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 format" : "No relays found. Try typing a custom URL."}
</div>
)}
</CommandEmpty>
<CommandGroup>
{presetRelays
.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) => {
setSelectedRelay(normalizeRelayUrl(currentValue));
setOpen(false);
setInputValue("");
}}
className="py-3"
>
<div className="flex items-center gap-3 w-full">
<Check
className={cn(
"h-4 w-4 text-green-600",
selectedRelay === option.url ? "opacity-100" : "opacity-0"
)}
/>
<Globe className="h-4 w-4 text-muted-foreground" />
<div className="flex flex-col min-w-0 flex-1">
<span className="font-medium truncate">{option.name}</span>
<span className="text-xs text-muted-foreground truncate">{option.url}</span>
</div>
</div>
</CommandItem>
))}
{inputValue && isValidRelayInput(inputValue) && (
<CommandItem
onSelect={() => handleAddCustomRelay(inputValue)}
className="cursor-pointer border-t py-3"
>
<div className="flex items-center gap-3 w-full">
<Plus className="h-4 w-4 text-green-600" />
<Globe className="h-4 w-4 text-muted-foreground" />
<div className="flex flex-col min-w-0 flex-1">
<span className="font-medium">Add custom relay</span>
<span className="text-xs text-muted-foreground truncate">
{normalizeRelayUrl(inputValue)}
</span>
</div>
</div>
</CommandItem>
)}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
17 changes: 17 additions & 0 deletions src/contexts/AppContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { createContext } from "react";

export interface AppConfig {
/** 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<AppContextType | undefined>(undefined);
14 changes: 14 additions & 0 deletions src/hooks/useAppContext.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading