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:
SkateSpots 2026-09-26 21:26:13 +07:00
parent a1c30cad4e
commit e75c4a8355
5 changed files with 236 additions and 20 deletions

View file

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

View file

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

View file

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

View file

@ -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
View 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]);
}