mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-06-04 09:41:13 +02:00
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:
@@ -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}
|
||||
|
||||
466
src/lib/relay-list-utils.test.ts
Normal file
466
src/lib/relay-list-utils.test.ts
Normal 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
132
src/lib/relay-list-utils.ts
Normal 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",
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user