diff --git a/CLAUDE.md b/CLAUDE.md index f5c49f8f..1a970867 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -375,6 +375,11 @@ This allows `applyTheme()` to switch themes at runtime. - Provides diagnostic UI with retry capability and error details - Error boundaries auto-reset when event changes +**Shared Badge Components**: +- **`KindBadge`** (`src/components/KindBadge.tsx`): Displays a Nostr event kind with icon, name, and kind number. Uses `getKindInfo()` from `src/constants/kinds.ts`. Variants: `"default"` (icon + name), `"compact"` (icon only), `"full"` (icon + name + kind number). Supports `clickable` prop to open kind detail window. +- **`NIPBadge`** (`src/components/NIPBadge.tsx`): Displays a NIP reference with number and optional name. Clickable to open the NIP document in a new window. Shows deprecation state. Props: `nipNumber`, `showName`, `showNIPPrefix`. +- Use these components whenever displaying kind numbers or NIP references in the UI — they provide consistent styling, tooltips, and navigation. + ## Chat System **Current Status**: Only NIP-29 (relay-based groups) is supported. Other protocols are planned for future releases. diff --git a/PLAN-blocked-search-relays.md b/PLAN-blocked-search-relays.md new file mode 100644 index 00000000..32296a70 --- /dev/null +++ b/PLAN-blocked-search-relays.md @@ -0,0 +1,204 @@ +# Plan: Honor Blocked Relay List (10006) & Search Relay List (10007) + +## Context + +Grimoire now fetches and displays kinds 10006 (blocked relays) and 10007 (search relays) in Settings, but these lists have **no runtime effect**. This plan adds two behaviors: + +1. **Blocked relays (kind 10006)**: Never connect to relays the user has explicitly blocked +2. **Search relays (kind 10007)**: Use the user's search relays for NIP-50 search queries when no relays are explicitly provided + +## Architecture Analysis + +### Where relay connections originate + +| Code Path | File | How relays are selected | +|-----------|------|------------------------| +| NIP-65 outbox selection | `relay-selection.ts` → `selectRelaysForFilter()` | Fetches kind 10002 for authors, applies health filter | +| Event loader hints | `loaders.ts` → `eventLoader()` | Merges relay hints from pointers, seen-at, outbox, fallback | +| REQ viewer (explicit relays) | `ReqViewer.tsx` | User-provided relay list from command args | +| REQ viewer (auto) | `ReqViewer.tsx` → `useOutboxRelays()` | Calls `selectRelaysForFilter()` | +| useReqTimelineEnhanced | `useReqTimelineEnhanced.ts` | Receives relay list from caller, subscribes per-relay | +| Chat adapters | `nip-29-adapter.ts`, etc. | Group-specific relay (not filterable — group IS the relay) | +| Publishing | `hub.ts` → `publishEvent()` | Author's outbox relays from `relayListCache` | +| Address loader | `loaders.ts` → `addressLoader()` | Internal applesauce loader, uses pool directly | +| Live timeline | `useLiveTimeline.ts` | Receives relay list from caller | + +### Key insight: Two filtering points + +1. **`relay-selection.ts`** — The central relay selection function. Most automated queries flow through here. This is where blocked relays should be filtered for query paths. +2. **`relay-pool.ts`** — The singleton pool. Adding a filter here would catch ALL connections including explicit ones. This is the nuclear option. + +## Implementation Plan + +### Part 1: Blocked Relay List Service + +**New file: `src/services/blocked-relays.ts`** + +A lightweight singleton that reads the user's kind 10006 from EventStore and exposes: + +```ts +class BlockedRelayService { + // Reactive set updated when kind 10006 changes in EventStore + blockedUrls$: BehaviorSubject>; + + // Sync check - for hot path filtering + isBlocked(url: string): boolean; + + // Filter helper - remove blocked relays from a list + filter(relays: string[]): string[]; + + // Start watching for the active account's kind 10006 + setAccount(pubkey: string | undefined): void; +} + +export const blockedRelays = new BlockedRelayService(); +``` + +**Why a singleton service?** Same pattern as `relayListCache` and `relayLiveness`. Needs to be accessible from non-React code (relay-selection.ts, loaders.ts, hub.ts) without prop drilling. + +**Implementation details:** +- Subscribe to `eventStore.replaceable(10006, pubkey, "")` when account changes +- Parse `["relay", url]` tags, normalize URLs, store in a `Set` +- `filter()` returns `relays.filter(url => !this.isBlocked(url))` +- Must handle the case where kind 10006 hasn't loaded yet (don't block anything — fail open) + +### Part 2: Wire blocked relay filtering into relay selection + +**File: `src/services/relay-selection.ts`** + +In `selectRelaysForFilter()`, after the existing health filter (`liveness.filter()`), add: + +```ts +// Existing flow: +const healthy = liveness.filter(sanitized); + +// Add after: +const allowed = blockedRelays.filter(healthy); +``` + +This catches the main query path (REQ viewer auto-relay, outbox selection, etc.). + +**File: `src/services/loaders.ts`** + +In `eventLoader()`, filter the merged relay hints before subscribing: + +```ts +const relays = blockedRelays.filter(mergedRelayHints); +``` + +**File: `src/services/hub.ts`** + +In `publishEvent()`, filter outbox relays: + +```ts +let relays = await relayListCache.getOutboxRelays(event.pubkey); +relays = blockedRelays.filter(relays ?? []); +``` + +This prevents publishing to blocked relays. + +### Part 3: Account lifecycle integration + +**File: `src/hooks/useAccountSync.ts`** + +When the active account changes, update the blocked relay service: + +```ts +import { blockedRelays } from "@/services/blocked-relays"; + +// In the account sync effect: +useEffect(() => { + blockedRelays.setAccount(activeAccount?.pubkey); +}, [activeAccount?.pubkey]); +``` + +### Part 4: Search Relay List (kind 10007) + +**Simpler scope** — search relays only apply when a filter has `.search` set. + +**File: `src/services/relay-selection.ts`** + +Add a new exported function: + +```ts +export async function getSearchRelays(pubkey: string | undefined): Promise { + if (!pubkey) return undefined; + + // Check EventStore for kind 10007 + const event = eventStore.getReplaceable(10007, pubkey, ""); + if (!event) return undefined; + + const relays = getRelaysFromList(event, "all"); + if (relays.length === 0) return undefined; + + return blockedRelays.filter(relays); +} +``` + +**File: `src/components/ReqViewer.tsx`** + +In the relay selection logic (around line 795-812), when no explicit relays are provided and the filter has `.search`: + +```ts +// If search query and user has search relays configured, use those +if (filter.search && !explicitRelays) { + const searchRelays = await getSearchRelays(pubkey); + if (searchRelays?.length) { + return searchRelays; + } + // Fall through to normal relay selection if no search relays configured +} +``` + +This also applies to `useOutboxRelays` or wherever REQ relay selection happens — need to check if the filter contains a search term and short-circuit to search relays. + +### Part 5: Testing + +**New test file: `src/services/blocked-relays.test.ts`** + +- `isBlocked()` returns false when no account is set +- `isBlocked()` correctly identifies blocked URLs after event loaded +- `filter()` removes blocked relays from a list +- URL normalization: blocking `relay.example.com` also blocks `wss://relay.example.com/` +- Handles empty/missing kind 10006 gracefully (fail open) + +**New test file: `src/services/relay-selection.test.ts`** (additions) + +- `selectRelaysForFilter()` excludes blocked relays +- `getSearchRelays()` returns search relays when kind 10007 exists +- `getSearchRelays()` returns undefined when no kind 10007 + +## Edge Cases & Considerations + +### Blocked relays +- **NIP-29 chat groups**: Do NOT filter group relay — the group IS the relay. If user blocks a relay that hosts a group, they simply won't join that group. +- **Explicit relay args in REQ command**: Should we honor the block? Recommendation: YES, still filter. If the user explicitly types `req -r wss://blocked.relay`, we should warn them but respect the block. They can unblock in settings. +- **Race condition on login**: Kind 10006 may not be loaded yet when first queries fire. Fail open (don't block anything until the event is loaded). This is the safe default. +- **Publishing own kind 10006**: When saving the blocked relay list itself, we publish to the user's outbox relays — which won't include blocked relays (they wouldn't be in kind 10002 typically). No special handling needed. + +### Search relays +- **No search relays configured**: Fall through to normal NIP-65 relay selection. The user's regular relays may support NIP-50 search. +- **Search relays + explicit relays**: If user provides `-r` flag in REQ command, respect explicit relays over search relays. +- **Non-search queries**: Kind 10007 only applies when `filter.search` is set. Normal queries are unaffected. + +## File Change Summary + +| File | Change | +|------|--------| +| `src/services/blocked-relays.ts` | **NEW** — Singleton service for blocked relay filtering | +| `src/services/blocked-relays.test.ts` | **NEW** — Tests | +| `src/services/relay-selection.ts` | Add blocked relay filter + `getSearchRelays()` | +| `src/services/loaders.ts` | Filter relay hints through blocked list | +| `src/services/hub.ts` | Filter publish relays through blocked list | +| `src/hooks/useAccountSync.ts` | Wire blocked relay service to account lifecycle | +| `src/components/ReqViewer.tsx` | Use search relays for NIP-50 queries | + +## Order of Implementation + +1. `blocked-relays.ts` service + tests (foundation) +2. Wire into `useAccountSync.ts` (lifecycle) +3. Filter in `relay-selection.ts` (main query path) +4. Filter in `loaders.ts` (event loading) +5. Filter in `hub.ts` (publishing) +6. `getSearchRelays()` + ReqViewer integration (search) +7. Full integration test diff --git a/src/components/SettingsViewer.tsx b/src/components/SettingsViewer.tsx index ef596e4a..8153266c 100644 --- a/src/components/SettingsViewer.tsx +++ b/src/components/SettingsViewer.tsx @@ -9,7 +9,8 @@ import { import { Switch } from "./ui/switch"; import { useSettings } from "@/hooks/useSettings"; import { useTheme } from "@/lib/themes"; -import { Palette, FileEdit } from "lucide-react"; +import { Palette, FileEdit, Radio } from "lucide-react"; +import { RelayListsSettings } from "./settings/RelayListsSettings"; export function SettingsViewer() { const { settings, updateSetting } = useSettings(); @@ -28,6 +29,10 @@ export function SettingsViewer() { Post + + + Relays + @@ -142,6 +147,10 @@ export function SettingsViewer() { + + + + diff --git a/src/components/settings/RelayListsSettings.tsx b/src/components/settings/RelayListsSettings.tsx new file mode 100644 index 00000000..a3fabaa2 --- /dev/null +++ b/src/components/settings/RelayListsSettings.tsx @@ -0,0 +1,556 @@ +import { useState, useCallback, useEffect, useMemo } from "react"; +import { use$, useEventStore } from "applesauce-react/hooks"; +import { EventFactory } from "applesauce-core/event-factory"; +import { toast } from "sonner"; +import { X, Plus, Loader2, Save, Undo2, CircleDot } from "lucide-react"; +import type { NostrEvent } from "nostr-tools"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { KindBadge } from "@/components/KindBadge"; +import { NIPBadge } from "@/components/NIPBadge"; +import { useAccount } from "@/hooks/useAccount"; +import { useRelayInfo } from "@/hooks/useRelayInfo"; +import { publishEvent } from "@/services/hub"; +import accountManager from "@/services/accounts"; +import { cn } from "@/lib/utils"; +import { + type RelayEntry, + type RelayMode, + type RelayListKindConfig, + parseRelayEntries, + buildRelayListTags, + sanitizeRelayInput, + relayEntriesEqual, + getRelayMode, + modeToFlags, +} from "@/lib/relay-list-utils"; + +// --- Config --- + +interface RelayListKindUIConfig extends RelayListKindConfig { + nip: string; +} + +const RELAY_LIST_KINDS: RelayListKindUIConfig[] = [ + { + kind: 10002, + name: "Relay List", + description: + "Your primary read and write relays. Other clients use this to find your posts and deliver mentions to you.", + nip: "65", + tagName: "r", + hasMarkers: true, + }, + { + kind: 10006, + name: "Blocked Relays", + description: + "Relays your client should never connect to. Useful for avoiding spam or untrusted servers.", + nip: "51", + tagName: "relay", + hasMarkers: false, + }, + { + kind: 10007, + name: "Search Relays", + description: + "Relays used for search queries. These should support NIP-50 full-text search.", + nip: "51", + tagName: "relay", + hasMarkers: false, + }, + { + kind: 10012, + name: "Favorite Relays", + description: + "Relays you find interesting or want to browse. Can be used by clients for relay discovery and recommendations.", + nip: "51", + tagName: "relay", + hasMarkers: false, + }, + { + kind: 10050, + name: "DM Relays", + description: + "Relays where you receive direct messages. Senders look up this list to deliver encrypted DMs to you.", + nip: "17", + tagName: "relay", + hasMarkers: false, + }, +]; + +// --- Components --- + +function RelayModeSelect({ + mode, + onChange, +}: { + mode: RelayMode; + onChange: (mode: RelayMode) => void; +}) { + return ( + + ); +} + +/** Display-only relay row for the settings list (no navigation on click) */ +function RelaySettingsRow({ + url, + iconClassname, +}: { + url: string; + iconClassname?: string; +}) { + const relayInfo = useRelayInfo(url); + const displayUrl = url.replace(/^wss?:\/\//, "").replace(/\/$/, ""); + + return ( +
+ {relayInfo?.icon && ( + + )} + {displayUrl} +
+ ); +} + +function RelayEntryRow({ + entry, + config, + onRemove, + onModeChange, +}: { + entry: RelayEntry; + config: RelayListKindUIConfig; + onRemove: () => void; + onModeChange?: (mode: RelayMode) => void; +}) { + const currentMode = getRelayMode(entry); + + return ( +
+ + {config.hasMarkers && onModeChange && ( + + )} + +
+ ); +} + +function AddRelayInput({ + config, + existingUrls, + onAdd, +}: { + config: RelayListKindUIConfig; + existingUrls: Set; + onAdd: (entry: RelayEntry) => void; +}) { + const [input, setInput] = useState(""); + const [mode, setMode] = useState("readwrite"); + const [error, setError] = useState(null); + + const handleAdd = useCallback(() => { + setError(null); + const normalized = sanitizeRelayInput(input); + + if (!normalized) { + setError("Invalid relay URL"); + return; + } + + if (existingUrls.has(normalized)) { + setError("Relay already in list"); + return; + } + + onAdd({ + url: normalized, + ...modeToFlags(mode), + }); + setInput(""); + setError(null); + }, [input, mode, existingUrls, onAdd]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + handleAdd(); + } + }, + [handleAdd], + ); + + return ( +
+
+ { + setInput(e.target.value); + setError(null); + }} + onKeyDown={handleKeyDown} + placeholder="relay.example.com" + className="h-8 text-xs flex-1" + /> + {config.hasMarkers && ( + + )} + +
+ {error &&

{error}

} +
+ ); +} + +function RelayListAccordion({ + config, + entries, + isDirty, + onChange, +}: { + config: RelayListKindUIConfig; + entries: RelayEntry[]; + isDirty: boolean; + onChange: (entries: RelayEntry[]) => void; +}) { + const existingUrls = useMemo( + () => new Set(entries.map((e) => e.url)), + [entries], + ); + + const handleRemove = useCallback( + (url: string) => { + onChange(entries.filter((e) => e.url !== url)); + }, + [entries, onChange], + ); + + const handleModeChange = useCallback( + (url: string, mode: RelayMode) => { + onChange( + entries.map((e) => + e.url === url ? { ...e, ...modeToFlags(mode) } : e, + ), + ); + }, + [entries, onChange], + ); + + const handleAdd = useCallback( + (entry: RelayEntry) => { + onChange([...entries, entry]); + }, + [entries, onChange], + ); + + return ( + + +
+ + {entries.length > 0 && ( + + {entries.length} + + )} + {isDirty && ( + + )} +
+
+ +
+

+ {config.description} +

+ +
+ {entries.length === 0 ? ( +

+ No relays configured +

+ ) : ( +
+ {entries.map((entry) => ( + handleRemove(entry.url)} + onModeChange={ + config.hasMarkers + ? (mode) => handleModeChange(entry.url, mode) + : undefined + } + /> + ))} +
+ )} + +
+
+ ); +} + +// --- Main Component --- + +export function RelayListsSettings() { + const { pubkey, canSign } = useAccount(); + const eventStore = useEventStore(); + const [saving, setSaving] = useState(false); + + // Read current events from EventStore for each kind + const event10002 = use$( + () => (pubkey ? eventStore.replaceable(10002, pubkey, "") : undefined), + [pubkey], + ); + const event10006 = use$( + () => (pubkey ? eventStore.replaceable(10006, pubkey, "") : undefined), + [pubkey], + ); + const event10007 = use$( + () => (pubkey ? eventStore.replaceable(10007, pubkey, "") : undefined), + [pubkey], + ); + const event10012 = use$( + () => (pubkey ? eventStore.replaceable(10012, pubkey, "") : undefined), + [pubkey], + ); + const event10050 = use$( + () => (pubkey ? eventStore.replaceable(10050, pubkey, "") : undefined), + [pubkey], + ); + + const eventsMap: Record = useMemo( + () => ({ + 10002: event10002, + 10006: event10006, + 10007: event10007, + 10012: event10012, + 10050: event10050, + }), + [event10002, event10006, event10007, event10012, event10050], + ); + + // Local draft state: kind -> entries + const [drafts, setDrafts] = useState>({}); + // Track which event IDs we've initialized from (to re-sync when events update) + const [syncedEventIds, setSyncedEventIds] = useState< + Record + >({}); + + // Sync drafts from EventStore events when they change + useEffect(() => { + let changed = false; + const newDrafts = { ...drafts }; + const newSyncedIds = { ...syncedEventIds }; + + for (const config of RELAY_LIST_KINDS) { + const event = eventsMap[config.kind]; + const eventId = event?.id; + + if (eventId !== syncedEventIds[config.kind]) { + newDrafts[config.kind] = parseRelayEntries(event, config); + newSyncedIds[config.kind] = eventId; + changed = true; + } + } + + if (changed) { + setDrafts(newDrafts); + setSyncedEventIds(newSyncedIds); + } + }, [eventsMap]); // eslint-disable-line react-hooks/exhaustive-deps + + // Per-kind dirty check + const dirtyKinds = useMemo(() => { + const dirty = new Set(); + for (const config of RELAY_LIST_KINDS) { + const original = parseRelayEntries(eventsMap[config.kind], config); + const draft = drafts[config.kind] ?? []; + if (!relayEntriesEqual(original, draft)) { + dirty.add(config.kind); + } + } + return dirty; + }, [eventsMap, drafts]); + + const hasChanges = dirtyKinds.size > 0; + + const handleChange = useCallback((kind: number, entries: RelayEntry[]) => { + setDrafts((prev) => ({ ...prev, [kind]: entries })); + }, []); + + const handleDiscard = useCallback(() => { + const restored: Record = {}; + for (const config of RELAY_LIST_KINDS) { + restored[config.kind] = parseRelayEntries(eventsMap[config.kind], config); + } + setDrafts(restored); + }, [eventsMap]); + + const handleSave = useCallback(async () => { + if (!canSign || saving) return; + + const account = accountManager.active; + if (!account?.signer) { + toast.error("No signer available"); + return; + } + + setSaving(true); + + try { + const factory = new EventFactory({ signer: account.signer }); + + for (const config of RELAY_LIST_KINDS) { + if (!dirtyKinds.has(config.kind)) continue; + + const draft = drafts[config.kind] ?? []; + const tags = buildRelayListTags(draft, config); + const built = await factory.build({ + kind: config.kind, + content: "", + tags, + }); + const signed = await factory.sign(built); + await publishEvent(signed); + } + + toast.success("Relay lists updated"); + } catch (err) { + console.error("Failed to publish relay lists:", err); + toast.error( + `Failed to save: ${err instanceof Error ? err.message : "Unknown error"}`, + ); + } finally { + setSaving(false); + } + }, [canSign, saving, drafts, dirtyKinds]); + + if (!pubkey) { + return ( +
+

