mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-08 22:47:02 +02:00
feat(nip-66): add relay discovery and monitor announcement renderers (#172)
* feat(nip-66): add relay discovery and monitor announcement renderers Implements NIP-66 support to display relay health metrics and monitoring information. Users can now view relay performance data (RTT, network type, supported NIPs) and monitor announcements to make informed decisions about relay selection and reliability. Includes 58 comprehensive tests for all helper functions and event parsing. * refactor(nip-66): improve UI with Label, NIPBadge, and clickable titles Enhance NIP-66 renderers with better UI components: - Use NIPBadge component for clickable NIP numbers - Replace section headers with Label component for consistency - Add ClickableEventTitle to monitor announcements - Improve requirement icons with CheckCircle/XCircle for clarity - Add proper icons throughout for better visual hierarchy * refactor(nip-66): use Hammer icon for PoW requirements Replace Zap (lightning bolt) icon with Hammer icon for proof-of-work indicators to better represent the mining/work metaphor. Updates both feed and detail renderers for relay discovery events. * refactor(nip-66): improve feed UI with clickable titles and simplified layout - Add ClickableEventTitle to relay discovery feed items for opening detail view - Remove "Monitoring" label from relay monitor feed items for cleaner layout - Remove unused imports (RelayLink, Label, Activity) from feed renderers - Maintain existing Label and NIPBadge usage in detail renderers * refactor(nip-66): add Label component for check types in monitor feed Add "Check Types" label to relay monitor feed renderer for better visual hierarchy and consistency with detail renderer. * refactor(nip-66): remove Check Types label from monitor feed Remove label title to simplify monitor feed layout - check type badges are displayed directly without a header for cleaner appearance. * refactor(nip-66): use Label component for individual check types in monitor feed Replace Badge components with Label components for check types to match the design system and provide better visual consistency. * refactor(nip-66): rename components to human-readable names Rename NIP-66 component exports to match established naming convention: - Kind10166Renderer → MonitorAnnouncementRenderer - Kind10166DetailRenderer → MonitorAnnouncementDetailRenderer - Kind30166Renderer → RelayDiscoveryRenderer - Kind30166DetailRenderer → RelayDiscoveryDetailRenderer This follows the pattern used elsewhere (e.g., LiveActivityRenderer vs Kind30311Renderer) to make code more readable without memorizing kind numbers. * refactor(nip-66): extract relay kinds display into reusable component Create RelayKindsDisplay component to show accepted/rejected kinds in a consistent format across detail views. Used in RelayDiscoveryDetailRenderer to reduce code duplication and improve maintainability. * refactor(nip-66): extract supported NIPs display into reusable component Create RelaySupportedNips component to show relay-supported NIPs in a consistent format. Used in RelayDiscoveryDetailRenderer to reduce code duplication and improve maintainability. * refactor(nip-66): add icon to supported NIPs component for consistent styling Add FileText icon to RelaySupportedNips Label to match the visual hierarchy pattern used in other relay detail sections (Performance Metrics, Characteristics, Requirements, etc.). * refactor(nip-66): use nicer NIP rendering from RelayViewer in shared component Update RelaySupportedNips component to match RelayViewer's nicer styling: - Show NIP names alongside numbers (showName=true) for better readability - Use gap-2 for better spacing - Use h3 title styling instead of Label with icon - Make component reusable with optional title customization - Use in both RelayViewer and RelayDiscoveryDetailRenderer --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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 && (
|
||||
<div>
|
||||
<h3 className="mb-3 font-semibold text-sm">Supported NIPs</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{info.supported_nips.map((num: number) => (
|
||||
<NIPBadge
|
||||
key={num}
|
||||
nipNumber={String(num).padStart(2, "0")}
|
||||
showName={true}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{info?.supported_nips && (
|
||||
<RelaySupportedNips nips={info.supported_nips} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
60
src/components/nostr/RelayKindsDisplay.tsx
Normal file
60
src/components/nostr/RelayKindsDisplay.tsx
Normal file
@@ -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 && (
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2 text-muted-foreground">
|
||||
<Filter className="size-4" />
|
||||
Accepted Kinds ({accepted.length})
|
||||
</Label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{accepted.map((kind) => (
|
||||
<Badge key={kind} variant="outline" className="font-mono text-xs">
|
||||
{kind}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rejected Kinds */}
|
||||
{rejected.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2 text-muted-foreground">
|
||||
<XCircle className="size-4" />
|
||||
Rejected Kinds ({rejected.length})
|
||||
</Label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{rejected.map((kind) => (
|
||||
<Badge
|
||||
key={kind}
|
||||
variant="outline"
|
||||
className="font-mono text-xs text-red-600 border-red-600/30"
|
||||
>
|
||||
!{kind}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
38
src/components/nostr/RelaySupportedNips.tsx
Normal file
38
src/components/nostr/RelaySupportedNips.tsx
Normal file
@@ -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 (
|
||||
<div>
|
||||
{showTitle && <h3 className="mb-3 font-semibold text-sm">{title}</h3>}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{nips.map((nip) => (
|
||||
<NIPBadge
|
||||
key={nip}
|
||||
nipNumber={nip.toString().padStart(2, "0")}
|
||||
showName={true}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
138
src/components/nostr/kinds/MonitorAnnouncementDetailRenderer.tsx
Normal file
138
src/components/nostr/kinds/MonitorAnnouncementDetailRenderer.tsx
Normal file
@@ -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 (
|
||||
<div className="flex flex-col gap-4 p-4 max-w-4xl">
|
||||
{/* Header: Monitor Identity */}
|
||||
<div className="flex flex-col gap-2 pb-4 border-b">
|
||||
<h1 className="text-2xl font-bold">Relay Monitor</h1>
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<span className="text-sm">Operated by</span>
|
||||
<UserName pubkey={event.pubkey} className="font-medium" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Operational Parameters */}
|
||||
<div className="space-y-4">
|
||||
{/* Monitoring Frequency */}
|
||||
{frequency && !isNaN(frequency) && (
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2 text-muted-foreground">
|
||||
<Clock className="size-4" />
|
||||
Publishing Frequency
|
||||
</Label>
|
||||
<div className="p-3 rounded-lg bg-muted/50">
|
||||
<span className="text-lg font-semibold">
|
||||
{formatFrequency(frequency)}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground ml-2">
|
||||
({frequency} seconds)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Check Types Performed */}
|
||||
{checks.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2 text-muted-foreground">
|
||||
<Activity className="size-4" />
|
||||
Check Types ({checks.length})
|
||||
</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{checks.map((check) => (
|
||||
<Badge key={check} variant="secondary" className="px-3 py-1.5">
|
||||
{getCheckTypeName(check)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timeout Configurations */}
|
||||
{Object.keys(timeouts).length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2 text-muted-foreground">
|
||||
<Timer className="size-4" />
|
||||
Timeout Configurations
|
||||
</Label>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
{Object.entries(timeouts).map(([checkType, timeout]) => (
|
||||
<div
|
||||
key={checkType}
|
||||
className="flex flex-col gap-1 p-3 rounded-lg bg-muted/50"
|
||||
>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{getCheckTypeName(checkType)}
|
||||
</span>
|
||||
<span className="text-base font-semibold">
|
||||
{formatTimeout(timeout)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Geographic Location */}
|
||||
{geohash && (
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2 text-muted-foreground">
|
||||
<MapPin className="size-4" />
|
||||
Location
|
||||
</Label>
|
||||
<Badge variant="outline" className="gap-2 px-3 py-1.5">
|
||||
<MapPin className="size-4" />
|
||||
<span className="font-mono">{geohash}</span>
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Monitor Description */}
|
||||
{event.content && event.content.trim() !== "" && (
|
||||
<div className="space-y-2 pt-4 border-t">
|
||||
<Label className="text-muted-foreground">About this Monitor</Label>
|
||||
<p className="text-sm whitespace-pre-wrap">{event.content}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!frequency &&
|
||||
checks.length === 0 &&
|
||||
Object.keys(timeouts).length === 0 &&
|
||||
!geohash &&
|
||||
(!event.content || event.content.trim() === "") && (
|
||||
<div className="text-center text-muted-foreground text-sm py-8">
|
||||
No monitoring configuration specified
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
61
src/components/nostr/kinds/MonitorAnnouncementRenderer.tsx
Normal file
61
src/components/nostr/kinds/MonitorAnnouncementRenderer.tsx
Normal file
@@ -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 (
|
||||
<BaseEventContainer event={event}>
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* Clickable Title */}
|
||||
<ClickableEventTitle event={event} className="text-base font-semibold">
|
||||
Relay Monitor
|
||||
</ClickableEventTitle>
|
||||
|
||||
{/* Monitoring Frequency */}
|
||||
{frequency && !isNaN(frequency) && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Clock className="size-4 text-muted-foreground" />
|
||||
<span>
|
||||
Checks every{" "}
|
||||
<span className="font-medium">{formatFrequency(frequency)}</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Check Types */}
|
||||
{checks.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{checks.map((check) => (
|
||||
<Label key={check} className="text-xs text-muted-foreground">
|
||||
{getCheckTypeName(check)}
|
||||
</Label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!frequency && checks.length === 0 && (
|
||||
<div className="text-xs text-muted-foreground italic">
|
||||
No monitoring configuration specified
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</BaseEventContainer>
|
||||
);
|
||||
}
|
||||
275
src/components/nostr/kinds/RelayDiscoveryDetailRenderer.tsx
Normal file
275
src/components/nostr/kinds/RelayDiscoveryDetailRenderer.tsx
Normal file
@@ -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 (
|
||||
<div className="p-4 text-center text-muted-foreground text-sm">
|
||||
Invalid relay discovery event (missing relay URL)
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Calculate health color based on score
|
||||
const healthColor =
|
||||
health >= 80
|
||||
? "text-green-600"
|
||||
: health >= 50
|
||||
? "text-yellow-600"
|
||||
: "text-red-600";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 p-4 max-w-4xl">
|
||||
{/* Header: Relay URL and Health */}
|
||||
<div className="flex items-center justify-between gap-4 pb-4 border-b">
|
||||
<div className="flex-1 min-w-0">
|
||||
<RelayLink
|
||||
url={relayUrl}
|
||||
urlClassname="text-xl font-bold underline decoration-dotted"
|
||||
iconClassname="size-6"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Activity className={cn("size-5", healthColor)} />
|
||||
<span className={cn("text-2xl font-bold", healthColor)}>
|
||||
{health}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Performance Metrics Section */}
|
||||
{(rtt.open !== undefined ||
|
||||
rtt.read !== undefined ||
|
||||
rtt.write !== undefined) && (
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2 text-muted-foreground">
|
||||
<Activity className="size-4" />
|
||||
Performance Metrics
|
||||
</Label>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{rtt.open !== undefined && !isNaN(rtt.open) && (
|
||||
<div className="flex flex-col gap-1 p-3 rounded-lg bg-muted/50">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Connection
|
||||
</span>
|
||||
<span className="text-lg font-semibold">{rtt.open}ms</span>
|
||||
</div>
|
||||
)}
|
||||
{rtt.read !== undefined && !isNaN(rtt.read) && (
|
||||
<div className="flex flex-col gap-1 p-3 rounded-lg bg-muted/50">
|
||||
<span className="text-xs text-muted-foreground">Read</span>
|
||||
<span className="text-lg font-semibold">{rtt.read}ms</span>
|
||||
</div>
|
||||
)}
|
||||
{rtt.write !== undefined && !isNaN(rtt.write) && (
|
||||
<div className="flex flex-col gap-1 p-3 rounded-lg bg-muted/50">
|
||||
<span className="text-xs text-muted-foreground">Write</span>
|
||||
<span className="text-lg font-semibold">{rtt.write}ms</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Relay Characteristics */}
|
||||
{(networkType || relayType || geohash) && (
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2 text-muted-foreground">
|
||||
<Globe className="size-4" />
|
||||
Characteristics
|
||||
</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{networkType && (
|
||||
<Badge variant="outline" className="gap-1.5 px-3 py-1">
|
||||
{networkType === "tor" && <CircleDot className="size-4" />}
|
||||
{(networkType === "i2p" || networkType === "clearnet") && (
|
||||
<Globe className="size-4" />
|
||||
)}
|
||||
<span className="capitalize">{networkType}</span>
|
||||
</Badge>
|
||||
)}
|
||||
{relayType && (
|
||||
<Badge variant="secondary" className="px-3 py-1">
|
||||
{relayType}
|
||||
</Badge>
|
||||
)}
|
||||
{geohash && (
|
||||
<Badge variant="outline" className="gap-1.5 px-3 py-1">
|
||||
<MapPin className="size-4" />
|
||||
{geohash}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Requirements Section */}
|
||||
{Object.keys(requirements).length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2 text-muted-foreground">
|
||||
<Shield className="size-4" />
|
||||
Requirements
|
||||
</Label>
|
||||
<div className="flex flex-col gap-2">
|
||||
{requirements.auth !== undefined && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
{requirements.auth ? (
|
||||
<Lock className="size-4 text-orange-600" />
|
||||
) : (
|
||||
<CheckCircle className="size-4 text-green-600" />
|
||||
)}
|
||||
<span>
|
||||
Authentication{" "}
|
||||
{requirements.auth ? "required" : "not required"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{requirements.payment !== undefined && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
{requirements.payment ? (
|
||||
<CreditCard className="size-4 text-blue-600" />
|
||||
) : (
|
||||
<CheckCircle className="size-4 text-green-600" />
|
||||
)}
|
||||
<span>
|
||||
Payment {requirements.payment ? "required" : "not required"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{requirements.writes !== undefined && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
{requirements.writes ? (
|
||||
<CheckCircle className="size-4 text-green-600" />
|
||||
) : (
|
||||
<XCircle className="size-4 text-red-600" />
|
||||
)}
|
||||
<span>
|
||||
{requirements.writes
|
||||
? "Write access enabled"
|
||||
: "Read-only relay"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{requirements.pow !== undefined && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
{requirements.pow ? (
|
||||
<Hammer className="size-4 text-purple-600" />
|
||||
) : (
|
||||
<CheckCircle className="size-4 text-green-600" />
|
||||
)}
|
||||
<span>
|
||||
Proof of work {requirements.pow ? "required" : "not required"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Supported NIPs */}
|
||||
{nips.length > 0 && (
|
||||
<RelaySupportedNips
|
||||
nips={nips}
|
||||
title={`Supported NIPs (${nips.length})`}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Relay Kinds */}
|
||||
<RelayKindsDisplay accepted={kinds.accepted} rejected={kinds.rejected} />
|
||||
|
||||
{/* Topics */}
|
||||
{topics.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2 text-muted-foreground">
|
||||
<Tag className="size-4" />
|
||||
Topics ({topics.length})
|
||||
</Label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{topics.map((topic, index) => (
|
||||
<Badge key={index} variant="secondary" className="text-xs">
|
||||
{topic}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* NIP-11 Document */}
|
||||
{nip11 && (
|
||||
<div className="space-y-2">
|
||||
<JsonViewer
|
||||
data={nip11}
|
||||
open={showNip11}
|
||||
onOpenChange={setShowNip11}
|
||||
title="NIP-11 Information Document"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Monitor Attribution */}
|
||||
<div className="pt-4 border-t text-xs text-muted-foreground space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="size-3" />
|
||||
<span>
|
||||
Monitored {formatTimestamp(event.created_at)} by{" "}
|
||||
<UserName pubkey={event.pubkey} className="font-medium" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
147
src/components/nostr/kinds/RelayDiscoveryRenderer.tsx
Normal file
147
src/components/nostr/kinds/RelayDiscoveryRenderer.tsx
Normal file
@@ -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 (
|
||||
<BaseEventContainer event={event}>
|
||||
<div className="text-xs text-muted-foreground italic">
|
||||
Invalid relay discovery event (missing relay URL)
|
||||
</div>
|
||||
</BaseEventContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<BaseEventContainer event={event}>
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* Clickable Title and Health Score */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<ClickableEventTitle
|
||||
event={event}
|
||||
className="text-base font-semibold truncate flex-1 min-w-0"
|
||||
>
|
||||
{relayUrl}
|
||||
</ClickableEventTitle>
|
||||
<div className="flex items-center gap-1 text-xs shrink-0">
|
||||
<Activity className={cn("size-3", healthColor)} />
|
||||
<span className={cn("font-medium", healthColor)}>{health}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Badges: Network Type, Relay Type, RTT, Requirements */}
|
||||
<div className="flex flex-wrap gap-1.5 text-xs">
|
||||
{/* Network Type Badge */}
|
||||
{networkType && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="gap-1 h-5 px-1.5 bg-background/50"
|
||||
>
|
||||
{networkType === "tor" && <CircleDot className="size-3" />}
|
||||
{networkType === "i2p" && <Globe className="size-3" />}
|
||||
{networkType === "clearnet" && <Globe className="size-3" />}
|
||||
<span className="capitalize">{networkType}</span>
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{/* Relay Type Badge */}
|
||||
{relayType && (
|
||||
<Badge variant="secondary" className="h-5 px-1.5">
|
||||
{relayType}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{/* RTT Badge */}
|
||||
{avgRtt > 0 && (
|
||||
<Badge variant="outline" className="h-5 px-1.5 gap-1">
|
||||
<Activity className="size-3" />
|
||||
{Math.round(avgRtt)}ms
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{/* Requirements Badges */}
|
||||
{requirements.auth && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="h-5 px-1.5 gap-1 text-orange-600 border-orange-600/30"
|
||||
>
|
||||
<Lock className="size-3" />
|
||||
Auth
|
||||
</Badge>
|
||||
)}
|
||||
{requirements.payment && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="h-5 px-1.5 gap-1 text-blue-600 border-blue-600/30"
|
||||
>
|
||||
<CreditCard className="size-3" />
|
||||
Paid
|
||||
</Badge>
|
||||
)}
|
||||
{requirements.writes === false && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="h-5 px-1.5 gap-1 text-muted-foreground"
|
||||
>
|
||||
Read-only
|
||||
</Badge>
|
||||
)}
|
||||
{requirements.pow && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="h-5 px-1.5 gap-1 text-purple-600 border-purple-600/30"
|
||||
>
|
||||
<Hammer className="size-3" />
|
||||
PoW
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</BaseEventContainer>
|
||||
);
|
||||
}
|
||||
@@ -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<number, React.ComponentType<BaseEventProps>> = {
|
||||
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<number, React.ComponentType<BaseEventProps>> = {
|
||||
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)
|
||||
|
||||
667
src/lib/nip66-helpers.test.ts
Normal file
667
src/lib/nip66-helpers.test.ts
Normal file
@@ -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>,
|
||||
): 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>): 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
352
src/lib/nip66-helpers.ts
Normal file
352
src/lib/nip66-helpers.ts
Normal file
@@ -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<string, number> {
|
||||
const timeouts: Record<string, number> = {};
|
||||
|
||||
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<string, string> = {
|
||||
open: "Connection",
|
||||
read: "Read",
|
||||
write: "Write",
|
||||
auth: "Authentication",
|
||||
nip11: "NIP-11 Info",
|
||||
dns: "DNS",
|
||||
geo: "Geolocation",
|
||||
};
|
||||
|
||||
return names[checkType] || checkType;
|
||||
}
|
||||
Reference in New Issue
Block a user