mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-06-04 09:41:13 +02:00
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:
@@ -381,6 +381,11 @@ This allows `applyTheme()` to switch themes at runtime.
|
||||
- Provides diagnostic UI with retry capability and error details
|
||||
- Error boundaries auto-reset when event changes
|
||||
|
||||
**Shared Badge Components**:
|
||||
- **`KindBadge`** (`src/components/KindBadge.tsx`): Displays a Nostr event kind with icon, name, and kind number. Uses `getKindInfo()` from `src/constants/kinds.ts`. Variants: `"default"` (icon + name), `"compact"` (icon only), `"full"` (icon + name + kind number). Supports `clickable` prop to open kind detail window.
|
||||
- **`NIPBadge`** (`src/components/NIPBadge.tsx`): Displays a NIP reference with number and optional name. Clickable to open the NIP document in a new window. Shows deprecation state. Props: `nipNumber`, `showName`, `showNIPPrefix`.
|
||||
- Use these components whenever displaying kind numbers or NIP references in the UI — they provide consistent styling, tooltips, and navigation.
|
||||
|
||||
## Chat System
|
||||
|
||||
**Current Status**: Only NIP-29 (relay-based groups) is supported. Other protocols are planned for future releases.
|
||||
|
||||
204
PLAN-blocked-search-relays.md
Normal file
204
PLAN-blocked-search-relays.md
Normal file
@@ -0,0 +1,204 @@
|
||||
# Plan: Honor Blocked Relay List (10006) & Search Relay List (10007)
|
||||
|
||||
## Context
|
||||
|
||||
Grimoire now fetches and displays kinds 10006 (blocked relays) and 10007 (search relays) in Settings, but these lists have **no runtime effect**. This plan adds two behaviors:
|
||||
|
||||
1. **Blocked relays (kind 10006)**: Never connect to relays the user has explicitly blocked
|
||||
2. **Search relays (kind 10007)**: Use the user's search relays for NIP-50 search queries when no relays are explicitly provided
|
||||
|
||||
## Architecture Analysis
|
||||
|
||||
### Where relay connections originate
|
||||
|
||||
| Code Path | File | How relays are selected |
|
||||
|-----------|------|------------------------|
|
||||
| NIP-65 outbox selection | `relay-selection.ts` → `selectRelaysForFilter()` | Fetches kind 10002 for authors, applies health filter |
|
||||
| Event loader hints | `loaders.ts` → `eventLoader()` | Merges relay hints from pointers, seen-at, outbox, fallback |
|
||||
| REQ viewer (explicit relays) | `ReqViewer.tsx` | User-provided relay list from command args |
|
||||
| REQ viewer (auto) | `ReqViewer.tsx` → `useOutboxRelays()` | Calls `selectRelaysForFilter()` |
|
||||
| useReqTimelineEnhanced | `useReqTimelineEnhanced.ts` | Receives relay list from caller, subscribes per-relay |
|
||||
| Chat adapters | `nip-29-adapter.ts`, etc. | Group-specific relay (not filterable — group IS the relay) |
|
||||
| Publishing | `hub.ts` → `publishEvent()` | Author's outbox relays from `relayListCache` |
|
||||
| Address loader | `loaders.ts` → `addressLoader()` | Internal applesauce loader, uses pool directly |
|
||||
| Live timeline | `useLiveTimeline.ts` | Receives relay list from caller |
|
||||
|
||||
### Key insight: Two filtering points
|
||||
|
||||
1. **`relay-selection.ts`** — The central relay selection function. Most automated queries flow through here. This is where blocked relays should be filtered for query paths.
|
||||
2. **`relay-pool.ts`** — The singleton pool. Adding a filter here would catch ALL connections including explicit ones. This is the nuclear option.
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Part 1: Blocked Relay List Service
|
||||
|
||||
**New file: `src/services/blocked-relays.ts`**
|
||||
|
||||
A lightweight singleton that reads the user's kind 10006 from EventStore and exposes:
|
||||
|
||||
```ts
|
||||
class BlockedRelayService {
|
||||
// Reactive set updated when kind 10006 changes in EventStore
|
||||
blockedUrls$: BehaviorSubject<Set<string>>;
|
||||
|
||||
// Sync check - for hot path filtering
|
||||
isBlocked(url: string): boolean;
|
||||
|
||||
// Filter helper - remove blocked relays from a list
|
||||
filter(relays: string[]): string[];
|
||||
|
||||
// Start watching for the active account's kind 10006
|
||||
setAccount(pubkey: string | undefined): void;
|
||||
}
|
||||
|
||||
export const blockedRelays = new BlockedRelayService();
|
||||
```
|
||||
|
||||
**Why a singleton service?** Same pattern as `relayListCache` and `relayLiveness`. Needs to be accessible from non-React code (relay-selection.ts, loaders.ts, hub.ts) without prop drilling.
|
||||
|
||||
**Implementation details:**
|
||||
- Subscribe to `eventStore.replaceable(10006, pubkey, "")` when account changes
|
||||
- Parse `["relay", url]` tags, normalize URLs, store in a `Set<string>`
|
||||
- `filter()` returns `relays.filter(url => !this.isBlocked(url))`
|
||||
- Must handle the case where kind 10006 hasn't loaded yet (don't block anything — fail open)
|
||||
|
||||
### Part 2: Wire blocked relay filtering into relay selection
|
||||
|
||||
**File: `src/services/relay-selection.ts`**
|
||||
|
||||
In `selectRelaysForFilter()`, after the existing health filter (`liveness.filter()`), add:
|
||||
|
||||
```ts
|
||||
// Existing flow:
|
||||
const healthy = liveness.filter(sanitized);
|
||||
|
||||
// Add after:
|
||||
const allowed = blockedRelays.filter(healthy);
|
||||
```
|
||||
|
||||
This catches the main query path (REQ viewer auto-relay, outbox selection, etc.).
|
||||
|
||||
**File: `src/services/loaders.ts`**
|
||||
|
||||
In `eventLoader()`, filter the merged relay hints before subscribing:
|
||||
|
||||
```ts
|
||||
const relays = blockedRelays.filter(mergedRelayHints);
|
||||
```
|
||||
|
||||
**File: `src/services/hub.ts`**
|
||||
|
||||
In `publishEvent()`, filter outbox relays:
|
||||
|
||||
```ts
|
||||
let relays = await relayListCache.getOutboxRelays(event.pubkey);
|
||||
relays = blockedRelays.filter(relays ?? []);
|
||||
```
|
||||
|
||||
This prevents publishing to blocked relays.
|
||||
|
||||
### Part 3: Account lifecycle integration
|
||||
|
||||
**File: `src/hooks/useAccountSync.ts`**
|
||||
|
||||
When the active account changes, update the blocked relay service:
|
||||
|
||||
```ts
|
||||
import { blockedRelays } from "@/services/blocked-relays";
|
||||
|
||||
// In the account sync effect:
|
||||
useEffect(() => {
|
||||
blockedRelays.setAccount(activeAccount?.pubkey);
|
||||
}, [activeAccount?.pubkey]);
|
||||
```
|
||||
|
||||
### Part 4: Search Relay List (kind 10007)
|
||||
|
||||
**Simpler scope** — search relays only apply when a filter has `.search` set.
|
||||
|
||||
**File: `src/services/relay-selection.ts`**
|
||||
|
||||
Add a new exported function:
|
||||
|
||||
```ts
|
||||
export async function getSearchRelays(pubkey: string | undefined): Promise<string[] | undefined> {
|
||||
if (!pubkey) return undefined;
|
||||
|
||||
// Check EventStore for kind 10007
|
||||
const event = eventStore.getReplaceable(10007, pubkey, "");
|
||||
if (!event) return undefined;
|
||||
|
||||
const relays = getRelaysFromList(event, "all");
|
||||
if (relays.length === 0) return undefined;
|
||||
|
||||
return blockedRelays.filter(relays);
|
||||
}
|
||||
```
|
||||
|
||||
**File: `src/components/ReqViewer.tsx`**
|
||||
|
||||
In the relay selection logic (around line 795-812), when no explicit relays are provided and the filter has `.search`:
|
||||
|
||||
```ts
|
||||
// If search query and user has search relays configured, use those
|
||||
if (filter.search && !explicitRelays) {
|
||||
const searchRelays = await getSearchRelays(pubkey);
|
||||
if (searchRelays?.length) {
|
||||
return searchRelays;
|
||||
}
|
||||
// Fall through to normal relay selection if no search relays configured
|
||||
}
|
||||
```
|
||||
|
||||
This also applies to `useOutboxRelays` or wherever REQ relay selection happens — need to check if the filter contains a search term and short-circuit to search relays.
|
||||
|
||||
### Part 5: Testing
|
||||
|
||||
**New test file: `src/services/blocked-relays.test.ts`**
|
||||
|
||||
- `isBlocked()` returns false when no account is set
|
||||
- `isBlocked()` correctly identifies blocked URLs after event loaded
|
||||
- `filter()` removes blocked relays from a list
|
||||
- URL normalization: blocking `relay.example.com` also blocks `wss://relay.example.com/`
|
||||
- Handles empty/missing kind 10006 gracefully (fail open)
|
||||
|
||||
**New test file: `src/services/relay-selection.test.ts`** (additions)
|
||||
|
||||
- `selectRelaysForFilter()` excludes blocked relays
|
||||
- `getSearchRelays()` returns search relays when kind 10007 exists
|
||||
- `getSearchRelays()` returns undefined when no kind 10007
|
||||
|
||||
## Edge Cases & Considerations
|
||||
|
||||
### Blocked relays
|
||||
- **NIP-29 chat groups**: Do NOT filter group relay — the group IS the relay. If user blocks a relay that hosts a group, they simply won't join that group.
|
||||
- **Explicit relay args in REQ command**: Should we honor the block? Recommendation: YES, still filter. If the user explicitly types `req -r wss://blocked.relay`, we should warn them but respect the block. They can unblock in settings.
|
||||
- **Race condition on login**: Kind 10006 may not be loaded yet when first queries fire. Fail open (don't block anything until the event is loaded). This is the safe default.
|
||||
- **Publishing own kind 10006**: When saving the blocked relay list itself, we publish to the user's outbox relays — which won't include blocked relays (they wouldn't be in kind 10002 typically). No special handling needed.
|
||||
|
||||
### Search relays
|
||||
- **No search relays configured**: Fall through to normal NIP-65 relay selection. The user's regular relays may support NIP-50 search.
|
||||
- **Search relays + explicit relays**: If user provides `-r` flag in REQ command, respect explicit relays over search relays.
|
||||
- **Non-search queries**: Kind 10007 only applies when `filter.search` is set. Normal queries are unaffected.
|
||||
|
||||
## File Change Summary
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `src/services/blocked-relays.ts` | **NEW** — Singleton service for blocked relay filtering |
|
||||
| `src/services/blocked-relays.test.ts` | **NEW** — Tests |
|
||||
| `src/services/relay-selection.ts` | Add blocked relay filter + `getSearchRelays()` |
|
||||
| `src/services/loaders.ts` | Filter relay hints through blocked list |
|
||||
| `src/services/hub.ts` | Filter publish relays through blocked list |
|
||||
| `src/hooks/useAccountSync.ts` | Wire blocked relay service to account lifecycle |
|
||||
| `src/components/ReqViewer.tsx` | Use search relays for NIP-50 queries |
|
||||
|
||||
## Order of Implementation
|
||||
|
||||
1. `blocked-relays.ts` service + tests (foundation)
|
||||
2. Wire into `useAccountSync.ts` (lifecycle)
|
||||
3. Filter in `relay-selection.ts` (main query path)
|
||||
4. Filter in `loaders.ts` (event loading)
|
||||
5. Filter in `hub.ts` (publishing)
|
||||
6. `getSearchRelays()` + ReqViewer integration (search)
|
||||
7. Full integration test
|
||||
@@ -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>
|
||||
|
||||
556
src/components/settings/RelayListsSettings.tsx
Normal file
556
src/components/settings/RelayListsSettings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
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