Add relay list management UI and settings integration (#254)

* feat(settings): add relay lists management section

- Fetch additional relay list kinds (10006, 10007, 10050) on login
- Add "Relays" tab to Settings with accordion UI for each relay list kind
- Support NIP-65 relay list (kind 10002) with read/write markers
- Support blocked relays (10006), search relays (10007), DM relays (10050)
- Add/remove relays with URL sanitization and normalization
- Explicit save button publishes only modified lists as replaceable events

https://claude.ai/code/session_01JHirYU56sKDKYhRx6aCQ54

* docs: add plan for honoring blocked & search relay lists

Detailed implementation plan for:
- Kind 10006: filter blocked relays from all connection paths
- Kind 10007: use search relays for NIP-50 queries

https://claude.ai/code/session_01JHirYU56sKDKYhRx6aCQ54

* refactor(settings): extract relay list logic into tested lib, fix UX issues

Extract parseRelayEntries, buildRelayListTags, sanitizeRelayInput, and
comparison/mode helpers into src/lib/relay-list-utils.ts with 52 tests
covering roundtrips, normalization, edge cases, and mode conversions.

UX fixes:
- Replace RelayLink (navigates away on click) with static RelaySettingsRow
- Remove redundant inbox/outbox icons (mode dropdown is sufficient)
- Always-visible delete button instead of hover-only opacity
- Per-accordion dirty indicator (CircleDot icon) for modified lists
- Discard button to reset all changes
- Read-only account explanation text
- Human-friendly descriptions (no NIP references or kind numbers)
- Separator between relay list and add input
- Larger relay icons and text for readability

https://claude.ai/code/session_01JHirYU56sKDKYhRx6aCQ54

* feat(settings): use KindBadge and NIPBadge in relay list accordions

Replace plain text kind names with KindBadge (full variant showing icon,
name, and kind number) and add NIPBadge next to each list description.
This gives power users the protocol context they expect.

Also document KindBadge and NIPBadge as shared components in CLAUDE.md.

https://claude.ai/code/session_01JHirYU56sKDKYhRx6aCQ54

* feat(settings): add favorite relays list (kind 10012) to relay settings

Add kind 10012 (Favorite Relays / Relay Feeds) to the settings UI and
account sync fetching. Uses "relay" tags like other NIP-51 lists.

https://claude.ai/code/session_01JHirYU56sKDKYhRx6aCQ54

* fix(kinds): use semantic icons for blocked and search relay lists

- Kind 10006 (Blocked Relays): Radio → ShieldBan
- Kind 10007 (Search Relays): Radio → Search

These icons propagate to KindBadge, settings accordions, and event
renderers via getKindInfo(). Generic relay kinds (10002, 30002, etc.)
keep the Radio icon.

https://claude.ai/code/session_01JHirYU56sKDKYhRx6aCQ54

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Alejandro
2026-02-20 13:11:27 +01:00
committed by GitHub
parent 96d3d5bab6
commit d630a72409
8 changed files with 1396 additions and 3 deletions

View File

@@ -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() {
<FileEdit className="h-4 w-4" />
Post
</TabsTrigger>
<TabsTrigger value="relays" className="gap-2">
<Radio className="h-4 w-4" />
Relays
</TabsTrigger>
</TabsList>
</div>
@@ -142,6 +147,10 @@ export function SettingsViewer() {
</div>
</div>
</TabsContent>
<TabsContent value="relays" className="m-0 p-6 space-y-6">
<RelayListsSettings />
</TabsContent>
</div>
</Tabs>
</div>

View File

@@ -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 (
<Select value={mode} onValueChange={(v) => onChange(v as RelayMode)}>
<SelectTrigger className="w-32 h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="readwrite">Read & Write</SelectItem>
<SelectItem value="read">Read only</SelectItem>
<SelectItem value="write">Write only</SelectItem>
</SelectContent>
</Select>
);
}
/** 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 (
<div className="flex items-center gap-1.5 min-w-0 flex-1 overflow-hidden">
{relayInfo?.icon && (
<img
src={relayInfo.icon}
alt=""
className={cn("size-4 flex-shrink-0 rounded-sm", iconClassname)}
/>
)}
<span className="text-sm truncate">{displayUrl}</span>
</div>
);
}
function RelayEntryRow({
entry,
config,
onRemove,
onModeChange,
}: {
entry: RelayEntry;
config: RelayListKindUIConfig;
onRemove: () => void;
onModeChange?: (mode: RelayMode) => void;
}) {
const currentMode = getRelayMode(entry);
return (
<div className="flex items-center gap-2 py-1.5 px-2 rounded-md hover:bg-muted/50 group">
<RelaySettingsRow url={entry.url} />
{config.hasMarkers && onModeChange && (
<RelayModeSelect mode={currentMode} onChange={onModeChange} />
)}
<Button
variant="ghost"
size="icon"
className="size-7 flex-shrink-0 text-muted-foreground hover:text-destructive"
onClick={onRemove}
>
<X className="size-3.5" />
</Button>
</div>
);
}
function AddRelayInput({
config,
existingUrls,
onAdd,
}: {
config: RelayListKindUIConfig;
existingUrls: Set<string>;
onAdd: (entry: RelayEntry) => void;
}) {
const [input, setInput] = useState("");
const [mode, setMode] = useState<RelayMode>("readwrite");
const [error, setError] = useState<string | null>(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 (
<div className="space-y-1.5 pt-3 border-t border-border/30">
<div className="flex items-center gap-2">
<Input
value={input}
onChange={(e) => {
setInput(e.target.value);
setError(null);
}}
onKeyDown={handleKeyDown}
placeholder="relay.example.com"
className="h-8 text-xs flex-1"
/>
{config.hasMarkers && (
<RelayModeSelect mode={mode} onChange={setMode} />
)}
<Button
variant="outline"
size="sm"
className="h-8 gap-1"
onClick={handleAdd}
disabled={!input.trim()}
>
<Plus className="size-3.5" />
Add
</Button>
</div>
{error && <p className="text-xs text-destructive">{error}</p>}
</div>
);
}
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 (
<AccordionItem value={`kind-${config.kind}`}>
<AccordionTrigger className="hover:no-underline">
<div className="flex items-center gap-2">
<KindBadge
kind={config.kind}
variant="full"
className="text-sm"
iconClassname="text-muted-foreground"
/>
{entries.length > 0 && (
<span className="text-xs bg-muted text-muted-foreground rounded-full px-1.5 py-0.5 tabular-nums">
{entries.length}
</span>
)}
{isDirty && (
<CircleDot className="size-3 text-primary flex-shrink-0" />
)}
</div>
</AccordionTrigger>
<AccordionContent>
<div className="flex items-center gap-2 mb-3">
<p className="text-xs text-muted-foreground flex-1">
{config.description}
</p>
<NIPBadge
nipNumber={config.nip}
showName={false}
className="text-xs flex-shrink-0"
/>
</div>
{entries.length === 0 ? (
<p className="text-xs text-muted-foreground italic py-2">
No relays configured
</p>
) : (
<div className="space-y-0.5">
{entries.map((entry) => (
<RelayEntryRow
key={entry.url}
entry={entry}
config={config}
onRemove={() => handleRemove(entry.url)}
onModeChange={
config.hasMarkers
? (mode) => handleModeChange(entry.url, mode)
: undefined
}
/>
))}
</div>
)}
<AddRelayInput
config={config}
existingUrls={existingUrls}
onAdd={handleAdd}
/>
</AccordionContent>
</AccordionItem>
);
}
// --- 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<number, NostrEvent | undefined> = 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<Record<number, RelayEntry[]>>({});
// Track which event IDs we've initialized from (to re-sync when events update)
const [syncedEventIds, setSyncedEventIds] = useState<
Record<number, string | undefined>
>({});
// 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<number>();
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<number, RelayEntry[]> = {};
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 (
<div>
<h3 className="text-lg font-semibold mb-1">Relays</h3>
<p className="text-sm text-muted-foreground">
Log in to manage your relay lists.
</p>
</div>
);
}
return (
<div className="space-y-4">
<div>
<h3 className="text-lg font-semibold mb-1">Relays</h3>
<p className="text-sm text-muted-foreground">
Manage your Nostr relay lists
</p>
</div>
<Accordion type="multiple" className="w-full">
{RELAY_LIST_KINDS.map((config) => (
<RelayListAccordion
key={config.kind}
config={config}
entries={drafts[config.kind] ?? []}
isDirty={dirtyKinds.has(config.kind)}
onChange={(entries) => handleChange(config.kind, entries)}
/>
))}
</Accordion>
{!canSign && (
<p className="text-xs text-muted-foreground">
Read-only account. Log in with a signer to edit relay lists.
</p>
)}
<div className="flex items-center justify-end gap-2 pt-2">
{hasChanges && (
<Button
variant="ghost"
size="sm"
onClick={handleDiscard}
disabled={saving}
className="gap-1.5 text-muted-foreground"
>
<Undo2 className="size-3.5" />
Discard
</Button>
)}
<Button
onClick={handleSave}
disabled={!hasChanges || saving || !canSign}
className="gap-2"
>
{saving ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Save className="size-4" />
)}
{saving ? "Saving..." : "Save"}
</Button>
</div>
</div>
);
}

View File

@@ -49,8 +49,10 @@ import {
Presentation,
Radio,
Repeat,
Search,
Settings,
Shield,
ShieldBan,
ShoppingBag,
Smile,
Star,
@@ -758,14 +760,14 @@ export const EVENT_KINDS: Record<number | string, EventKind> = {
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,

View File

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

View File

@@ -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<RelayListKindConfig, "tagName" | "hasMarkers"> = {
tagName: "r",
hasMarkers: true,
};
const NIP51_CONFIG: Pick<RelayListKindConfig, "tagName" | "hasMarkers"> = {
tagName: "relay",
hasMarkers: false,
};
function makeEvent(
kind: number,
tags: string[][],
overrides?: Partial<NostrEvent>,
): 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);
}
});
});

132
src/lib/relay-list-utils.ts Normal file
View File

@@ -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<RelayListKindConfig, "tagName" | "hasMarkers">,
): RelayEntry[] {
if (!event) return [];
const entries: RelayEntry[] = [];
const seenUrls = new Set<string>();
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<RelayListKindConfig, "tagName" | "hasMarkers">,
): 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",
};
}