Relays

+

+ Log in to manage your relay lists. +

+
+ ); + } + + return ( +
+
+

Relays

+

+ Manage your Nostr relay lists +

+
+ + + {RELAY_LIST_KINDS.map((config) => ( + handleChange(config.kind, entries)} + /> + ))} + + + {!canSign && ( +

+ Read-only account. Log in with a signer to edit relay lists. +

+ )} + +
+ {hasChanges && ( + + )} + +
+
+ ); +} diff --git a/src/constants/kinds.ts b/src/constants/kinds.ts index 791ef06e..bad27117 100644 --- a/src/constants/kinds.ts +++ b/src/constants/kinds.ts @@ -48,8 +48,10 @@ import { Presentation, Radio, Repeat, + Search, Settings, Shield, + ShieldBan, ShoppingBag, Smile, Star, @@ -757,14 +759,14 @@ export const EVENT_KINDS: Record = { name: "Blocked Relay List", description: "Blocked relays list", nip: "51", - icon: Radio, + icon: ShieldBan, }, 10007: { kind: 10007, name: "Search Relay List", description: "Search relays list", nip: "51", - icon: Radio, + icon: Search, }, 10009: { kind: 10009, diff --git a/src/hooks/useAccountSync.ts b/src/hooks/useAccountSync.ts index bc7f96f4..380ba282 100644 --- a/src/hooks/useAccountSync.ts +++ b/src/hooks/useAccountSync.ts @@ -89,6 +89,25 @@ export function useAccountSync() { }; }, [activeAccount?.pubkey, eventStore, setActiveAccountRelays]); + // Fetch other replaceable relay lists when account changes + // These are read directly from EventStore in the settings UI, we just need to trigger fetching + useEffect(() => { + if (!activeAccount?.pubkey) { + return; + } + + const pubkey = activeAccount.pubkey; + const relayListKinds = [10006, 10007, 10012, 10050]; + + const subscriptions = relayListKinds.map((kind) => + addressLoader({ kind, pubkey, identifier: "" }).subscribe(), + ); + + return () => { + subscriptions.forEach((s) => s.unsubscribe()); + }; + }, [activeAccount?.pubkey]); + // Fetch and watch blossom server list (kind 10063) when account changes useEffect(() => { if (!activeAccount?.pubkey) { diff --git a/src/lib/relay-list-utils.test.ts b/src/lib/relay-list-utils.test.ts new file mode 100644 index 00000000..9fb31aa8 --- /dev/null +++ b/src/lib/relay-list-utils.test.ts @@ -0,0 +1,466 @@ +import { describe, it, expect } from "vitest"; +import type { NostrEvent } from "nostr-tools"; +import { + parseRelayEntries, + buildRelayListTags, + sanitizeRelayInput, + relayEntriesEqual, + getRelayMode, + modeToFlags, + type RelayEntry, + type RelayListKindConfig, +} from "./relay-list-utils"; + +// --- Fixtures --- + +const NIP65_CONFIG: Pick = { + tagName: "r", + hasMarkers: true, +}; + +const NIP51_CONFIG: Pick = { + tagName: "relay", + hasMarkers: false, +}; + +function makeEvent( + kind: number, + tags: string[][], + overrides?: Partial, +): NostrEvent { + return { + id: "abc123", + pubkey: "pubkey123", + created_at: 1700000000, + kind, + tags, + content: "", + sig: "sig123", + ...overrides, + }; +} + +// --- parseRelayEntries --- + +describe("parseRelayEntries", () => { + it("should return empty array for undefined event", () => { + expect(parseRelayEntries(undefined, NIP65_CONFIG)).toEqual([]); + }); + + it("should return empty array for event with no matching tags", () => { + const event = makeEvent(10002, [ + ["p", "somepubkey"], + ["e", "someeventid"], + ]); + expect(parseRelayEntries(event, NIP65_CONFIG)).toEqual([]); + }); + + describe("NIP-65 (kind 10002) with markers", () => { + it("should parse relay with no marker as read+write", () => { + const event = makeEvent(10002, [["r", "wss://relay.example.com/"]]); + const result = parseRelayEntries(event, NIP65_CONFIG); + expect(result).toEqual([ + { url: "wss://relay.example.com/", read: true, write: true }, + ]); + }); + + it("should parse relay with read marker", () => { + const event = makeEvent(10002, [ + ["r", "wss://relay.example.com/", "read"], + ]); + const result = parseRelayEntries(event, NIP65_CONFIG); + expect(result).toEqual([ + { url: "wss://relay.example.com/", read: true, write: false }, + ]); + }); + + it("should parse relay with write marker", () => { + const event = makeEvent(10002, [ + ["r", "wss://relay.example.com/", "write"], + ]); + const result = parseRelayEntries(event, NIP65_CONFIG); + expect(result).toEqual([ + { url: "wss://relay.example.com/", read: false, write: true }, + ]); + }); + + it("should parse mixed markers", () => { + const event = makeEvent(10002, [ + ["r", "wss://both.example.com/"], + ["r", "wss://read.example.com/", "read"], + ["r", "wss://write.example.com/", "write"], + ]); + const result = parseRelayEntries(event, NIP65_CONFIG); + expect(result).toEqual([ + { url: "wss://both.example.com/", read: true, write: true }, + { url: "wss://read.example.com/", read: true, write: false }, + { url: "wss://write.example.com/", read: false, write: true }, + ]); + }); + + it("should normalize relay URLs", () => { + const event = makeEvent(10002, [["r", "wss://RELAY.Example.COM"]]); + const result = parseRelayEntries(event, NIP65_CONFIG); + expect(result[0].url).toBe("wss://relay.example.com/"); + }); + + it("should deduplicate relay URLs after normalization", () => { + const event = makeEvent(10002, [ + ["r", "wss://relay.example.com/"], + ["r", "wss://relay.example.com"], + ["r", "wss://RELAY.EXAMPLE.COM/"], + ]); + const result = parseRelayEntries(event, NIP65_CONFIG); + expect(result).toHaveLength(1); + }); + + it("should skip tags with empty URL", () => { + const event = makeEvent(10002, [ + ["r", ""], + ["r", "wss://valid.example.com/"], + ]); + const result = parseRelayEntries(event, NIP65_CONFIG); + expect(result).toHaveLength(1); + expect(result[0].url).toBe("wss://valid.example.com/"); + }); + + it("should skip invalid relay URLs gracefully", () => { + const event = makeEvent(10002, [ + ["r", "not a valid url at all!!!"], + ["r", "wss://valid.example.com/"], + ]); + const result = parseRelayEntries(event, NIP65_CONFIG); + expect(result).toHaveLength(1); + expect(result[0].url).toBe("wss://valid.example.com/"); + }); + + it("should ignore non-r tags", () => { + const event = makeEvent(10002, [ + ["relay", "wss://ignored.example.com/"], + ["r", "wss://included.example.com/"], + ["p", "somepubkey"], + ]); + const result = parseRelayEntries(event, NIP65_CONFIG); + expect(result).toHaveLength(1); + expect(result[0].url).toBe("wss://included.example.com/"); + }); + }); + + describe("NIP-51 (relay tag) without markers", () => { + it("should parse relay tags as read+write", () => { + const event = makeEvent(10006, [["relay", "wss://blocked.example.com/"]]); + const result = parseRelayEntries(event, NIP51_CONFIG); + expect(result).toEqual([ + { url: "wss://blocked.example.com/", read: true, write: true }, + ]); + }); + + it("should ignore markers on NIP-51 lists", () => { + const event = makeEvent(10007, [ + ["relay", "wss://search.example.com/", "read"], + ]); + const result = parseRelayEntries(event, NIP51_CONFIG); + expect(result).toEqual([ + { url: "wss://search.example.com/", read: true, write: true }, + ]); + }); + + it("should parse multiple relay tags", () => { + const event = makeEvent(10050, [ + ["relay", "wss://dm1.example.com/"], + ["relay", "wss://dm2.example.com/"], + ]); + const result = parseRelayEntries(event, NIP51_CONFIG); + expect(result).toHaveLength(2); + }); + + it("should deduplicate NIP-51 relay URLs", () => { + const event = makeEvent(10006, [ + ["relay", "wss://relay.example.com/"], + ["relay", "wss://relay.example.com"], + ]); + const result = parseRelayEntries(event, NIP51_CONFIG); + expect(result).toHaveLength(1); + }); + + it("should ignore r tags for NIP-51 config", () => { + const event = makeEvent(10006, [ + ["r", "wss://ignored.example.com/"], + ["relay", "wss://included.example.com/"], + ]); + const result = parseRelayEntries(event, NIP51_CONFIG); + expect(result).toHaveLength(1); + expect(result[0].url).toBe("wss://included.example.com/"); + }); + }); +}); + +// --- buildRelayListTags --- + +describe("buildRelayListTags", () => { + describe("NIP-65 format (r tags with markers)", () => { + it("should build r tag without marker for read+write", () => { + const entries: RelayEntry[] = [ + { url: "wss://relay.example.com/", read: true, write: true }, + ]; + expect(buildRelayListTags(entries, NIP65_CONFIG)).toEqual([ + ["r", "wss://relay.example.com/"], + ]); + }); + + it("should build r tag with read marker", () => { + const entries: RelayEntry[] = [ + { url: "wss://relay.example.com/", read: true, write: false }, + ]; + expect(buildRelayListTags(entries, NIP65_CONFIG)).toEqual([ + ["r", "wss://relay.example.com/", "read"], + ]); + }); + + it("should build r tag with write marker", () => { + const entries: RelayEntry[] = [ + { url: "wss://relay.example.com/", read: false, write: true }, + ]; + expect(buildRelayListTags(entries, NIP65_CONFIG)).toEqual([ + ["r", "wss://relay.example.com/", "write"], + ]); + }); + + it("should build mixed tags", () => { + const entries: RelayEntry[] = [ + { url: "wss://both.com/", read: true, write: true }, + { url: "wss://read.com/", read: true, write: false }, + { url: "wss://write.com/", read: false, write: true }, + ]; + expect(buildRelayListTags(entries, NIP65_CONFIG)).toEqual([ + ["r", "wss://both.com/"], + ["r", "wss://read.com/", "read"], + ["r", "wss://write.com/", "write"], + ]); + }); + + it("should return empty array for empty entries", () => { + expect(buildRelayListTags([], NIP65_CONFIG)).toEqual([]); + }); + }); + + describe("NIP-51 format (relay tags)", () => { + it("should build relay tags", () => { + const entries: RelayEntry[] = [ + { url: "wss://relay.example.com/", read: true, write: true }, + ]; + expect(buildRelayListTags(entries, NIP51_CONFIG)).toEqual([ + ["relay", "wss://relay.example.com/"], + ]); + }); + + it("should ignore read/write flags for NIP-51 tags", () => { + const entries: RelayEntry[] = [ + { url: "wss://relay.example.com/", read: true, write: false }, + ]; + expect(buildRelayListTags(entries, NIP51_CONFIG)).toEqual([ + ["relay", "wss://relay.example.com/"], + ]); + }); + }); + + describe("roundtrip: parse -> build -> parse", () => { + it("should roundtrip NIP-65 events", () => { + const originalTags = [ + ["r", "wss://both.example.com/"], + ["r", "wss://read.example.com/", "read"], + ["r", "wss://write.example.com/", "write"], + ]; + const event = makeEvent(10002, originalTags); + const entries = parseRelayEntries(event, NIP65_CONFIG); + const rebuiltTags = buildRelayListTags(entries, NIP65_CONFIG); + expect(rebuiltTags).toEqual(originalTags); + }); + + it("should roundtrip NIP-51 events", () => { + const originalTags = [ + ["relay", "wss://relay1.example.com/"], + ["relay", "wss://relay2.example.com/"], + ]; + const event = makeEvent(10006, originalTags); + const entries = parseRelayEntries(event, NIP51_CONFIG); + const rebuiltTags = buildRelayListTags(entries, NIP51_CONFIG); + expect(rebuiltTags).toEqual(originalTags); + }); + }); +}); + +// --- sanitizeRelayInput --- + +describe("sanitizeRelayInput", () => { + it("should return null for empty string", () => { + expect(sanitizeRelayInput("")).toBeNull(); + }); + + it("should return null for whitespace-only string", () => { + expect(sanitizeRelayInput(" ")).toBeNull(); + }); + + it("should normalize a valid wss:// URL", () => { + expect(sanitizeRelayInput("wss://relay.example.com")).toBe( + "wss://relay.example.com/", + ); + }); + + it("should add wss:// scheme if missing", () => { + expect(sanitizeRelayInput("relay.example.com")).toBe( + "wss://relay.example.com/", + ); + }); + + it("should preserve ws:// scheme", () => { + const result = sanitizeRelayInput("ws://localhost:8080"); + expect(result).toBe("ws://localhost:8080/"); + }); + + it("should trim whitespace", () => { + expect(sanitizeRelayInput(" wss://relay.example.com ")).toBe( + "wss://relay.example.com/", + ); + }); + + it("should lowercase the URL", () => { + expect(sanitizeRelayInput("wss://RELAY.EXAMPLE.COM")).toBe( + "wss://relay.example.com/", + ); + }); + + it("should add trailing slash", () => { + expect(sanitizeRelayInput("wss://relay.example.com")).toBe( + "wss://relay.example.com/", + ); + }); + + it("should handle URLs with paths", () => { + const result = sanitizeRelayInput("wss://relay.example.com/custom"); + expect(result).toBe("wss://relay.example.com/custom"); + }); + + it("should return null for completely invalid input", () => { + expect(sanitizeRelayInput("not a url at all!!!")).toBeNull(); + }); + + it("should handle bare hostname with port", () => { + const result = sanitizeRelayInput("relay.example.com:8080"); + expect(result).toBe("wss://relay.example.com:8080/"); + }); + + it("should strip default wss port 443", () => { + const result = sanitizeRelayInput("relay.example.com:443"); + expect(result).toBe("wss://relay.example.com/"); + }); +}); + +// --- relayEntriesEqual --- + +describe("relayEntriesEqual", () => { + it("should return true for two empty arrays", () => { + expect(relayEntriesEqual([], [])).toBe(true); + }); + + it("should return true for identical entries", () => { + const a: RelayEntry[] = [ + { url: "wss://a.com/", read: true, write: true }, + { url: "wss://b.com/", read: true, write: false }, + ]; + const b: RelayEntry[] = [ + { url: "wss://a.com/", read: true, write: true }, + { url: "wss://b.com/", read: true, write: false }, + ]; + expect(relayEntriesEqual(a, b)).toBe(true); + }); + + it("should return false for different lengths", () => { + const a: RelayEntry[] = [{ url: "wss://a.com/", read: true, write: true }]; + const b: RelayEntry[] = []; + expect(relayEntriesEqual(a, b)).toBe(false); + }); + + it("should return false for different URLs", () => { + const a: RelayEntry[] = [{ url: "wss://a.com/", read: true, write: true }]; + const b: RelayEntry[] = [{ url: "wss://b.com/", read: true, write: true }]; + expect(relayEntriesEqual(a, b)).toBe(false); + }); + + it("should return false for different read flags", () => { + const a: RelayEntry[] = [{ url: "wss://a.com/", read: true, write: true }]; + const b: RelayEntry[] = [{ url: "wss://a.com/", read: false, write: true }]; + expect(relayEntriesEqual(a, b)).toBe(false); + }); + + it("should return false for different write flags", () => { + const a: RelayEntry[] = [{ url: "wss://a.com/", read: true, write: true }]; + const b: RelayEntry[] = [{ url: "wss://a.com/", read: true, write: false }]; + expect(relayEntriesEqual(a, b)).toBe(false); + }); + + it("should be order-sensitive", () => { + const a: RelayEntry[] = [ + { url: "wss://a.com/", read: true, write: true }, + { url: "wss://b.com/", read: true, write: true }, + ]; + const b: RelayEntry[] = [ + { url: "wss://b.com/", read: true, write: true }, + { url: "wss://a.com/", read: true, write: true }, + ]; + expect(relayEntriesEqual(a, b)).toBe(false); + }); +}); + +// --- getRelayMode --- + +describe("getRelayMode", () => { + it("should return readwrite for read+write", () => { + expect(getRelayMode({ url: "wss://a.com/", read: true, write: true })).toBe( + "readwrite", + ); + }); + + it("should return read for read-only", () => { + expect( + getRelayMode({ url: "wss://a.com/", read: true, write: false }), + ).toBe("read"); + }); + + it("should return write for write-only", () => { + expect( + getRelayMode({ url: "wss://a.com/", read: false, write: true }), + ).toBe("write"); + }); + + it("should return write for neither read nor write", () => { + // Edge case: both false defaults to "write" (last branch) + expect( + getRelayMode({ url: "wss://a.com/", read: false, write: false }), + ).toBe("write"); + }); +}); + +// --- modeToFlags --- + +describe("modeToFlags", () => { + it("should return both true for readwrite", () => { + expect(modeToFlags("readwrite")).toEqual({ read: true, write: true }); + }); + + it("should return read=true, write=false for read", () => { + expect(modeToFlags("read")).toEqual({ read: true, write: false }); + }); + + it("should return read=false, write=true for write", () => { + expect(modeToFlags("write")).toEqual({ read: false, write: true }); + }); + + it("should roundtrip with getRelayMode", () => { + for (const mode of ["readwrite", "read", "write"] as const) { + const flags = modeToFlags(mode); + const entry = { url: "wss://test.com/", ...flags }; + expect(getRelayMode(entry)).toBe(mode); + } + }); +}); diff --git a/src/lib/relay-list-utils.ts b/src/lib/relay-list-utils.ts new file mode 100644 index 00000000..ac8535c1 --- /dev/null +++ b/src/lib/relay-list-utils.ts @@ -0,0 +1,132 @@ +import type { NostrEvent } from "nostr-tools"; +import { normalizeRelayURL, isValidRelayURL } from "@/lib/relay-url"; + +// --- Types --- + +export interface RelayEntry { + url: string; + read: boolean; + write: boolean; +} + +export type RelayMode = "readwrite" | "read" | "write"; + +export interface RelayListKindConfig { + kind: number; + name: string; + description: string; + /** Tag name used in the event: "r" for NIP-65, "relay" for NIP-51 */ + tagName: "r" | "relay"; + /** Whether read/write markers are supported (only kind 10002) */ + hasMarkers: boolean; +} + +// --- Parsing --- + +/** Parse relay entries from a Nostr event based on the kind config */ +export function parseRelayEntries( + event: NostrEvent | undefined, + config: Pick, +): RelayEntry[] { + if (!event) return []; + + const entries: RelayEntry[] = []; + const seenUrls = new Set(); + + for (const tag of event.tags) { + if (tag[0] === config.tagName && tag[1]) { + try { + const url = normalizeRelayURL(tag[1]); + if (seenUrls.has(url)) continue; + seenUrls.add(url); + + if (config.hasMarkers) { + const marker = tag[2]; + entries.push({ + url, + read: !marker || marker === "read", + write: !marker || marker === "write", + }); + } else { + entries.push({ url, read: true, write: true }); + } + } catch { + // Skip invalid URLs + } + } + } + + return entries; +} + +// --- Tag Building --- + +/** Build event tags from relay entries for a given kind config */ +export function buildRelayListTags( + entries: RelayEntry[], + config: Pick, +): string[][] { + return entries.map((entry) => { + if (config.tagName === "r" && config.hasMarkers) { + if (entry.read && entry.write) return ["r", entry.url]; + if (entry.read) return ["r", entry.url, "read"]; + return ["r", entry.url, "write"]; + } + return [config.tagName, entry.url]; + }); +} + +// --- Input Sanitization --- + +/** Sanitize and normalize user input into a valid relay URL, or return null */ +export function sanitizeRelayInput(input: string): string | null { + const trimmed = input.trim(); + if (!trimmed) return null; + + // Add wss:// scheme if missing + let url = trimmed; + if (!url.startsWith("ws://") && !url.startsWith("wss://")) { + url = `wss://${url}`; + } + + try { + const normalized = normalizeRelayURL(url); + if (!isValidRelayURL(normalized)) return null; + return normalized; + } catch { + return null; + } +} + +// --- Comparison --- + +/** Check if two relay entry arrays are deeply equal */ +export function relayEntriesEqual(a: RelayEntry[], b: RelayEntry[]): boolean { + if (a.length !== b.length) return false; + return a.every( + (entry, i) => + entry.url === b[i].url && + entry.read === b[i].read && + entry.write === b[i].write, + ); +} + +// --- Mode Helpers --- + +/** Get the mode string from a relay entry */ +export function getRelayMode(entry: RelayEntry): RelayMode { + if (entry.read && entry.write) return "readwrite"; + if (entry.read) return "read"; + return "write"; +} + +/** Create read/write flags from a mode string */ +export function modeToFlags(mode: RelayMode): { + read: boolean; + write: boolean; +} { + return { + read: mode === "readwrite" || mode === "read", + write: mode === "readwrite" || mode === "write", + }; +}