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
This commit is contained in:
Claude
2026-02-20 09:11:46 +00:00
parent de03923f24
commit 5624df8448
3 changed files with 716 additions and 170 deletions

View File

@@ -11,6 +11,8 @@ import {
Plus,
Loader2,
Save,
Undo2,
CircleDot,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import type { NostrEvent } from "nostr-tools";
@@ -29,38 +31,35 @@ import {
} from "@/components/ui/select";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { RelayLink } from "@/components/nostr/RelayLink";
import { useAccount } from "@/hooks/useAccount";
import { normalizeRelayURL, isValidRelayURL } from "@/lib/relay-url";
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";
// --- Types ---
// --- Config ---
interface RelayEntry {
url: string;
read: boolean;
write: boolean;
}
type RelayMode = "readwrite" | "read" | "write";
interface RelayListKindConfig {
kind: number;
name: string;
description: string;
interface RelayListKindUIConfig extends RelayListKindConfig {
icon: LucideIcon;
/** 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;
}
const RELAY_LIST_KINDS: RelayListKindConfig[] = [
const RELAY_LIST_KINDS: RelayListKindUIConfig[] = [
{
kind: 10002,
name: "Relay List",
description: "Read & write relays (NIP-65)",
description:
"Your primary read and write relays. Other clients use this to find your posts and deliver mentions to you.",
icon: Radio,
tagName: "r",
hasMarkers: true,
@@ -68,7 +67,8 @@ const RELAY_LIST_KINDS: RelayListKindConfig[] = [
{
kind: 10006,
name: "Blocked Relays",
description: "Relays to never connect to",
description:
"Relays your client should never connect to. Useful for avoiding spam or untrusted servers.",
icon: ShieldBan,
tagName: "relay",
hasMarkers: false,
@@ -76,7 +76,8 @@ const RELAY_LIST_KINDS: RelayListKindConfig[] = [
{
kind: 10007,
name: "Search Relays",
description: "Relays for search queries",
description:
"Relays used for search queries. These should support NIP-50 full-text search.",
icon: Search,
tagName: "relay",
hasMarkers: false,
@@ -84,86 +85,14 @@ const RELAY_LIST_KINDS: RelayListKindConfig[] = [
{
kind: 10050,
name: "DM Relays",
description: "Relays for receiving direct messages",
description:
"Relays where you receive direct messages. Senders look up this list to deliver encrypted DMs to you.",
icon: Mail,
tagName: "relay",
hasMarkers: false,
},
];
// --- Helpers ---
/** Parse relay entries from a Nostr event based on the kind config */
function parseRelayEntries(
event: NostrEvent | undefined,
config: RelayListKindConfig,
): 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;
}
/** Build event tags from relay entries */
function buildTags(
entries: RelayEntry[],
config: RelayListKindConfig,
): string[][] {
return entries.map((entry) => {
if (config.tagName === "r") {
if (entry.read && entry.write) return ["r", entry.url];
if (entry.read) return ["r", entry.url, "read"];
return ["r", entry.url, "write"];
}
return ["relay", entry.url];
});
}
/** Sanitize and normalize user input into a valid relay URL */
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;
}
}
// --- Components ---
function RelayModeSelect({
@@ -187,6 +116,31 @@ function RelayModeSelect({
);
}
/** 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,
@@ -194,37 +148,23 @@ function RelayEntryRow({
onModeChange,
}: {
entry: RelayEntry;
config: RelayListKindConfig;
config: RelayListKindUIConfig;
onRemove: () => void;
onModeChange?: (mode: RelayMode) => void;
}) {
const currentMode: RelayMode =
entry.read && entry.write ? "readwrite" : entry.read ? "read" : "write";
const currentMode = getRelayMode(entry);
return (
<div className="flex items-center gap-2 py-1 group">
<div className="flex-1 min-w-0">
<RelayLink
url={entry.url}
read={config.hasMarkers ? entry.read : false}
write={config.hasMarkers ? entry.write : false}
showInboxOutbox={config.hasMarkers}
className="py-0.5"
iconClassname="size-4"
urlClassname="underline decoration-dotted"
/>
</div>
<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-6 opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
className="size-7 flex-shrink-0 text-muted-foreground hover:text-destructive"
onClick={onRemove}
>
<X className="size-3.5" />
</Button>
@@ -237,7 +177,7 @@ function AddRelayInput({
existingUrls,
onAdd,
}: {
config: RelayListKindConfig;
config: RelayListKindUIConfig;
existingUrls: Set<string>;
onAdd: (entry: RelayEntry) => void;
}) {
@@ -261,8 +201,7 @@ function AddRelayInput({
onAdd({
url: normalized,
read: mode === "readwrite" || mode === "read",
write: mode === "readwrite" || mode === "write",
...modeToFlags(mode),
});
setInput("");
setError(null);
@@ -279,7 +218,7 @@ function AddRelayInput({
);
return (
<div className="space-y-1.5 pt-2">
<div className="space-y-1.5 pt-3 border-t border-border/30">
<div className="flex items-center gap-2">
<Input
value={input}
@@ -288,7 +227,7 @@ function AddRelayInput({
setError(null);
}}
onKeyDown={handleKeyDown}
placeholder="wss://relay.example.com"
placeholder="relay.example.com"
className="h-8 text-xs flex-1"
/>
{config.hasMarkers && (
@@ -313,10 +252,12 @@ function AddRelayInput({
function RelayListAccordion({
config,
entries,
isDirty,
onChange,
}: {
config: RelayListKindConfig;
config: RelayListKindUIConfig;
entries: RelayEntry[];
isDirty: boolean;
onChange: (entries: RelayEntry[]) => void;
}) {
const Icon = config.icon;
@@ -336,13 +277,7 @@ function RelayListAccordion({
(url: string, mode: RelayMode) => {
onChange(
entries.map((e) =>
e.url === url
? {
...e,
read: mode === "readwrite" || mode === "read",
write: mode === "readwrite" || mode === "write",
}
: e,
e.url === url ? { ...e, ...modeToFlags(mode) } : e,
),
);
},
@@ -359,17 +294,19 @@ function RelayListAccordion({
return (
<AccordionItem value={`kind-${config.kind}`}>
<AccordionTrigger className="hover:no-underline">
<div className="flex items-center gap-2">
<Icon className="size-4 text-muted-foreground" />
<span className="font-medium">{config.name}</span>
<span className="text-xs text-muted-foreground">
Kind {config.kind}
</span>
{entries.length > 0 && (
<span className="text-xs bg-muted text-muted-foreground rounded-full px-1.5 py-0.5">
{entries.length}
</span>
)}
<div className="flex items-center gap-2.5">
<Icon className="size-4 text-muted-foreground flex-shrink-0" />
<div className="flex items-center gap-2 min-w-0">
<span className="font-medium">{config.name}</span>
{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>
</div>
</AccordionTrigger>
<AccordionContent>
@@ -472,30 +409,33 @@ export function RelayListsSettings() {
}
}, [eventsMap]); // eslint-disable-line react-hooks/exhaustive-deps
// Check if any list has been modified
const hasChanges = useMemo(() => {
// 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 (original.length !== draft.length) return true;
for (let i = 0; i < original.length; i++) {
if (
original[i].url !== draft[i].url ||
original[i].read !== draft[i].read ||
original[i].write !== draft[i].write
)
return true;
if (!relayEntriesEqual(original, draft)) {
dirty.add(config.kind);
}
}
return false;
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;
@@ -511,21 +451,10 @@ export function RelayListsSettings() {
const factory = new EventFactory({ signer: account.signer });
for (const config of RELAY_LIST_KINDS) {
const original = parseRelayEntries(eventsMap[config.kind], config);
if (!dirtyKinds.has(config.kind)) continue;
const draft = drafts[config.kind] ?? [];
// Skip kinds that haven't changed
const isEqual =
original.length === draft.length &&
original.every(
(o, i) =>
o.url === draft[i].url &&
o.read === draft[i].read &&
o.write === draft[i].write,
);
if (isEqual) continue;
const tags = buildTags(draft, config);
const tags = buildRelayListTags(draft, config);
const built = await factory.build({
kind: config.kind,
content: "",
@@ -544,7 +473,7 @@ export function RelayListsSettings() {
} finally {
setSaving(false);
}
}, [canSign, saving, eventsMap, drafts]);
}, [canSign, saving, drafts, dirtyKinds]);
if (!pubkey) {
return (
@@ -572,12 +501,31 @@ export function RelayListsSettings() {
key={config.kind}
config={config}
entries={drafts[config.kind] ?? []}
isDirty={dirtyKinds.has(config.kind)}
onChange={(entries) => handleChange(config.kind, entries)}
/>
))}
</Accordion>
<div className="flex justify-end pt-2">
{!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}

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",
};
}