diff --git a/src/components/RelayViewer.tsx b/src/components/RelayViewer.tsx index 6e50325..44f1ce6 100644 --- a/src/components/RelayViewer.tsx +++ b/src/components/RelayViewer.tsx @@ -3,7 +3,7 @@ import { useRelayInfo } from "../hooks/useRelayInfo"; import { useCopy } from "../hooks/useCopy"; import { Button } from "./ui/button"; import { UserName } from "./nostr/UserName"; -import { NIPBadge } from "./NIPBadge"; +import { RelaySupportedNips } from "./nostr/RelaySupportedNips"; export interface RelayViewerProps { url: string; @@ -68,19 +68,8 @@ export function RelayViewer({ url }: RelayViewerProps) { )} {/* Supported NIPs */} - {info?.supported_nips && info.supported_nips.length > 0 && ( -
-

Supported NIPs

-
- {info.supported_nips.map((num: number) => ( - - ))} -
-
+ {info?.supported_nips && ( + )} ); diff --git a/src/components/nostr/RelayKindsDisplay.tsx b/src/components/nostr/RelayKindsDisplay.tsx new file mode 100644 index 0000000..8beab46 --- /dev/null +++ b/src/components/nostr/RelayKindsDisplay.tsx @@ -0,0 +1,60 @@ +import { Badge } from "@/components/ui/badge"; +import { Label } from "@/components/ui/label"; +import { Filter, XCircle } from "lucide-react"; + +interface RelayKindsDisplayProps { + accepted: number[]; + rejected: number[]; +} + +/** + * Relay Kinds Display Component + * Shows accepted and rejected event kinds for a relay in a consistent format + * Used in both Relay Discovery and Monitor Announcement detail views + */ +export function RelayKindsDisplay({ + accepted, + rejected, +}: RelayKindsDisplayProps) { + return ( + <> + {/* Accepted Kinds */} + {accepted.length > 0 && ( +
+ +
+ {accepted.map((kind) => ( + + {kind} + + ))} +
+
+ )} + + {/* Rejected Kinds */} + {rejected.length > 0 && ( +
+ +
+ {rejected.map((kind) => ( + + !{kind} + + ))} +
+
+ )} + + ); +} diff --git a/src/components/nostr/RelaySupportedNips.tsx b/src/components/nostr/RelaySupportedNips.tsx new file mode 100644 index 0000000..c0cfe8d --- /dev/null +++ b/src/components/nostr/RelaySupportedNips.tsx @@ -0,0 +1,38 @@ +import { NIPBadge } from "@/components/NIPBadge"; + +interface RelaySupportedNipsProps { + nips: number[]; + title?: string; + showTitle?: boolean; +} + +/** + * Relay Supported NIPs Display Component + * Shows supported Nostr Implementation Possibilities (NIPs) for a relay + * Used in RelayViewer and Relay Discovery detail views + * Renders NIP badges with names for better readability + */ +export function RelaySupportedNips({ + nips, + title = "Supported NIPs", + showTitle = true, +}: RelaySupportedNipsProps) { + if (nips.length === 0) { + return null; + } + + return ( +
+ {showTitle &&

{title}

} +
+ {nips.map((nip) => ( + + ))} +
+
+ ); +} diff --git a/src/components/nostr/kinds/MonitorAnnouncementDetailRenderer.tsx b/src/components/nostr/kinds/MonitorAnnouncementDetailRenderer.tsx new file mode 100644 index 0000000..fb03944 --- /dev/null +++ b/src/components/nostr/kinds/MonitorAnnouncementDetailRenderer.tsx @@ -0,0 +1,138 @@ +import { NostrEvent } from "@/types/nostr"; +import { Badge } from "@/components/ui/badge"; +import { Label } from "@/components/ui/label"; +import { UserName } from "../UserName"; +import { + getMonitorFrequency, + getMonitorTimeouts, + getMonitorChecks, + getMonitorGeohash, + formatFrequency, + formatTimeout, + getCheckTypeName, +} from "@/lib/nip66-helpers"; +import { Activity, Clock, MapPin, Timer } from "lucide-react"; + +/** + * Monitor Announcement Detail Renderer - NIP-66 Relay Monitor Announcement (Detail View) + * Kind 10166 - Shows comprehensive monitor configuration including frequency, timeouts, and checks + */ +export function MonitorAnnouncementDetailRenderer({ + event, +}: { + event: NostrEvent; +}) { + const frequency = getMonitorFrequency(event); + const timeouts = getMonitorTimeouts(event); + const checks = getMonitorChecks(event); + const geohash = getMonitorGeohash(event); + + return ( +
+ {/* Header: Monitor Identity */} +
+

Relay Monitor

+
+ Operated by + +
+
+ + {/* Operational Parameters */} +
+ {/* Monitoring Frequency */} + {frequency && !isNaN(frequency) && ( +
+ +
+ + {formatFrequency(frequency)} + + + ({frequency} seconds) + +
+
+ )} + + {/* Check Types Performed */} + {checks.length > 0 && ( +
+ +
+ {checks.map((check) => ( + + {getCheckTypeName(check)} + + ))} +
+
+ )} + + {/* Timeout Configurations */} + {Object.keys(timeouts).length > 0 && ( +
+ +
+ {Object.entries(timeouts).map(([checkType, timeout]) => ( +
+ + {getCheckTypeName(checkType)} + + + {formatTimeout(timeout)} + +
+ ))} +
+
+ )} + + {/* Geographic Location */} + {geohash && ( +
+ + + + {geohash} + +
+ )} +
+ + {/* Monitor Description */} + {event.content && event.content.trim() !== "" && ( +
+ +

{event.content}

+
+ )} + + {/* Empty State */} + {!frequency && + checks.length === 0 && + Object.keys(timeouts).length === 0 && + !geohash && + (!event.content || event.content.trim() === "") && ( +
+ No monitoring configuration specified +
+ )} +
+ ); +} diff --git a/src/components/nostr/kinds/MonitorAnnouncementRenderer.tsx b/src/components/nostr/kinds/MonitorAnnouncementRenderer.tsx new file mode 100644 index 0000000..bc78a49 --- /dev/null +++ b/src/components/nostr/kinds/MonitorAnnouncementRenderer.tsx @@ -0,0 +1,61 @@ +import { + BaseEventProps, + BaseEventContainer, + ClickableEventTitle, +} from "./BaseEventRenderer"; +import { Label } from "@/components/ui/label"; +import { + getMonitorFrequency, + getMonitorChecks, + formatFrequency, + getCheckTypeName, +} from "@/lib/nip66-helpers"; +import { Clock } from "lucide-react"; + +/** + * Monitor Announcement Renderer - NIP-66 Relay Monitor Announcement (Feed View) + * Kind 10166 - Displays monitor announcement with frequency and check types + */ +export function MonitorAnnouncementRenderer({ event }: BaseEventProps) { + const frequency = getMonitorFrequency(event); + const checks = getMonitorChecks(event); + + return ( + +
+ {/* Clickable Title */} + + Relay Monitor + + + {/* Monitoring Frequency */} + {frequency && !isNaN(frequency) && ( +
+ + + Checks every{" "} + {formatFrequency(frequency)} + +
+ )} + + {/* Check Types */} + {checks.length > 0 && ( +
+ {checks.map((check) => ( + + ))} +
+ )} + + {!frequency && checks.length === 0 && ( +
+ No monitoring configuration specified +
+ )} +
+
+ ); +} diff --git a/src/components/nostr/kinds/RelayDiscoveryDetailRenderer.tsx b/src/components/nostr/kinds/RelayDiscoveryDetailRenderer.tsx new file mode 100644 index 0000000..6e1eadd --- /dev/null +++ b/src/components/nostr/kinds/RelayDiscoveryDetailRenderer.tsx @@ -0,0 +1,275 @@ +import { NostrEvent } from "@/types/nostr"; +import { RelayLink } from "../RelayLink"; +import { Badge } from "@/components/ui/badge"; +import { Label } from "@/components/ui/label"; +import { JsonViewer } from "@/components/JsonViewer"; +import { UserName } from "../UserName"; +import { RelayKindsDisplay } from "../RelayKindsDisplay"; +import { RelaySupportedNips } from "../RelaySupportedNips"; +import { + getRelayUrl, + getRttMetrics, + getNetworkType, + getRelayType, + getSupportedNips, + getRelayRequirements, + getRelayTopics, + getRelayKinds, + getRelayGeohash, + parseNip11Document, + calculateRelayHealth, +} from "@/lib/nip66-helpers"; +import { + Activity, + CircleDot, + Globe, + Lock, + CreditCard, + Hammer, + MapPin, + Shield, + Tag, + Clock, + CheckCircle, + XCircle, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import { formatTimestamp } from "@/hooks/useLocale"; +import { useState } from "react"; + +/** + * Relay Discovery Detail Renderer - NIP-66 Relay Discovery (Detail View) + * Kind 30166 - Shows comprehensive relay information with all metrics and capabilities + */ +export function RelayDiscoveryDetailRenderer({ event }: { event: NostrEvent }) { + const [showNip11, setShowNip11] = useState(false); + + const relayUrl = getRelayUrl(event); + const rtt = getRttMetrics(event); + const networkType = getNetworkType(event); + const relayType = getRelayType(event); + const nips = getSupportedNips(event); + const requirements = getRelayRequirements(event); + const topics = getRelayTopics(event); + const kinds = getRelayKinds(event); + const geohash = getRelayGeohash(event); + const nip11 = parseNip11Document(event); + const health = calculateRelayHealth(event); + + if (!relayUrl) { + return ( +
+ Invalid relay discovery event (missing relay URL) +
+ ); + } + + // Calculate health color based on score + const healthColor = + health >= 80 + ? "text-green-600" + : health >= 50 + ? "text-yellow-600" + : "text-red-600"; + + return ( +
+ {/* Header: Relay URL and Health */} +
+
+ +
+
+ + + {health}% + +
+
+ + {/* Performance Metrics Section */} + {(rtt.open !== undefined || + rtt.read !== undefined || + rtt.write !== undefined) && ( +
+ +
+ {rtt.open !== undefined && !isNaN(rtt.open) && ( +
+ + Connection + + {rtt.open}ms +
+ )} + {rtt.read !== undefined && !isNaN(rtt.read) && ( +
+ Read + {rtt.read}ms +
+ )} + {rtt.write !== undefined && !isNaN(rtt.write) && ( +
+ Write + {rtt.write}ms +
+ )} +
+
+ )} + + {/* Relay Characteristics */} + {(networkType || relayType || geohash) && ( +
+ +
+ {networkType && ( + + {networkType === "tor" && } + {(networkType === "i2p" || networkType === "clearnet") && ( + + )} + {networkType} + + )} + {relayType && ( + + {relayType} + + )} + {geohash && ( + + + {geohash} + + )} +
+
+ )} + + {/* Requirements Section */} + {Object.keys(requirements).length > 0 && ( +
+ +
+ {requirements.auth !== undefined && ( +
+ {requirements.auth ? ( + + ) : ( + + )} + + Authentication{" "} + {requirements.auth ? "required" : "not required"} + +
+ )} + {requirements.payment !== undefined && ( +
+ {requirements.payment ? ( + + ) : ( + + )} + + Payment {requirements.payment ? "required" : "not required"} + +
+ )} + {requirements.writes !== undefined && ( +
+ {requirements.writes ? ( + + ) : ( + + )} + + {requirements.writes + ? "Write access enabled" + : "Read-only relay"} + +
+ )} + {requirements.pow !== undefined && ( +
+ {requirements.pow ? ( + + ) : ( + + )} + + Proof of work {requirements.pow ? "required" : "not required"} + +
+ )} +
+
+ )} + + {/* Supported NIPs */} + {nips.length > 0 && ( + + )} + + {/* Relay Kinds */} + + + {/* Topics */} + {topics.length > 0 && ( +
+ +
+ {topics.map((topic, index) => ( + + {topic} + + ))} +
+
+ )} + + {/* NIP-11 Document */} + {nip11 && ( +
+ +
+ )} + + {/* Monitor Attribution */} +
+
+ + + Monitored {formatTimestamp(event.created_at)} by{" "} + + +
+
+
+ ); +} diff --git a/src/components/nostr/kinds/RelayDiscoveryRenderer.tsx b/src/components/nostr/kinds/RelayDiscoveryRenderer.tsx new file mode 100644 index 0000000..154fe9b --- /dev/null +++ b/src/components/nostr/kinds/RelayDiscoveryRenderer.tsx @@ -0,0 +1,147 @@ +import { + BaseEventProps, + BaseEventContainer, + ClickableEventTitle, +} from "./BaseEventRenderer"; +import { Badge } from "@/components/ui/badge"; +import { + getRelayUrl, + getRttMetrics, + getNetworkType, + getRelayType, + getRelayRequirements, + calculateRelayHealth, +} from "@/lib/nip66-helpers"; +import { + Activity, + CircleDot, + Globe, + Lock, + CreditCard, + Hammer, +} from "lucide-react"; +import { cn } from "@/lib/utils"; + +/** + * Relay Discovery Renderer - NIP-66 Relay Discovery (Feed View) + * Kind 30166 - Displays relay information with health metrics, network type, and capabilities + */ +export function RelayDiscoveryRenderer({ event }: BaseEventProps) { + const relayUrl = getRelayUrl(event); + const rtt = getRttMetrics(event); + const networkType = getNetworkType(event); + const relayType = getRelayType(event); + const requirements = getRelayRequirements(event); + const health = calculateRelayHealth(event); + + if (!relayUrl) { + return ( + +
+ Invalid relay discovery event (missing relay URL) +
+
+ ); + } + + // Calculate health color based on score + const healthColor = + health >= 80 + ? "text-green-600" + : health >= 50 + ? "text-yellow-600" + : "text-red-600"; + + // Format RTT for display (average of available metrics) + const avgRtt = [rtt.open, rtt.read, rtt.write] + .filter((v): v is number => v !== undefined && !isNaN(v)) + .reduce((sum, v, _, arr) => sum + v / arr.length, 0); + + return ( + +
+ {/* Clickable Title and Health Score */} +
+ + {relayUrl} + +
+ + {health}% +
+
+ + {/* Badges: Network Type, Relay Type, RTT, Requirements */} +
+ {/* Network Type Badge */} + {networkType && ( + + {networkType === "tor" && } + {networkType === "i2p" && } + {networkType === "clearnet" && } + {networkType} + + )} + + {/* Relay Type Badge */} + {relayType && ( + + {relayType} + + )} + + {/* RTT Badge */} + {avgRtt > 0 && ( + + + {Math.round(avgRtt)}ms + + )} + + {/* Requirements Badges */} + {requirements.auth && ( + + + Auth + + )} + {requirements.payment && ( + + + Paid + + )} + {requirements.writes === false && ( + + Read-only + + )} + {requirements.pow && ( + + + PoW + + )} +
+
+
+ ); +} diff --git a/src/components/nostr/kinds/index.tsx b/src/components/nostr/kinds/index.tsx index 1ec856d..fc5058c 100644 --- a/src/components/nostr/kinds/index.tsx +++ b/src/components/nostr/kinds/index.tsx @@ -148,6 +148,10 @@ import { BadgeAwardRenderer } from "./BadgeAwardRenderer"; import { BadgeAwardDetailRenderer } from "./BadgeAwardDetailRenderer"; import { ProfileBadgesRenderer } from "./ProfileBadgesRenderer"; import { ProfileBadgesDetailRenderer } from "./ProfileBadgesDetailRenderer"; +import { MonitorAnnouncementRenderer } from "./MonitorAnnouncementRenderer"; +import { MonitorAnnouncementDetailRenderer } from "./MonitorAnnouncementDetailRenderer"; +import { RelayDiscoveryRenderer } from "./RelayDiscoveryRenderer"; +import { RelayDiscoveryDetailRenderer } from "./RelayDiscoveryDetailRenderer"; import { GoalRenderer } from "./GoalRenderer"; import { GoalDetailRenderer } from "./GoalDetailRenderer"; @@ -201,6 +205,7 @@ const kindRenderers: Record> = { 10063: BlossomServerListRenderer, // Blossom User Server List (BUD-03) 10101: WikiAuthorsRenderer, // Good Wiki Authors (NIP-51) 10102: WikiRelaysRenderer, // Good Wiki Relays (NIP-51) + 10166: MonitorAnnouncementRenderer, // Relay Monitor Announcement (NIP-66) 10317: Kind10317Renderer, // User Grasp List (NIP-34) 13534: RelayMembersRenderer, // Relay Members (NIP-43) 30000: FollowSetRenderer, // Follow Sets (NIP-51) @@ -216,6 +221,7 @@ const kindRenderers: Record> = { 30023: Kind30023Renderer, // Long-form Article 30030: EmojiSetRenderer, // Emoji Sets (NIP-30) 30063: ZapstoreReleaseRenderer, // Zapstore App Release + 30166: RelayDiscoveryRenderer, // Relay Discovery (NIP-66) 30267: ZapstoreAppSetRenderer, // Zapstore App Collection 30311: LiveActivityRenderer, // Live Streaming Event (NIP-53) 34235: Kind21Renderer, // Horizontal Video (NIP-71 legacy) @@ -300,6 +306,7 @@ const detailRenderers: Record< 10063: BlossomServerListDetailRenderer, // Blossom User Server List Detail (BUD-03) 10101: WikiAuthorsDetailRenderer, // Good Wiki Authors Detail (NIP-51) 10102: WikiRelaysDetailRenderer, // Good Wiki Relays Detail (NIP-51) + 10166: MonitorAnnouncementDetailRenderer, // Relay Monitor Announcement Detail (NIP-66) 10317: Kind10317DetailRenderer, // User Grasp List Detail (NIP-34) 13534: RelayMembersDetailRenderer, // Relay Members Detail (NIP-43) 30000: FollowSetDetailRenderer, // Follow Sets Detail (NIP-51) @@ -314,6 +321,7 @@ const detailRenderers: Record< 30023: Kind30023DetailRenderer, // Long-form Article Detail 30030: EmojiSetDetailRenderer, // Emoji Sets Detail (NIP-30) 30063: ZapstoreReleaseDetailRenderer, // Zapstore App Release Detail + 30166: RelayDiscoveryDetailRenderer, // Relay Discovery Detail (NIP-66) 30267: ZapstoreAppSetDetailRenderer, // Zapstore App Collection Detail 30311: LiveActivityDetailRenderer, // Live Streaming Event Detail (NIP-53) 30617: RepositoryDetailRenderer, // Repository Detail (NIP-34) diff --git a/src/lib/nip66-helpers.test.ts b/src/lib/nip66-helpers.test.ts new file mode 100644 index 0000000..88beecd --- /dev/null +++ b/src/lib/nip66-helpers.test.ts @@ -0,0 +1,667 @@ +import { describe, it, expect } from "vitest"; +import { + getRelayUrl, + getRttMetrics, + getNetworkType, + getRelayType, + getSupportedNips, + getRelayRequirements, + getRelayTopics, + getRelayKinds, + getRelayGeohash, + parseNip11Document, + calculateRelayHealth, + getMonitorFrequency, + getMonitorTimeouts, + getMonitorChecks, + getMonitorGeohash, + formatFrequency, + formatTimeout, + getCheckTypeName, +} from "./nip66-helpers"; +import { NostrEvent } from "@/types/nostr"; + +// Helper to create a minimal kind 30166 event (Relay Discovery) +function createRelayDiscoveryEvent( + overrides?: Partial, +): NostrEvent { + return { + id: "test-id", + pubkey: "test-pubkey", + created_at: Math.floor(Date.now() / 1000), // Current time by default + kind: 30166, + tags: [], + content: "", + sig: "test-sig", + ...overrides, + }; +} + +// Helper to create a minimal kind 10166 event (Monitor Announcement) +function createMonitorEvent(overrides?: Partial): NostrEvent { + return { + id: "test-id", + pubkey: "test-pubkey", + created_at: Math.floor(Date.now() / 1000), + kind: 10166, + tags: [], + content: "", + sig: "test-sig", + ...overrides, + }; +} + +describe("Kind 30166 (Relay Discovery) Helpers", () => { + describe("getRelayUrl", () => { + it("should extract relay URL from d tag", () => { + const event = createRelayDiscoveryEvent({ + tags: [["d", "wss://relay.example.com"]], + }); + expect(getRelayUrl(event)).toBe("wss://relay.example.com"); + }); + + it("should handle normalized URL without wss://", () => { + const event = createRelayDiscoveryEvent({ + tags: [["d", "relay.example.com"]], + }); + expect(getRelayUrl(event)).toBe("relay.example.com"); + }); + + it("should return undefined if no d tag", () => { + const event = createRelayDiscoveryEvent({ + tags: [], + }); + expect(getRelayUrl(event)).toBeUndefined(); + }); + }); + + describe("getRttMetrics", () => { + it("should parse all RTT values", () => { + const event = createRelayDiscoveryEvent({ + tags: [ + ["d", "relay.example.com"], + ["rtt-open", "150"], + ["rtt-read", "200"], + ["rtt-write", "250"], + ], + }); + const rtt = getRttMetrics(event); + expect(rtt.open).toBe(150); + expect(rtt.read).toBe(200); + expect(rtt.write).toBe(250); + }); + + it("should handle missing RTT values", () => { + const event = createRelayDiscoveryEvent({ + tags: [ + ["d", "relay.example.com"], + ["rtt-open", "100"], + ], + }); + const rtt = getRttMetrics(event); + expect(rtt.open).toBe(100); + expect(rtt.read).toBeUndefined(); + expect(rtt.write).toBeUndefined(); + }); + + it("should return all undefined if no RTT tags", () => { + const event = createRelayDiscoveryEvent({ + tags: [["d", "relay.example.com"]], + }); + const rtt = getRttMetrics(event); + expect(rtt.open).toBeUndefined(); + expect(rtt.read).toBeUndefined(); + expect(rtt.write).toBeUndefined(); + }); + + it("should handle non-numeric RTT values", () => { + const event = createRelayDiscoveryEvent({ + tags: [ + ["d", "relay.example.com"], + ["rtt-open", "invalid"], + ], + }); + const rtt = getRttMetrics(event); + expect(rtt.open).toBeNaN(); + }); + }); + + describe("getNetworkType", () => { + it("should extract clearnet network type", () => { + const event = createRelayDiscoveryEvent({ + tags: [ + ["d", "relay.example.com"], + ["n", "clearnet"], + ], + }); + expect(getNetworkType(event)).toBe("clearnet"); + }); + + it("should extract tor network type", () => { + const event = createRelayDiscoveryEvent({ + tags: [ + ["d", "relay.onion"], + ["n", "tor"], + ], + }); + expect(getNetworkType(event)).toBe("tor"); + }); + + it("should return undefined if no network type tag", () => { + const event = createRelayDiscoveryEvent({ + tags: [["d", "relay.example.com"]], + }); + expect(getNetworkType(event)).toBeUndefined(); + }); + }); + + describe("getRelayType", () => { + it("should extract relay type", () => { + const event = createRelayDiscoveryEvent({ + tags: [ + ["d", "relay.example.com"], + ["T", "Public"], + ], + }); + expect(getRelayType(event)).toBe("Public"); + }); + + it("should return undefined if no type tag", () => { + const event = createRelayDiscoveryEvent({ + tags: [["d", "relay.example.com"]], + }); + expect(getRelayType(event)).toBeUndefined(); + }); + }); + + describe("getSupportedNips", () => { + it("should extract and sort NIP numbers", () => { + const event = createRelayDiscoveryEvent({ + tags: [ + ["d", "relay.example.com"], + ["N", "11"], + ["N", "1"], + ["N", "65"], + ["N", "42"], + ], + }); + expect(getSupportedNips(event)).toEqual([1, 11, 42, 65]); + }); + + it("should deduplicate NIPs", () => { + const event = createRelayDiscoveryEvent({ + tags: [ + ["d", "relay.example.com"], + ["N", "1"], + ["N", "11"], + ["N", "1"], + ], + }); + expect(getSupportedNips(event)).toEqual([1, 11]); + }); + + it("should filter out invalid NIP numbers", () => { + const event = createRelayDiscoveryEvent({ + tags: [ + ["d", "relay.example.com"], + ["N", "1"], + ["N", "invalid"], + ["N", "11"], + ], + }); + expect(getSupportedNips(event)).toEqual([1, 11]); + }); + + it("should return empty array if no NIP tags", () => { + const event = createRelayDiscoveryEvent({ + tags: [["d", "relay.example.com"]], + }); + expect(getSupportedNips(event)).toEqual([]); + }); + }); + + describe("getRelayRequirements", () => { + it("should parse positive requirements", () => { + const event = createRelayDiscoveryEvent({ + tags: [ + ["d", "relay.example.com"], + ["R", "auth"], + ["R", "payment"], + ], + }); + const reqs = getRelayRequirements(event); + expect(reqs.auth).toBe(true); + expect(reqs.payment).toBe(true); + }); + + it("should parse negative requirements with ! prefix", () => { + const event = createRelayDiscoveryEvent({ + tags: [ + ["d", "relay.example.com"], + ["R", "!auth"], + ["R", "!payment"], + ], + }); + const reqs = getRelayRequirements(event); + expect(reqs.auth).toBe(false); + expect(reqs.payment).toBe(false); + }); + + it("should handle mixed positive and negative requirements", () => { + const event = createRelayDiscoveryEvent({ + tags: [ + ["d", "relay.example.com"], + ["R", "auth"], + ["R", "!payment"], + ["R", "writes"], + ["R", "!pow"], + ], + }); + const reqs = getRelayRequirements(event); + expect(reqs.auth).toBe(true); + expect(reqs.payment).toBe(false); + expect(reqs.writes).toBe(true); + expect(reqs.pow).toBe(false); + }); + + it("should return empty object if no requirement tags", () => { + const event = createRelayDiscoveryEvent({ + tags: [["d", "relay.example.com"]], + }); + expect(getRelayRequirements(event)).toEqual({}); + }); + + it("should ignore unknown requirements", () => { + const event = createRelayDiscoveryEvent({ + tags: [ + ["d", "relay.example.com"], + ["R", "auth"], + ["R", "unknown"], + ], + }); + const reqs = getRelayRequirements(event); + expect(reqs.auth).toBe(true); + expect(Object.keys(reqs)).toHaveLength(1); + }); + }); + + describe("getRelayTopics", () => { + it("should extract all topics", () => { + const event = createRelayDiscoveryEvent({ + tags: [ + ["d", "relay.example.com"], + ["t", "bitcoin"], + ["t", "nostr"], + ["t", "general"], + ], + }); + expect(getRelayTopics(event)).toEqual(["bitcoin", "nostr", "general"]); + }); + + it("should return empty array if no topics", () => { + const event = createRelayDiscoveryEvent({ + tags: [["d", "relay.example.com"]], + }); + expect(getRelayTopics(event)).toEqual([]); + }); + }); + + describe("getRelayKinds", () => { + it("should separate accepted and rejected kinds", () => { + const event = createRelayDiscoveryEvent({ + tags: [ + ["d", "relay.example.com"], + ["k", "1"], + ["k", "3"], + ["k", "!7"], + ["k", "!1984"], + ], + }); + const kinds = getRelayKinds(event); + expect(kinds.accepted).toEqual([1, 3]); + expect(kinds.rejected).toEqual([7, 1984]); + }); + + it("should deduplicate kinds", () => { + const event = createRelayDiscoveryEvent({ + tags: [ + ["d", "relay.example.com"], + ["k", "1"], + ["k", "1"], + ["k", "!7"], + ["k", "!7"], + ], + }); + const kinds = getRelayKinds(event); + expect(kinds.accepted).toEqual([1]); + expect(kinds.rejected).toEqual([7]); + }); + + it("should return empty arrays if no kind tags", () => { + const event = createRelayDiscoveryEvent({ + tags: [["d", "relay.example.com"]], + }); + const kinds = getRelayKinds(event); + expect(kinds.accepted).toEqual([]); + expect(kinds.rejected).toEqual([]); + }); + + it("should filter out invalid kind numbers", () => { + const event = createRelayDiscoveryEvent({ + tags: [ + ["d", "relay.example.com"], + ["k", "1"], + ["k", "invalid"], + ["k", "!7"], + ], + }); + const kinds = getRelayKinds(event); + expect(kinds.accepted).toEqual([1]); + expect(kinds.rejected).toEqual([7]); + }); + }); + + describe("getRelayGeohash", () => { + it("should extract geohash", () => { + const event = createRelayDiscoveryEvent({ + tags: [ + ["d", "relay.example.com"], + ["g", "u4pruydqqvj"], + ], + }); + expect(getRelayGeohash(event)).toBe("u4pruydqqvj"); + }); + + it("should return undefined if no geohash tag", () => { + const event = createRelayDiscoveryEvent({ + tags: [["d", "relay.example.com"]], + }); + expect(getRelayGeohash(event)).toBeUndefined(); + }); + }); + + describe("parseNip11Document", () => { + it("should parse valid JSON content", () => { + const nip11 = { + name: "Test Relay", + description: "A test relay", + supported_nips: [1, 11, 42], + }; + const event = createRelayDiscoveryEvent({ + tags: [["d", "relay.example.com"]], + content: JSON.stringify(nip11), + }); + expect(parseNip11Document(event)).toEqual(nip11); + }); + + it("should return undefined for empty content", () => { + const event = createRelayDiscoveryEvent({ + tags: [["d", "relay.example.com"]], + content: "", + }); + expect(parseNip11Document(event)).toBeUndefined(); + }); + + it("should return undefined for invalid JSON", () => { + const event = createRelayDiscoveryEvent({ + tags: [["d", "relay.example.com"]], + content: "not json", + }); + expect(parseNip11Document(event)).toBeUndefined(); + }); + }); + + describe("calculateRelayHealth", () => { + it("should return 100 for recent event with low RTT", () => { + const event = createRelayDiscoveryEvent({ + tags: [ + ["d", "relay.example.com"], + ["rtt-open", "50"], + ["rtt-read", "60"], + ["rtt-write", "70"], + ], + created_at: Math.floor(Date.now() / 1000), // Now + }); + expect(calculateRelayHealth(event)).toBe(100); + }); + + it("should penalize high RTT", () => { + const event = createRelayDiscoveryEvent({ + tags: [ + ["d", "relay.example.com"], + ["rtt-open", "1500"], + ["rtt-read", "1500"], + ["rtt-write", "1500"], + ], + created_at: Math.floor(Date.now() / 1000), // Now + }); + const health = calculateRelayHealth(event); + expect(health).toBeLessThan(100); + expect(health).toBeGreaterThanOrEqual(0); + }); + + it("should penalize old events", () => { + const event = createRelayDiscoveryEvent({ + tags: [ + ["d", "relay.example.com"], + ["rtt-open", "50"], + ["rtt-read", "60"], + ["rtt-write", "70"], + ], + created_at: Math.floor(Date.now() / 1000) - 10 * 24 * 60 * 60, // 10 days ago + }); + const health = calculateRelayHealth(event); + expect(health).toBeLessThan(100); + expect(health).toBeGreaterThanOrEqual(0); + }); + + it("should handle events with no RTT data", () => { + const event = createRelayDiscoveryEvent({ + tags: [["d", "relay.example.com"]], + created_at: Math.floor(Date.now() / 1000), // Now + }); + const health = calculateRelayHealth(event); + expect(health).toBe(100); // No RTT penalty + }); + + it("should never return negative health", () => { + const event = createRelayDiscoveryEvent({ + tags: [ + ["d", "relay.example.com"], + ["rtt-open", "5000"], + ["rtt-read", "5000"], + ["rtt-write", "5000"], + ], + created_at: Math.floor(Date.now() / 1000) - 30 * 24 * 60 * 60, // 30 days ago + }); + const health = calculateRelayHealth(event); + expect(health).toBeGreaterThanOrEqual(0); + }); + + it("should never return health above 100", () => { + const event = createRelayDiscoveryEvent({ + tags: [ + ["d", "relay.example.com"], + ["rtt-open", "10"], + ["rtt-read", "10"], + ["rtt-write", "10"], + ], + created_at: Math.floor(Date.now() / 1000), // Now + }); + const health = calculateRelayHealth(event); + expect(health).toBeLessThanOrEqual(100); + }); + }); +}); + +describe("Kind 10166 (Monitor Announcement) Helpers", () => { + describe("getMonitorFrequency", () => { + it("should extract frequency in seconds", () => { + const event = createMonitorEvent({ + tags: [["frequency", "300"]], + }); + expect(getMonitorFrequency(event)).toBe(300); + }); + + it("should return undefined if no frequency tag", () => { + const event = createMonitorEvent({ + tags: [], + }); + expect(getMonitorFrequency(event)).toBeUndefined(); + }); + + it("should handle invalid frequency value", () => { + const event = createMonitorEvent({ + tags: [["frequency", "invalid"]], + }); + expect(getMonitorFrequency(event)).toBeNaN(); + }); + }); + + describe("getMonitorTimeouts", () => { + it("should extract timeout configurations", () => { + const event = createMonitorEvent({ + tags: [ + ["timeout", "open", "1000"], + ["timeout", "read", "2000"], + ["timeout", "write", "3000"], + ], + }); + const timeouts = getMonitorTimeouts(event); + expect(timeouts.open).toBe(1000); + expect(timeouts.read).toBe(2000); + expect(timeouts.write).toBe(3000); + }); + + it("should return empty object if no timeout tags", () => { + const event = createMonitorEvent({ + tags: [], + }); + expect(getMonitorTimeouts(event)).toEqual({}); + }); + + it("should ignore malformed timeout tags", () => { + const event = createMonitorEvent({ + tags: [ + ["timeout", "open", "1000"], + ["timeout", "invalid"], + ["timeout", "read"], + ], + }); + const timeouts = getMonitorTimeouts(event); + expect(timeouts.open).toBe(1000); + expect(Object.keys(timeouts)).toHaveLength(1); + }); + }); + + describe("getMonitorChecks", () => { + it("should extract all check types", () => { + const event = createMonitorEvent({ + tags: [ + ["c", "open"], + ["c", "read"], + ["c", "write"], + ["c", "auth"], + ], + }); + expect(getMonitorChecks(event)).toEqual([ + "open", + "read", + "write", + "auth", + ]); + }); + + it("should deduplicate check types", () => { + const event = createMonitorEvent({ + tags: [ + ["c", "open"], + ["c", "read"], + ["c", "open"], + ], + }); + expect(getMonitorChecks(event)).toEqual(["open", "read"]); + }); + + it("should return empty array if no check tags", () => { + const event = createMonitorEvent({ + tags: [], + }); + expect(getMonitorChecks(event)).toEqual([]); + }); + }); + + describe("getMonitorGeohash", () => { + it("should extract geohash", () => { + const event = createMonitorEvent({ + tags: [["g", "u4pruydqqvj"]], + }); + expect(getMonitorGeohash(event)).toBe("u4pruydqqvj"); + }); + + it("should return undefined if no geohash tag", () => { + const event = createMonitorEvent({ + tags: [], + }); + expect(getMonitorGeohash(event)).toBeUndefined(); + }); + }); +}); + +describe("Formatting Utilities", () => { + describe("formatFrequency", () => { + it("should format seconds", () => { + expect(formatFrequency(1)).toBe("1 second"); + expect(formatFrequency(30)).toBe("30 seconds"); + }); + + it("should format minutes", () => { + expect(formatFrequency(60)).toBe("1 minute"); + expect(formatFrequency(300)).toBe("5 minutes"); + }); + + it("should format hours", () => { + expect(formatFrequency(3600)).toBe("1 hour"); + expect(formatFrequency(7200)).toBe("2 hours"); + }); + + it("should format days", () => { + expect(formatFrequency(86400)).toBe("1 day"); + expect(formatFrequency(172800)).toBe("2 days"); + }); + }); + + describe("formatTimeout", () => { + it("should format milliseconds", () => { + expect(formatTimeout(500)).toBe("500ms"); + expect(formatTimeout(999)).toBe("999ms"); + }); + + it("should format seconds", () => { + expect(formatTimeout(1000)).toBe("1s"); + expect(formatTimeout(2500)).toBe("2.5s"); + }); + + it("should format minutes", () => { + expect(formatTimeout(60000)).toBe("1m"); + expect(formatTimeout(120000)).toBe("2m"); + }); + }); + + describe("getCheckTypeName", () => { + it("should return human-readable names for known check types", () => { + expect(getCheckTypeName("open")).toBe("Connection"); + expect(getCheckTypeName("read")).toBe("Read"); + expect(getCheckTypeName("write")).toBe("Write"); + expect(getCheckTypeName("auth")).toBe("Authentication"); + expect(getCheckTypeName("nip11")).toBe("NIP-11 Info"); + expect(getCheckTypeName("dns")).toBe("DNS"); + expect(getCheckTypeName("geo")).toBe("Geolocation"); + }); + + it("should return input for unknown check types", () => { + expect(getCheckTypeName("unknown")).toBe("unknown"); + expect(getCheckTypeName("custom-check")).toBe("custom-check"); + }); + }); +}); diff --git a/src/lib/nip66-helpers.ts b/src/lib/nip66-helpers.ts new file mode 100644 index 0000000..fc3231e --- /dev/null +++ b/src/lib/nip66-helpers.ts @@ -0,0 +1,352 @@ +import type { NostrEvent } from "@/types/nostr"; +import { getTagValue } from "applesauce-core/helpers"; + +/** + * NIP-66 Helper Functions + * Utility functions for parsing NIP-66 relay discovery and monitoring events + */ + +// ============================================================================ +// Relay Discovery Event Helpers (Kind 30166) +// ============================================================================ + +/** + * Get relay URL from d tag (may be normalized or hex-encoded pubkey) + * @param event Relay discovery event (kind 30166) + * @returns Relay URL or undefined + */ +export function getRelayUrl(event: NostrEvent): string | undefined { + return getTagValue(event, "d"); +} + +/** + * Get RTT (round-trip time) metrics in milliseconds + * @param event Relay discovery event (kind 30166) + * @returns Object with open, read, write RTT values + */ +export function getRttMetrics(event: NostrEvent): { + open?: number; + read?: number; + write?: number; +} { + const rttOpen = getTagValue(event, "rtt-open"); + const rttRead = getTagValue(event, "rtt-read"); + const rttWrite = getTagValue(event, "rtt-write"); + + return { + open: rttOpen ? parseInt(rttOpen, 10) : undefined, + read: rttRead ? parseInt(rttRead, 10) : undefined, + write: rttWrite ? parseInt(rttWrite, 10) : undefined, + }; +} + +/** + * Get network type (clearnet, tor, i2p, loki) + * @param event Relay discovery event (kind 30166) + * @returns Network type or undefined + */ +export function getNetworkType(event: NostrEvent): string | undefined { + return getTagValue(event, "n"); +} + +/** + * Get relay type (PascalCase string like "Read", "Write", "Public") + * @param event Relay discovery event (kind 30166) + * @returns Relay type or undefined + */ +export function getRelayType(event: NostrEvent): string | undefined { + return getTagValue(event, "T"); +} + +/** + * Get array of supported NIP numbers + * @param event Relay discovery event (kind 30166) + * @returns Array of NIP numbers + */ +export function getSupportedNips(event: NostrEvent): number[] { + const nips = event.tags + .filter((t) => t[0] === "N") + .map((t) => parseInt(t[1], 10)) + .filter((n) => !isNaN(n)); + + // Return unique sorted NIPs + return Array.from(new Set(nips)).sort((a, b) => a - b); +} + +/** + * Get relay requirements (auth, writes, pow, payment) + * Returns object with boolean values for each requirement + * NIP-66 uses '!' prefix for false/negative requirements + * @param event Relay discovery event (kind 30166) + * @returns Object with requirement flags + */ +export function getRelayRequirements(event: NostrEvent): { + auth?: boolean; + writes?: boolean; + pow?: boolean; + payment?: boolean; +} { + const requirements: { + auth?: boolean; + writes?: boolean; + pow?: boolean; + payment?: boolean; + } = {}; + + const reqTags = event.tags.filter((t) => t[0] === "R"); + + for (const tag of reqTags) { + const value = tag[1]; + if (!value) continue; + + // Check for ! prefix (negative/false) + const isNegative = value.startsWith("!"); + const requirementKey = isNegative ? value.slice(1) : value; + const requirementValue = !isNegative; + + if (requirementKey === "auth") { + requirements.auth = requirementValue; + } else if (requirementKey === "writes") { + requirements.writes = requirementValue; + } else if (requirementKey === "pow") { + requirements.pow = requirementValue; + } else if (requirementKey === "payment") { + requirements.payment = requirementValue; + } + } + + return requirements; +} + +/** + * Get array of topics + * @param event Relay discovery event (kind 30166) + * @returns Array of topic strings + */ +export function getRelayTopics(event: NostrEvent): string[] { + return event.tags.filter((t) => t[0] === "t").map((t) => t[1]); +} + +/** + * Get accepted and rejected kinds + * Returns separate arrays for accepted and rejected kinds + * NIP-66 uses '!' prefix for rejected kinds + * @param event Relay discovery event (kind 30166) + * @returns Object with accepted and rejected kind arrays + */ +export function getRelayKinds(event: NostrEvent): { + accepted: number[]; + rejected: number[]; +} { + const accepted: number[] = []; + const rejected: number[] = []; + + const kindTags = event.tags.filter((t) => t[0] === "k"); + + for (const tag of kindTags) { + const value = tag[1]; + if (!value) continue; + + // Check for ! prefix (rejected) + const isRejected = value.startsWith("!"); + const kindStr = isRejected ? value.slice(1) : value; + const kindNum = parseInt(kindStr, 10); + + if (!isNaN(kindNum)) { + if (isRejected) { + rejected.push(kindNum); + } else { + accepted.push(kindNum); + } + } + } + + return { + accepted: Array.from(new Set(accepted)).sort((a, b) => a - b), + rejected: Array.from(new Set(rejected)).sort((a, b) => a - b), + }; +} + +/** + * Get geohash location + * @param event Relay discovery event (kind 30166) + * @returns Geohash string or undefined + */ +export function getRelayGeohash(event: NostrEvent): string | undefined { + return getTagValue(event, "g"); +} + +/** + * Parse NIP-11 document from content field + * @param event Relay discovery event (kind 30166) + * @returns Parsed NIP-11 object or undefined + */ +export function parseNip11Document(event: NostrEvent): object | undefined { + if (!event.content || event.content.trim() === "") { + return undefined; + } + + try { + return JSON.parse(event.content); + } catch { + return undefined; + } +} + +/** + * Calculate relay health score (0-100) based on RTT and event recency + * Lower RTT = higher score, more recent events = higher score + * @param event Relay discovery event (kind 30166) + * @returns Health score from 0 to 100 + */ +export function calculateRelayHealth(event: NostrEvent): number { + let score = 100; + + // Factor 1: RTT performance (up to -40 points) + const rtt = getRttMetrics(event); + const avgRtt = [rtt.open, rtt.read, rtt.write] + .filter((v): v is number => v !== undefined) + .reduce((sum, v, _, arr) => sum + v / arr.length, 0); + + if (avgRtt) { + // Penalize high RTT: 0-100ms = no penalty, 100-1000ms = -20 points, >1000ms = -40 points + if (avgRtt > 1000) { + score -= 40; + } else if (avgRtt > 100) { + score -= ((avgRtt - 100) / 900) * 20; + } + } + + // Factor 2: Event age (up to -60 points) + const now = Math.floor(Date.now() / 1000); + const ageInSeconds = now - event.created_at; + const ageInDays = ageInSeconds / (24 * 60 * 60); + + // Penalize old events: <1 day = no penalty, 1-7 days = -30 points, >7 days = -60 points + if (ageInDays > 7) { + score -= 60; + } else if (ageInDays > 1) { + score -= ((ageInDays - 1) / 6) * 30; + } + + return Math.max(0, Math.min(100, Math.round(score))); +} + +// ============================================================================ +// Monitor Announcement Event Helpers (Kind 10166) +// ============================================================================ + +/** + * Get monitoring frequency in seconds + * @param event Monitor announcement event (kind 10166) + * @returns Frequency in seconds or undefined + */ +export function getMonitorFrequency(event: NostrEvent): number | undefined { + const freq = getTagValue(event, "frequency"); + return freq ? parseInt(freq, 10) : undefined; +} + +/** + * Get timeout configurations per check type + * Returns map of check type to timeout in milliseconds + * @param event Monitor announcement event (kind 10166) + * @returns Map of check type to timeout value + */ +export function getMonitorTimeouts(event: NostrEvent): Record { + const timeouts: Record = {}; + + const timeoutTags = event.tags.filter((t) => t[0] === "timeout"); + + for (const tag of timeoutTags) { + if (tag.length >= 3) { + const checkType = tag[1]; + const timeout = parseInt(tag[2], 10); + if (checkType && !isNaN(timeout)) { + timeouts[checkType] = timeout; + } + } + } + + return timeouts; +} + +/** + * Get array of check types performed by this monitor + * @param event Monitor announcement event (kind 10166) + * @returns Array of check type strings (open, read, write, auth, nip11, dns, geo) + */ +export function getMonitorChecks(event: NostrEvent): string[] { + const checks = event.tags.filter((t) => t[0] === "c").map((t) => t[1]); + return Array.from(new Set(checks)); +} + +/** + * Get monitor's geohash location + * @param event Monitor announcement event (kind 10166) + * @returns Geohash string or undefined + */ +export function getMonitorGeohash(event: NostrEvent): string | undefined { + return getTagValue(event, "g"); +} + +/** + * Format frequency for human-readable display + * @param seconds Frequency in seconds + * @returns Formatted string (e.g., "5 minutes", "1 hour") + */ +export function formatFrequency(seconds: number): string { + if (seconds < 60) { + return seconds === 1 ? "1 second" : `${seconds} seconds`; + } + + const minutes = Math.floor(seconds / 60); + if (minutes < 60) { + return minutes === 1 ? "1 minute" : `${minutes} minutes`; + } + + const hours = Math.floor(minutes / 60); + if (hours < 24) { + return hours === 1 ? "1 hour" : `${hours} hours`; + } + + const days = Math.floor(hours / 24); + return days === 1 ? "1 day" : `${days} days`; +} + +/** + * Format timeout value for display + * @param milliseconds Timeout in milliseconds + * @returns Formatted string (e.g., "500ms", "2s") + */ +export function formatTimeout(milliseconds: number): string { + if (milliseconds < 1000) { + return `${milliseconds}ms`; + } + + const seconds = milliseconds / 1000; + if (seconds < 60) { + return `${seconds}s`; + } + + const minutes = Math.floor(seconds / 60); + return `${minutes}m`; +} + +/** + * Get human-readable check type name + * @param checkType Check type code (e.g., "open", "read", "write") + * @returns Human-readable name + */ +export function getCheckTypeName(checkType: string): string { + const names: Record = { + open: "Connection", + read: "Read", + write: "Write", + auth: "Authentication", + nip11: "NIP-11 Info", + dns: "DNS", + geo: "Geolocation", + }; + + return names[checkType] || checkType; +}