feat(relays): Local + Blocked relay categories
Add a purposeful subset of Amethyst-style relay categories (not a full clone): - App-local 'Local relays' (never published; added to reads+writes) - App-local 'Blocked relays' (excluded from every read/write) - src/lib/relays.ts: resolveRelays()/useRelays() + URL normalisation - NostrProvider req/event routers go through resolveRelays (blocklist applied) - RelayListManager: Local/Blocked editors; NIP-65 save now preserves the new lists instead of overwriting relayMetadata Deliberately omitted (documented in AGENTS.md): KeyPackage (10051, no MLS consumer) and proxy/broadcast/private-home (no such discovery model here). Follow-up noted: a few hooks still read config.relayMetadata.relays directly.
This commit is contained in:
parent
a1c30cad4e
commit
e75c4a8355
5 changed files with 236 additions and 20 deletions
11
AGENTS.md
11
AGENTS.md
|
|
@ -56,6 +56,17 @@ session with **no other memory of this project**. So:
|
|||
(tiny amounts) before trusting it with real money.
|
||||
- **Wallet**: `useCashu` (coco-cashu) is **native-only** — it returns early on
|
||||
web (mints are CORS-blocked). Zap rails are NWC + WebLN + Cashu melt (`useZaps`).
|
||||
- **Relays**: NIP-65 read/write (kind 10002) plus two **app-local** categories —
|
||||
`localRelays` (never published; added to reads+writes) and `blockedRelays`
|
||||
(excluded everywhere). `resolveRelays()` in `src/lib/relays.ts` applies them;
|
||||
`NostrProvider` uses it for its req/event routers, and the Relays settings UI
|
||||
(`RelayListManager`) has Local/Blocked editors. Deliberately **not** shipped:
|
||||
KeyPackage relays (`10051`, no MLS consumer yet) and Amethyst's proxy/broadcast/
|
||||
private-home (we don't have that relay-discovery model) — don't add UI for
|
||||
features we don't implement.
|
||||
- ⚠️ A few hooks build relay lists directly from `config.relayMetadata.relays`
|
||||
(DMs, listing reports, seller info, skate relays, zaps) and therefore do NOT
|
||||
yet honour the blocklist — migrate them to `useRelays()` when touched.
|
||||
- **Group DMs**: NIP-17 (`useDirectMessages`, `GroupConversationPage`).
|
||||
- **Mints directory**: `/mints` page lists Cashu mints from the community
|
||||
**MintRadar** project. MintRadar has **no CORS**, so it's fetched same-origin
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { useQueryClient } from '@tanstack/react-query';
|
|||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useCitrine } from '@/hooks/useCitrine';
|
||||
import { SKATE_RELAY } from '@/lib/types';
|
||||
import { resolveRelays } from '@/lib/relays';
|
||||
|
||||
interface NostrProviderProps {
|
||||
children: React.ReactNode;
|
||||
|
|
@ -114,15 +115,12 @@ const NostrProvider: React.FC<NostrProviderProps> = (props) => {
|
|||
reqRouter(filters: NostrFilter[]) {
|
||||
const routes = new Map<string, NostrFilter[]>();
|
||||
|
||||
// Get relays from metadata, fallback to defaults if empty
|
||||
const configuredRelays = relayMetadata.current.relays.length > 0
|
||||
? relayMetadata.current.relays
|
||||
: DEFAULT_RELAYS;
|
||||
|
||||
// Route to all read relays
|
||||
const readRelays = configuredRelays
|
||||
.filter(r => r.read)
|
||||
.map(r => r.url);
|
||||
// Get relays from metadata, fallback to defaults if empty.
|
||||
// `resolveRelays` applies the blocklist and adds local relays.
|
||||
const resolved = resolveRelays(relayMetadata.current);
|
||||
const readRelays = resolved.read.length > 0
|
||||
? resolved.read
|
||||
: DEFAULT_RELAYS.filter((r) => r.read).map((r) => r.url);
|
||||
|
||||
// --- Community "catching" relay: top hierarchy ---
|
||||
// goskatespots.nostr1.com is the app's curated, authoritative relay.
|
||||
|
|
@ -158,15 +156,11 @@ const NostrProvider: React.FC<NostrProviderProps> = (props) => {
|
|||
return routes;
|
||||
},
|
||||
eventRouter(event: NostrEvent) {
|
||||
// Get relays from metadata, fallback to defaults if empty
|
||||
const configuredRelays = relayMetadata.current.relays.length > 0
|
||||
? relayMetadata.current.relays
|
||||
: DEFAULT_RELAYS;
|
||||
|
||||
// Get write relays from metadata
|
||||
const writeRelays = configuredRelays
|
||||
.filter(r => r.write)
|
||||
.map(r => r.url);
|
||||
// `resolveRelays` applies the blocklist and adds local relays.
|
||||
const resolved = resolveRelays(relayMetadata.current);
|
||||
const writeRelays = resolved.write.length > 0
|
||||
? resolved.write
|
||||
: DEFAULT_RELAYS.filter((r) => r.write).map((r) => r.url);
|
||||
|
||||
const allRelays = new Set<string>();
|
||||
|
||||
|
|
|
|||
|
|
@ -63,6 +63,42 @@ export function RelayListManager() {
|
|||
|
||||
const [relays, setRelays] = useState<Relay[]>(config.relayMetadata.relays);
|
||||
const [newRelayUrl, setNewRelayUrl] = useState('');
|
||||
const [newLocalUrl, setNewLocalUrl] = useState('');
|
||||
const [newBlockedUrl, setNewBlockedUrl] = useState('');
|
||||
|
||||
const localRelays = config.relayMetadata.localRelays ?? [];
|
||||
const blockedRelays = config.relayMetadata.blockedRelays ?? [];
|
||||
|
||||
const updateCategory = (key: 'localRelays' | 'blockedRelays', list: string[]) => {
|
||||
// Local-only lists — never published to Nostr, so `updatedAt` is untouched.
|
||||
updateConfig((c) => ({
|
||||
...c,
|
||||
relayMetadata: { ...(c.relayMetadata ?? { relays: [], updatedAt: 0 }), [key]: list },
|
||||
}));
|
||||
};
|
||||
|
||||
const addCategoryRelay = (
|
||||
key: 'localRelays' | 'blockedRelays',
|
||||
value: string,
|
||||
reset: () => void,
|
||||
) => {
|
||||
if (!isValidRelayUrl(value)) {
|
||||
toast({ title: 'Invalid relay URL', variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
const normalized = normalizeRelayUrl(value);
|
||||
const existing = config.relayMetadata[key] ?? [];
|
||||
if (existing.includes(normalized)) {
|
||||
toast({ title: 'Relay already in this list', variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
updateCategory(key, [...existing, normalized]);
|
||||
reset();
|
||||
};
|
||||
|
||||
const removeCategoryRelay = (key: 'localRelays' | 'blockedRelays', url: string) => {
|
||||
updateCategory(key, (config.relayMetadata[key] ?? []).filter((u) => u !== url));
|
||||
};
|
||||
|
||||
// Sync local state with config when it changes (e.g., from NostrProvider sync)
|
||||
useEffect(() => {
|
||||
|
|
@ -174,10 +210,11 @@ export function RelayListManager() {
|
|||
const saveRelays = (newRelays: Relay[]) => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
// Update local config
|
||||
// Update local config (preserve local/blocked lists — only NIP-65 publishes)
|
||||
updateConfig((current) => ({
|
||||
...current,
|
||||
relayMetadata: {
|
||||
...(current.relayMetadata ?? { relays: [], updatedAt: 0 }),
|
||||
relays: newRelays,
|
||||
updatedAt: now,
|
||||
},
|
||||
|
|
@ -412,6 +449,108 @@ export function RelayListManager() {
|
|||
</button>
|
||||
</div>
|
||||
|
||||
{/* Local relays (app-only, never published) */}
|
||||
<div className="space-y-2 pt-4 border-t border-white/8">
|
||||
<div>
|
||||
<Label className="text-xs font-black uppercase tracking-wider text-muted-foreground">
|
||||
Local relays
|
||||
</Label>
|
||||
<p className="text-[11px] text-muted-foreground mt-0.5">
|
||||
App-only relays (e.g. a LAN node). Used for reads and writes, never published.
|
||||
</p>
|
||||
</div>
|
||||
{localRelays.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{localRelays.map((url) => (
|
||||
<span
|
||||
key={url}
|
||||
className="inline-flex items-center gap-1 rounded-full border border-[#ccff00]/25 bg-[#ccff00]/10 px-2 py-0.5 font-mono text-[11px] text-[#ccff00]"
|
||||
>
|
||||
{renderRelayUrl(url)}
|
||||
<button
|
||||
onClick={() => removeCategoryRelay('localRelays', url)}
|
||||
className="text-[#ccff00]/70 hover:text-white"
|
||||
aria-label={`Remove ${url}`}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={newLocalUrl}
|
||||
onChange={(e) => setNewLocalUrl(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') addCategoryRelay('localRelays', newLocalUrl, () => setNewLocalUrl(''));
|
||||
}}
|
||||
placeholder="ws://192.168.1.10:7777"
|
||||
className="rounded-xl bg-white/3 border-white/8 font-mono text-sm h-9"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-9 rounded-xl border-white/15"
|
||||
onClick={() => addCategoryRelay('localRelays', newLocalUrl, () => setNewLocalUrl(''))}
|
||||
disabled={!newLocalUrl.trim()}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Blocked relays */}
|
||||
<div className="space-y-2 pt-4 border-t border-white/8">
|
||||
<div>
|
||||
<Label className="text-xs font-black uppercase tracking-wider text-muted-foreground">
|
||||
Blocked relays
|
||||
</Label>
|
||||
<p className="text-[11px] text-muted-foreground mt-0.5">
|
||||
Never connect to these — excluded from every read and write.
|
||||
</p>
|
||||
</div>
|
||||
{blockedRelays.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{blockedRelays.map((url) => (
|
||||
<span
|
||||
key={url}
|
||||
className="inline-flex items-center gap-1 rounded-full border border-red-500/30 bg-red-500/10 px-2 py-0.5 font-mono text-[11px] text-red-400"
|
||||
>
|
||||
{renderRelayUrl(url)}
|
||||
<button
|
||||
onClick={() => removeCategoryRelay('blockedRelays', url)}
|
||||
className="text-red-400/70 hover:text-white"
|
||||
aria-label={`Unblock ${url}`}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={newBlockedUrl}
|
||||
onChange={(e) => setNewBlockedUrl(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') addCategoryRelay('blockedRelays', newBlockedUrl, () => setNewBlockedUrl(''));
|
||||
}}
|
||||
placeholder="wss://relay.example.com"
|
||||
className="rounded-xl bg-white/3 border-white/8 font-mono text-sm h-9"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-9 rounded-xl border-red-500/30 text-red-400 hover:bg-red-500/10"
|
||||
onClick={() => addCategoryRelay('blockedRelays', newBlockedUrl, () => setNewBlockedUrl(''))}
|
||||
disabled={!newBlockedUrl.trim()}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!user && (
|
||||
<p className="text-xs text-muted-foreground pt-1">
|
||||
Log in to sync your relay list with Nostr
|
||||
|
|
|
|||
|
|
@ -3,10 +3,17 @@ import { createContext } from "react";
|
|||
export type Theme = "dark" | "light" | "system";
|
||||
|
||||
export interface RelayMetadata {
|
||||
/** List of relays with read/write permissions */
|
||||
/** List of relays with read/write permissions (NIP-65, kind 10002) */
|
||||
relays: { url: string; read: boolean; write: boolean }[];
|
||||
/** Unix timestamp of when the relay list was last updated */
|
||||
updatedAt: number;
|
||||
/**
|
||||
* App-local relays (never published) — e.g. a LAN/dev relay. Included in
|
||||
* reads and writes alongside the NIP-65 list.
|
||||
*/
|
||||
localRelays?: string[];
|
||||
/** App-local blocklist — excluded from every read and write. */
|
||||
blockedRelays?: string[];
|
||||
}
|
||||
|
||||
export interface AppConfig {
|
||||
|
|
|
|||
65
src/lib/relays.ts
Normal file
65
src/lib/relays.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { useMemo } from 'react';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import type { RelayMetadata } from '@/contexts/AppContext';
|
||||
|
||||
/** Host (no scheme) for display, falling back to the raw value. */
|
||||
export function relayHost(url: string): string {
|
||||
try {
|
||||
return new URL(url).host;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
/** Normalise user input into a `ws(s)://` relay URL, or null if invalid. */
|
||||
export function normalizeRelayUrl(input: string): string | null {
|
||||
const raw = input.trim();
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const url = new URL(/^wss?:\/\//i.test(raw) ? raw : `wss://${raw}`);
|
||||
if (url.protocol === 'http:') url.protocol = 'ws:';
|
||||
if (url.protocol === 'https:') url.protocol = 'wss:';
|
||||
if (url.protocol !== 'ws:' && url.protocol !== 'wss:') return null;
|
||||
if (!url.host) return null;
|
||||
return url.toString().replace(/\/$/, '');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ResolvedRelays {
|
||||
/** NIP-65 relays with blocked ones removed (read/write flags kept). */
|
||||
configured: { url: string; read: boolean; write: boolean }[];
|
||||
/** App-local relays (blocked ones removed). */
|
||||
local: string[];
|
||||
/** The blocklist, lower-cased. */
|
||||
blocked: string[];
|
||||
/** URLs to read from: local first, then read-enabled configured. */
|
||||
read: string[];
|
||||
/** URLs to write to: write-enabled configured, then local. */
|
||||
write: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the effective relay sets from config: blocked relays are removed
|
||||
* everywhere, and local relays are added to both reads and writes.
|
||||
*/
|
||||
export function resolveRelays(metadata: RelayMetadata): ResolvedRelays {
|
||||
const blocked = new Set((metadata.blockedRelays ?? []).map((u) => u.toLowerCase()));
|
||||
const local = (metadata.localRelays ?? []).filter((u) => !blocked.has(u.toLowerCase()));
|
||||
const configured = metadata.relays.filter((r) => !blocked.has(r.url.toLowerCase()));
|
||||
|
||||
return {
|
||||
configured,
|
||||
local,
|
||||
blocked: [...blocked],
|
||||
read: [...local, ...configured.filter((r) => r.read).map((r) => r.url)],
|
||||
write: [...configured.filter((r) => r.write).map((r) => r.url), ...local],
|
||||
};
|
||||
}
|
||||
|
||||
/** React wrapper around {@link resolveRelays} for components/hooks. */
|
||||
export function useRelays(): ResolvedRelays {
|
||||
const { config } = useAppContext();
|
||||
return useMemo(() => resolveRelays(config.relayMetadata), [config.relayMetadata]);
|
||||
}
|
||||
Loading…
Reference in a new issue