From e75c4a83556ab52b71db13d16880bd7d450b4ad4 Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Sat, 26 Sep 2026 21:26:13 +0700 Subject: [PATCH] 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. --- AGENTS.md | 11 +++ src/components/NostrProvider.tsx | 30 +++--- src/components/RelayListManager.tsx | 141 +++++++++++++++++++++++++++- src/contexts/AppContext.ts | 9 +- src/lib/relays.ts | 65 +++++++++++++ 5 files changed, 236 insertions(+), 20 deletions(-) create mode 100644 src/lib/relays.ts diff --git a/AGENTS.md b/AGENTS.md index 8f90898..d0ae2ab 100644 --- a/AGENTS.md +++ b/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 diff --git a/src/components/NostrProvider.tsx b/src/components/NostrProvider.tsx index b4a7aae..a64408c 100644 --- a/src/components/NostrProvider.tsx +++ b/src/components/NostrProvider.tsx @@ -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 = (props) => { reqRouter(filters: NostrFilter[]) { const routes = new Map(); - // 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 = (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(); diff --git a/src/components/RelayListManager.tsx b/src/components/RelayListManager.tsx index 0384323..40a2bac 100644 --- a/src/components/RelayListManager.tsx +++ b/src/components/RelayListManager.tsx @@ -63,6 +63,42 @@ export function RelayListManager() { const [relays, setRelays] = useState(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() { + {/* Local relays (app-only, never published) */} +
+
+ +

+ App-only relays (e.g. a LAN node). Used for reads and writes, never published. +

+
+ {localRelays.length > 0 && ( +
+ {localRelays.map((url) => ( + + {renderRelayUrl(url)} + + + ))} +
+ )} +
+ 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" + /> + +
+
+ + {/* Blocked relays */} +
+
+ +

+ Never connect to these — excluded from every read and write. +

+
+ {blockedRelays.length > 0 && ( +
+ {blockedRelays.map((url) => ( + + {renderRelayUrl(url)} + + + ))} +
+ )} +
+ 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" + /> + +
+
+ {!user && (

Log in to sync your relay list with Nostr diff --git a/src/contexts/AppContext.ts b/src/contexts/AppContext.ts index 3ca6318..b4108f7 100644 --- a/src/contexts/AppContext.ts +++ b/src/contexts/AppContext.ts @@ -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 { diff --git a/src/lib/relays.ts b/src/lib/relays.ts new file mode 100644 index 0000000..39f0b79 --- /dev/null +++ b/src/lib/relays.ts @@ -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]); +}