Merge pull request #20 from purrgrammer/claude/improve-reqviewer-state-machine-cBkEO

docs: add comprehensive ReqViewer state machine analysis and improvement plan
This commit is contained in:
Alejandro
2025-12-22 19:51:21 +01:00
committed by GitHub
10 changed files with 3532 additions and 178 deletions

View File

@@ -14,11 +14,13 @@ import {
Search,
Code,
Loader2,
Mail,
Send,
Inbox,
Sparkles,
Link as LinkIcon,
Check,
} from "lucide-react";
import { Virtuoso } from "react-virtuoso";
import { useReqTimeline } from "@/hooks/useReqTimeline";
import { useReqTimelineEnhanced } from "@/hooks/useReqTimelineEnhanced";
import { useGrimoire } from "@/core/state";
import { useRelayState } from "@/hooks/useRelayState";
import { useOutboxRelays } from "@/hooks/useOutboxRelays";
@@ -69,6 +71,13 @@ import { useCopy } from "@/hooks/useCopy";
import { CodeCopyButton } from "@/components/CodeCopyButton";
import { SyntaxHighlight } from "@/components/SyntaxHighlight";
import { getConnectionIcon, getAuthIcon } from "@/lib/relay-status-utils";
import { normalizeRelayURL } from "@/lib/relay-url";
import {
getStatusText,
getStatusTooltip,
getStatusColor,
shouldAnimate,
} from "@/lib/req-state-machine";
import { resolveFilterAliases, getTagValues } from "@/lib/nostr-utils";
import { useNostrEvent } from "@/hooks/useNostrEvent";
import { MemoizedCompactEventRow } from "./nostr/CompactEventRow";
@@ -702,7 +711,6 @@ export default function ReqViewer({
const {
relays: selectedRelays,
reasoning,
isOptimized,
phase: relaySelectionPhase,
} = useOutboxRelays(resolvedFilter, outboxOptions);
@@ -723,26 +731,34 @@ export default function ReqViewer({
return selectedRelays;
}, [relays, relaySelectionPhase, selectedRelays]);
// Get relay state for each relay and calculate connected count
const relayStatesForReq = useMemo(
() =>
finalRelays.map((url) => ({
url,
state: relayStates[url],
})),
[finalRelays, relayStates],
);
const connectedCount = relayStatesForReq.filter(
(r) => r.state?.connectionState === "connected",
).length;
// Normalize relay URLs for consistent lookups in relayStates
// RelayStateManager normalizes all URLs (adds trailing slash, lowercase, etc.)
// so we must normalize here too to match the keys in relayStates
const normalizedRelays = useMemo(() => {
return finalRelays.map((url) => {
try {
return normalizeRelayURL(url);
} catch (err) {
console.warn("Failed to normalize relay URL:", url, err);
return url; // Fallback to original URL if normalization fails
}
});
}, [finalRelays]);
// Streaming is the default behavior, closeOnEose inverts it
const stream = !closeOnEose;
const { events, loading, error, eoseReceived } = useReqTimeline(
const {
events,
loading,
error,
eoseReceived,
relayStates: reqRelayStates,
overallState,
} = useReqTimelineEnhanced(
`req-${JSON.stringify(filter)}-${closeOnEose}`,
resolvedFilter,
finalRelays,
normalizedRelays,
{ limit: resolvedFilter.limit || 50, stream },
);
@@ -915,48 +931,25 @@ export default function ReqViewer({
{/* Compact Header */}
<div className="border-b border-border px-4 py-2 font-mono text-xs flex items-center justify-between">
{/* Left: Status Indicator */}
<div className="flex items-center gap-2">
<Radio
className={`size-3 ${
relaySelectionPhase !== "ready"
? "text-yellow-500 animate-pulse"
: loading && eoseReceived && stream
? "text-green-500 animate-pulse"
: loading && !eoseReceived
? "text-yellow-500 animate-pulse"
: eoseReceived
? "text-muted-foreground"
: "text-yellow-500 animate-pulse"
}`}
/>
<span
className={`${
relaySelectionPhase !== "ready"
? "text-yellow-500"
: loading && eoseReceived && stream
? "text-green-500"
: loading && !eoseReceived
? "text-yellow-500"
: eoseReceived
? "text-muted-foreground"
: "text-yellow-500"
} font-semibold`}
>
{relaySelectionPhase === "discovering"
? "DISCOVERING RELAYS"
: relaySelectionPhase === "selecting"
? "SELECTING RELAYS"
: loading && eoseReceived && stream
? "LIVE"
: loading && !eoseReceived && events.length === 0
? "CONNECTING"
: loading && !eoseReceived
? "LOADING"
: eoseReceived
? "CLOSED"
: "CONNECTING"}
</span>
</div>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-2 cursor-help">
<Radio
className={`size-3 ${getStatusColor(overallState.status)} ${
shouldAnimate(overallState.status) ? "animate-pulse" : ""
}`}
/>
<span
className={`${getStatusColor(overallState.status)} font-semibold`}
>
{getStatusText(overallState)}
</span>
</div>
</TooltipTrigger>
<TooltipContent className="bg-popover text-popover-foreground border border-border shadow-md">
<p>{getStatusTooltip(overallState)}</p>
</TooltipContent>
</Tooltip>
{/* Right: Stats */}
<div className="flex items-center gap-3">
@@ -991,72 +984,28 @@ export default function ReqViewer({
<button className="flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors">
<Wifi className="size-3" />
<span>
{connectedCount}/{finalRelays.length}
{overallState.connectedCount}/{overallState.totalRelays}
</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-80 max-h-96 overflow-y-auto"
className="w-96 max-h-96 overflow-y-auto"
>
{/* Connection Status */}
<div className="py-1 border-b border-border">
<div className="px-3 py-1 text-xs font-semibold text-muted-foreground">
Connection Status
</div>
{relayStatesForReq.map(({ url, state }) => {
const connIcon = getConnectionIcon(state);
const authIcon = getAuthIcon(state);
return (
<DropdownMenuItem
key={url}
className="flex items-center justify-between gap-2"
>
<RelayLink
url={url}
showInboxOutbox={false}
className="flex-1 min-w-0 hover:bg-transparent"
iconClassname="size-3"
urlClassname="text-xs"
/>
<div
className="flex items-center gap-1.5 flex-shrink-0"
onClick={(e) => e.stopPropagation()}
>
{authIcon && (
<Tooltip>
<TooltipTrigger asChild>
<div className="cursor-help">{authIcon.icon}</div>
</TooltipTrigger>
<TooltipContent>
<p>{authIcon.label}</p>
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<div className="cursor-help">{connIcon.icon}</div>
</TooltipTrigger>
<TooltipContent>
<p>{connIcon.label}</p>
</TooltipContent>
</Tooltip>
</div>
</DropdownMenuItem>
);
})}
</div>
{/* Relay Selection */}
{!relays && reasoning && reasoning.length > 0 && (
<div className="py-2">
<div className="px-3 py-1 text-xs font-semibold text-muted-foreground">
Relay Selection
{isOptimized && (
<span className="ml-1.5 font-normal">
(
{/* Header: Relay Selection Strategy */}
<div className="px-3 py-2 border-b border-border">
<div className="flex items-center gap-2 text-xs font-semibold text-muted-foreground">
{relays ? (
// Explicit relays
<>
<LinkIcon className="size-3 text-muted-foreground/60" />
<span>Explicit Relays ({finalRelays.length})</span>
</>
) : reasoning && reasoning.some((r) => !r.isFallback) ? (
// NIP-65 Outbox
<>
<Sparkles className="size-3 text-muted-foreground/60" />
<span>
<button
className="text-accent underline decoration-dotted cursor-crosshair"
onClick={(e) => {
@@ -1064,48 +1013,206 @@ export default function ReqViewer({
addWindow("nip", { number: "65" });
}}
>
NIP-65
</button>
)
NIP-65 Outbox
</button>{" "}
({finalRelays.length} relays)
</span>
)}
</div>
{/* Flat list of relays with icons and counts */}
<div className="px-3 py-1 space-y-1">
{reasoning.map((r, i) => (
<div
key={i}
className="flex items-center gap-2 text-xs py-0.5"
>
<RelayLink
url={r.relay}
className="flex-1 truncate font-mono text-foreground/80"
/>
<div className="flex items-center gap-2 flex-shrink-0 text-muted-foreground">
{r.readers.length > 0 && (
<div className="flex items-center gap-0.5">
<Mail className="w-3 h-3" />
<span>{r.readers.length}</span>
</div>
)}
{r.writers.length > 0 && (
<div className="flex items-center gap-0.5">
<Send className="w-3 h-3" />
<span>{r.writers.length}</span>
</div>
)}
{r.isFallback && (
<span className="text-[10px] text-muted-foreground/60">
fallback
</span>
)}
</div>
</div>
))}
</div>
</>
) : (
// Fallback relays
<>
<Inbox className="size-3 text-muted-foreground/60" />
<span>Fallback Relays ({finalRelays.length})</span>
</>
)}
</div>
)}
</div>
{(() => {
// Group relays by connection status
// Use normalizedRelays for lookups to match RelayStateManager's keys
const onlineRelays: string[] = [];
const disconnectedRelays: string[] = [];
normalizedRelays.forEach((url) => {
const globalState = relayStates[url];
const isConnected =
globalState?.connectionState === "connected";
if (isConnected) {
onlineRelays.push(url);
} else {
disconnectedRelays.push(url);
}
});
const renderRelay = (url: string) => {
const globalState = relayStates[url];
const reqState = reqRelayStates.get(url);
const connIcon = getConnectionIcon(globalState);
const authIcon = getAuthIcon(globalState);
// Find NIP-65 info for this relay (if using outbox)
const nip65Info = reasoning?.find((r) => r.relay === url);
// Build comprehensive tooltip content
const tooltipContent = (
<div className="space-y-3 text-xs p-1">
<div className="font-mono font-bold border-b border-border pb-2 mb-2 break-all text-primary">
{url}
</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-2">
<div className="space-y-0.5">
<div className="text-[10px] uppercase font-bold text-muted-foreground tracking-tight">
Connection
</div>
<div className="flex items-center gap-1.5 font-medium">
<span className="shrink-0">{connIcon.icon}</span>
<span>{connIcon.label}</span>
</div>
</div>
<div className="space-y-0.5">
<div className="text-[10px] uppercase font-bold text-muted-foreground tracking-tight">
Authentication
</div>
<div className="flex items-center gap-1.5 font-medium">
<span className="shrink-0">{authIcon.icon}</span>
<span>{authIcon.label}</span>
</div>
</div>
{reqState && (
<>
<div className="space-y-0.5">
<div className="text-[10px] uppercase font-bold text-muted-foreground tracking-tight">
Subscription
</div>
<div className="font-medium capitalize">
{reqState.subscriptionState}
</div>
</div>
<div className="space-y-0.5">
<div className="text-[10px] uppercase font-bold text-muted-foreground tracking-tight">
Events
</div>
<div className="flex items-center gap-1.5 font-medium">
<FileText className="size-3 text-muted-foreground" />
<span>{reqState.eventCount} received</span>
</div>
</div>
</>
)}
{nip65Info && (
<>
{nip65Info.readers.length > 0 && (
<div className="space-y-0.5">
<div className="text-[10px] uppercase font-bold text-muted-foreground tracking-tight">
Inbox (Read)
</div>
<div className="font-medium">
{nip65Info.readers.length} author
{nip65Info.readers.length !== 1 ? "s" : ""}
</div>
</div>
)}
{nip65Info.writers.length > 0 && (
<div className="space-y-0.5">
<div className="text-[10px] uppercase font-bold text-muted-foreground tracking-tight">
Outbox (Write)
</div>
<div className="font-medium">
{nip65Info.writers.length} author
{nip65Info.writers.length !== 1 ? "s" : ""}
</div>
</div>
)}
</>
)}
</div>
</div>
);
return (
<Tooltip key={url}>
<TooltipTrigger asChild>
<div className="flex items-center gap-2 text-xs py-1 px-3 hover:bg-accent/5 cursor-default">
{/* Relay URL */}
<RelayLink
url={url}
showInboxOutbox={false}
className="flex-1 min-w-0 truncate font-mono text-foreground/80"
/>
{/* Right side: compact status icons */}
<div className="flex items-center gap-1.5 flex-shrink-0">
{/* Event count badge */}
{reqState && reqState.eventCount > 0 && (
<div className="flex items-center gap-1 text-[10px] text-muted-foreground font-medium">
<FileText className="size-2.5" />
<span>{reqState.eventCount}</span>
</div>
)}
{/* EOSE status */}
{reqState && (
<>
{reqState.subscriptionState === "eose" ? (
<Check className="size-3 text-green-600/70" />
) : (
(reqState.subscriptionState === "receiving" ||
reqState.subscriptionState ===
"waiting") && (
<Loader2 className="size-3 text-muted-foreground/40 animate-spin" />
)
)}
</>
)}
{/* Auth icon (always visible) */}
<div>{authIcon.icon}</div>
{/* Connection icon (always visible) */}
<div>{connIcon.icon}</div>
</div>
</div>
</TooltipTrigger>
<TooltipContent
side="left"
className="max-w-xs bg-popover text-popover-foreground border border-border shadow-md"
>
{tooltipContent}
</TooltipContent>
</Tooltip>
);
};
return (
<>
{/* Online Section */}
{onlineRelays.length > 0 && (
<div className="py-2">
<div className="px-3 pb-1 text-[10px] font-semibold text-muted-foreground uppercase tracking-wide">
Online ({onlineRelays.length})
</div>
{onlineRelays.map(renderRelay)}
</div>
)}
{/* Disconnected Section */}
{disconnectedRelays.length > 0 && (
<div className="py-2 border-t border-border">
<div className="px-3 pb-1 text-[10px] font-semibold text-muted-foreground uppercase tracking-wide">
Disconnected ({disconnectedRelays.length})
</div>
{disconnectedRelays.map(renderRelay)}
</div>
)}
</>
);
})()}
</DropdownMenuContent>
</DropdownMenu>

View File

@@ -0,0 +1,340 @@
import { useState, useEffect, useMemo, useRef } from "react";
import pool from "@/services/relay-pool";
import type { NostrEvent, Filter } from "nostr-tools";
import { useEventStore } from "applesauce-react/hooks";
import { isNostrEvent } from "@/lib/type-guards";
import { useStableValue, useStableArray } from "./useStable";
import { useRelayState } from "./useRelayState";
import type { ReqRelayState, ReqOverallState } from "@/types/req-state";
import { deriveOverallState } from "@/lib/req-state-machine";
interface UseReqTimelineEnhancedOptions {
limit?: number;
stream?: boolean;
}
interface UseReqTimelineEnhancedReturn {
events: NostrEvent[];
loading: boolean;
error: Error | null;
eoseReceived: boolean;
// Enhanced state tracking
relayStates: Map<string, ReqRelayState>;
overallState: ReqOverallState;
}
/**
* Enhanced REQ timeline hook with per-relay state tracking
*
* This hook extends the original useReqTimeline with accurate per-relay
* state tracking and overall status derivation. It solves the "LIVE with 0 relays"
* bug by tracking connection state and event counts separately per relay.
*
* Architecture:
* - Uses pool.subscription() for event streaming (with deduplication)
* - Syncs connection state from RelayStateManager
* - Tracks events per relay via event._relay metadata
* - Derives overall state from individual relay states
*
* @param id - Unique identifier for this timeline (for caching)
* @param filters - Nostr filter(s)
* @param relays - Array of relay URLs
* @param options - Stream mode, limit, etc.
*/
export function useReqTimelineEnhanced(
id: string,
filters: Filter | Filter[],
relays: string[],
options: UseReqTimelineEnhancedOptions = { limit: 50 },
): UseReqTimelineEnhancedReturn {
const eventStore = useEventStore();
const { limit, stream = false } = options;
// Core state (compatible with original useReqTimeline)
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const [eoseReceived, setEoseReceived] = useState(false);
const [eventsMap, setEventsMap] = useState<Map<string, NostrEvent>>(
new Map(),
);
// Enhanced: Per-relay state tracking
const [relayStates, setRelayStates] = useState<Map<string, ReqRelayState>>(
new Map(),
);
const queryStartedAt = useRef<number>(Date.now());
const eoseReceivedRef = useRef<boolean>(false);
// Keep ref in sync with state
useEffect(() => {
eoseReceivedRef.current = eoseReceived;
}, [eoseReceived]);
// Get global relay connection states from RelayStateManager
const { relays: globalRelayStates } = useRelayState();
// Sort events by created_at (newest first)
const events = useMemo(() => {
return Array.from(eventsMap.values()).sort(
(a, b) => b.created_at - a.created_at,
);
}, [eventsMap]);
// Stabilize inputs to prevent unnecessary re-renders
const stableFilters = useStableValue(filters);
const stableRelays = useStableArray(relays);
// Initialize relay states when relays change
useEffect(() => {
queryStartedAt.current = Date.now();
const initialStates = new Map<string, ReqRelayState>();
for (const url of relays) {
initialStates.set(url, {
url,
connectionState: "pending",
subscriptionState: "waiting",
eventCount: 0,
});
}
setRelayStates(initialStates);
}, [stableRelays]);
// Sync connection states from RelayStateManager
// This runs whenever globalRelayStates updates
useEffect(() => {
if (relays.length === 0) return;
setRelayStates((prev) => {
const next = new Map(prev);
let changed = false;
// Sync state for all relays in our query
for (const url of relays) {
const globalState = globalRelayStates[url];
const currentState = prev.get(url);
// Initialize if relay not in map yet (shouldn't happen, but defensive)
if (!currentState) {
next.set(url, {
url,
connectionState: globalState?.connectionState || "pending",
subscriptionState: "waiting",
eventCount: 0,
connectedAt: globalState?.lastConnected,
disconnectedAt: globalState?.lastDisconnected,
});
changed = true;
console.log(
"REQ Enhanced: Initialized missing relay state",
url,
globalState?.connectionState,
);
} else if (
globalState &&
globalState.connectionState !== currentState.connectionState
) {
// Update connection state if changed
next.set(url, {
...currentState,
connectionState: globalState.connectionState as any,
connectedAt: globalState.lastConnected,
disconnectedAt: globalState.lastDisconnected,
});
changed = true;
console.log(
"REQ Enhanced: Connection state changed",
url,
currentState.connectionState,
"→",
globalState.connectionState,
);
}
}
return changed ? next : prev;
});
}, [globalRelayStates, relays]);
// Subscribe to events
useEffect(() => {
if (relays.length === 0) {
setLoading(false);
return;
}
console.log("REQ Enhanced: Starting query", {
relays,
filters,
limit,
stream,
});
setLoading(true);
setError(null);
setEoseReceived(false);
setEventsMap(new Map());
// Normalize filters to array
const filterArray = Array.isArray(filters) ? filters : [filters];
// Add limit to filters if specified
const filtersWithLimit = filterArray.map((f) => ({
...f,
limit: limit || f.limit,
}));
// CRITICAL FIX: Subscribe to each relay INDIVIDUALLY to get per-relay EOSE
// Previously used pool.subscription() which only emits EOSE when ALL relays finish
// Now we track each relay separately for accurate per-relay EOSE detection
const subscriptions = relays.map((url) => {
const relay = pool.relay(url);
return relay
.subscription(filtersWithLimit, {
retries: 5,
reconnect: 5,
resubscribe: true,
})
.subscribe(
(response) => {
// Response can be an event or 'EOSE' string
if (typeof response === "string" && response === "EOSE") {
console.log("REQ Enhanced: EOSE received from", url);
// Mark THIS specific relay as having received EOSE
setRelayStates((prev) => {
const state = prev.get(url);
if (!state || state.subscriptionState === "eose") {
return prev; // No change needed
}
const next = new Map(prev);
next.set(url, {
...state,
subscriptionState: "eose",
eoseAt: Date.now(),
});
// Check if ALL relays have reached EOSE
const allEose = Array.from(next.values()).every(
(s) =>
s.subscriptionState === "eose" ||
s.connectionState === "error" ||
s.connectionState === "disconnected",
);
if (allEose && !eoseReceivedRef.current) {
console.log("REQ Enhanced: All relays finished");
setEoseReceived(true);
if (!stream) {
setLoading(false);
}
}
return next;
});
} else if (isNostrEvent(response)) {
// Event received - store and track per relay
const event = response as NostrEvent & { _relay?: string };
// Store in EventStore and local map
eventStore.add(event);
setEventsMap((prev) => {
const next = new Map(prev);
next.set(event.id, event);
return next;
});
// Update relay state for this specific relay
// Use url from subscription, not event._relay (which might be wrong)
setRelayStates((prev) => {
const state = prev.get(url);
const now = Date.now();
const next = new Map(prev);
if (!state) {
// Relay not in map - initialize it (defensive)
console.warn(
"REQ Enhanced: Event from unknown relay, initializing",
url,
);
next.set(url, {
url,
connectionState: "connected",
subscriptionState: "receiving",
eventCount: 1,
firstEventAt: now,
lastEventAt: now,
});
} else {
// Update existing relay state
next.set(url, {
...state,
subscriptionState: "receiving",
eventCount: state.eventCount + 1,
firstEventAt: state.firstEventAt ?? now,
lastEventAt: now,
});
}
return next;
});
} else {
console.warn(
"REQ Enhanced: Unexpected response type from",
url,
response,
);
}
},
(err: Error) => {
console.error("REQ Enhanced: Error from", url, err);
// Mark this relay as errored
setRelayStates((prev) => {
const state = prev.get(url);
if (!state) return prev;
const next = new Map(prev);
next.set(url, {
...state,
subscriptionState: "error",
errorMessage: err.message,
errorType: "connection",
});
return next;
});
},
() => {
// This relay's observable completed
console.log("REQ Enhanced: Relay completed", url);
},
);
});
// Cleanup: unsubscribe from all relays
return () => {
subscriptions.forEach((sub) => sub.unsubscribe());
};
}, [id, stableFilters, stableRelays, limit, stream, eventStore]);
// Derive overall state from individual relay states
const overallState = useMemo(() => {
return deriveOverallState(
relayStates,
eoseReceived,
stream,
queryStartedAt.current,
);
}, [relayStates, eoseReceived, stream]);
return {
events: events || [],
loading,
error,
eoseReceived,
relayStates,
overallState,
};
}

View File

@@ -24,19 +24,19 @@ export function getConnectionIcon(relay: RelayState | undefined) {
const iconMap = {
connected: {
icon: <Wifi className="size-3 text-green-500" />,
icon: <Wifi className="size-3 text-green-600/70" />,
label: "Connected",
},
connecting: {
icon: <Loader2 className="size-3 text-yellow-500 animate-spin" />,
icon: <Loader2 className="size-3 text-yellow-600/70 animate-spin" />,
label: "Connecting",
},
disconnected: {
icon: <WifiOff className="size-3 text-muted-foreground" />,
icon: <WifiOff className="size-3 text-muted-foreground/60" />,
label: "Disconnected",
},
error: {
icon: <XCircle className="size-3 text-red-500" />,
icon: <XCircle className="size-3 text-red-600/70" />,
label: "Connection Error",
},
};
@@ -45,37 +45,40 @@ export function getConnectionIcon(relay: RelayState | undefined) {
/**
* Get authentication icon and label for a relay state
* Returns null if no authentication is required
* Always returns an icon (including for unauthenticated relays)
*/
export function getAuthIcon(relay: RelayState | undefined) {
if (!relay || relay.authStatus === "none") {
return null;
if (!relay) {
return {
icon: <Shield className="size-3 text-muted-foreground/40" />,
label: "Unknown",
};
}
const iconMap = {
authenticated: {
icon: <ShieldCheck className="size-3 text-green-500" />,
icon: <ShieldCheck className="size-3 text-green-600/70" />,
label: "Authenticated",
},
challenge_received: {
icon: <ShieldQuestion className="size-3 text-yellow-500" />,
icon: <ShieldQuestion className="size-3 text-yellow-600/70" />,
label: "Challenge Received",
},
authenticating: {
icon: <Loader2 className="size-3 text-yellow-500 animate-spin" />,
icon: <Loader2 className="size-3 text-yellow-600/70 animate-spin" />,
label: "Authenticating",
},
failed: {
icon: <ShieldX className="size-3 text-red-500" />,
icon: <ShieldX className="size-3 text-red-600/70" />,
label: "Authentication Failed",
},
rejected: {
icon: <ShieldAlert className="size-3 text-muted-foreground" />,
icon: <ShieldAlert className="size-3 text-muted-foreground/60" />,
label: "Authentication Rejected",
},
none: {
icon: <Shield className="size-3 text-muted-foreground" />,
label: "No Authentication",
icon: <Shield className="size-3 text-muted-foreground/40" />,
label: "Not required",
},
};
return iconMap[relay.authStatus] || iconMap.none;

View File

@@ -0,0 +1,681 @@
import { describe, it, expect } from "vitest";
import {
deriveOverallState,
getStatusText,
getStatusTooltip,
getStatusColor,
shouldAnimate,
getRelayStateBadge,
} from "./req-state-machine";
import type { ReqRelayState } from "@/types/req-state";
describe("deriveOverallState", () => {
const queryStartedAt = Date.now();
describe("discovering state", () => {
it("should return discovering when no relays", () => {
const state = deriveOverallState(new Map(), false, false, queryStartedAt);
expect(state.status).toBe("discovering");
expect(state.totalRelays).toBe(0);
});
});
describe("connecting state", () => {
it("should return connecting when relays pending with no events", () => {
const relays = new Map<string, ReqRelayState>([
[
"wss://relay1.com",
{
url: "wss://relay1.com",
connectionState: "pending",
subscriptionState: "waiting",
eventCount: 0,
},
],
]);
const state = deriveOverallState(relays, false, false, queryStartedAt);
expect(state.status).toBe("connecting");
expect(state.hasReceivedEvents).toBe(false);
expect(state.hasActiveRelays).toBe(false);
});
it("should return connecting when relays connecting with no events", () => {
const relays = new Map<string, ReqRelayState>([
[
"wss://relay1.com",
{
url: "wss://relay1.com",
connectionState: "connecting",
subscriptionState: "waiting",
eventCount: 0,
},
],
]);
const state = deriveOverallState(relays, false, false, queryStartedAt);
expect(state.status).toBe("connecting");
});
});
describe("failed state", () => {
it("should return failed when all relays error with no events", () => {
const relays = new Map<string, ReqRelayState>([
[
"wss://relay1.com",
{
url: "wss://relay1.com",
connectionState: "error",
subscriptionState: "error",
eventCount: 0,
},
],
[
"wss://relay2.com",
{
url: "wss://relay2.com",
connectionState: "error",
subscriptionState: "error",
eventCount: 0,
},
],
]);
const state = deriveOverallState(relays, false, false, queryStartedAt);
expect(state.status).toBe("failed");
expect(state.allRelaysFailed).toBe(true);
expect(state.errorCount).toBe(2);
});
});
describe("loading state", () => {
it("should return loading when connected but no EOSE", () => {
const relays = new Map<string, ReqRelayState>([
[
"wss://relay1.com",
{
url: "wss://relay1.com",
connectionState: "connected",
subscriptionState: "receiving",
eventCount: 5,
firstEventAt: Date.now(),
},
],
]);
const state = deriveOverallState(relays, false, false, queryStartedAt);
expect(state.status).toBe("loading");
expect(state.hasReceivedEvents).toBe(true);
expect(state.hasActiveRelays).toBe(true);
expect(state.receivingCount).toBe(1);
});
it("should return loading when waiting for events", () => {
const relays = new Map<string, ReqRelayState>([
[
"wss://relay1.com",
{
url: "wss://relay1.com",
connectionState: "connected",
subscriptionState: "waiting",
eventCount: 0,
},
],
]);
const state = deriveOverallState(relays, false, false, queryStartedAt);
expect(state.status).toBe("loading");
expect(state.hasReceivedEvents).toBe(false);
expect(state.connectedCount).toBe(1);
});
});
describe("live state", () => {
it("should return live when EOSE + streaming + connected", () => {
const relays = new Map<string, ReqRelayState>([
[
"wss://relay1.com",
{
url: "wss://relay1.com",
connectionState: "connected",
subscriptionState: "eose",
eventCount: 10,
eoseAt: Date.now(),
},
],
]);
const state = deriveOverallState(relays, true, true, queryStartedAt);
expect(state.status).toBe("live");
expect(state.hasActiveRelays).toBe(true);
expect(state.eoseCount).toBe(1);
});
it("should return live with multiple connected relays", () => {
const relays = new Map<string, ReqRelayState>([
[
"wss://relay1.com",
{
url: "wss://relay1.com",
connectionState: "connected",
subscriptionState: "eose",
eventCount: 10,
},
],
[
"wss://relay2.com",
{
url: "wss://relay2.com",
connectionState: "connected",
subscriptionState: "receiving",
eventCount: 5,
},
],
]);
const state = deriveOverallState(relays, true, true, queryStartedAt);
expect(state.status).toBe("live");
expect(state.connectedCount).toBe(2);
});
});
describe("offline state", () => {
it("should return offline when all disconnected after EOSE in streaming", () => {
const relays = new Map<string, ReqRelayState>([
[
"wss://relay1.com",
{
url: "wss://relay1.com",
connectionState: "disconnected",
subscriptionState: "eose",
eventCount: 10,
},
],
[
"wss://relay2.com",
{
url: "wss://relay2.com",
connectionState: "disconnected",
subscriptionState: "eose",
eventCount: 5,
},
],
]);
const state = deriveOverallState(relays, true, true, queryStartedAt);
expect(state.status).toBe("offline");
expect(state.hasActiveRelays).toBe(false);
expect(state.hasReceivedEvents).toBe(true);
expect(state.disconnectedCount).toBe(2);
});
it("should return offline when all errored after EOSE in streaming", () => {
const relays = new Map<string, ReqRelayState>([
[
"wss://relay1.com",
{
url: "wss://relay1.com",
connectionState: "error",
subscriptionState: "eose",
eventCount: 10,
},
],
]);
const state = deriveOverallState(relays, true, true, queryStartedAt);
expect(state.status).toBe("offline");
});
});
describe("partial state", () => {
it("should return partial when some relays ok, some failed after EOSE", () => {
const relays = new Map<string, ReqRelayState>([
[
"wss://relay1.com",
{
url: "wss://relay1.com",
connectionState: "connected",
subscriptionState: "eose",
eventCount: 10,
},
],
[
"wss://relay2.com",
{
url: "wss://relay2.com",
connectionState: "error",
subscriptionState: "error",
eventCount: 0,
},
],
]);
const state = deriveOverallState(relays, true, true, queryStartedAt);
expect(state.status).toBe("partial");
expect(state.connectedCount).toBe(1);
expect(state.errorCount).toBe(1);
});
it("should return partial when some disconnected after EOSE", () => {
const relays = new Map<string, ReqRelayState>([
[
"wss://relay1.com",
{
url: "wss://relay1.com",
connectionState: "connected",
subscriptionState: "eose",
eventCount: 10,
},
],
[
"wss://relay2.com",
{
url: "wss://relay2.com",
connectionState: "disconnected",
subscriptionState: "eose",
eventCount: 5,
},
],
]);
const state = deriveOverallState(relays, true, true, queryStartedAt);
expect(state.status).toBe("partial");
expect(state.disconnectedCount).toBe(1);
});
});
describe("closed state", () => {
it("should return closed when EOSE + not streaming", () => {
const relays = new Map<string, ReqRelayState>([
[
"wss://relay1.com",
{
url: "wss://relay1.com",
connectionState: "disconnected",
subscriptionState: "eose",
eventCount: 10,
},
],
]);
const state = deriveOverallState(relays, true, false, queryStartedAt);
expect(state.status).toBe("closed");
});
it("should return closed when all relays disconnected after EOSE non-streaming", () => {
const relays = new Map<string, ReqRelayState>([
[
"wss://relay1.com",
{
url: "wss://relay1.com",
connectionState: "disconnected",
subscriptionState: "eose",
eventCount: 10,
},
],
[
"wss://relay2.com",
{
url: "wss://relay2.com",
connectionState: "disconnected",
subscriptionState: "eose",
eventCount: 5,
},
],
]);
const state = deriveOverallState(relays, true, false, queryStartedAt);
expect(state.status).toBe("closed");
});
});
describe("edge cases from analysis", () => {
it("Scenario 1: All relays disconnect immediately", () => {
const relays = new Map<string, ReqRelayState>();
for (let i = 0; i < 10; i++) {
relays.set(`wss://relay${i}.com`, {
url: `wss://relay${i}.com`,
connectionState: "error",
subscriptionState: "error",
eventCount: 0,
});
}
const state = deriveOverallState(relays, false, true, queryStartedAt);
expect(state.status).toBe("failed");
expect(state.allRelaysFailed).toBe(true);
});
it("Scenario 5: Streaming mode with gradual disconnections (THE BUG)", () => {
// Start with all relays connected and receiving
const relays = new Map<string, ReqRelayState>();
for (let i = 0; i < 30; i++) {
relays.set(`wss://relay${i}.com`, {
url: `wss://relay${i}.com`,
connectionState: "disconnected", // All disconnected
subscriptionState: "eose",
eventCount: 5, // Had events before
});
}
const state = deriveOverallState(relays, true, true, queryStartedAt);
// Should be OFFLINE not LIVE
expect(state.status).toBe("offline");
expect(state.connectedCount).toBe(0);
expect(state.totalRelays).toBe(30);
expect(state.hasReceivedEvents).toBe(true);
});
it("Scenario 3: Mixed success/failure", () => {
const relays = new Map<string, ReqRelayState>();
// 10 succeed with EOSE
for (let i = 0; i < 10; i++) {
relays.set(`wss://success${i}.com`, {
url: `wss://success${i}.com`,
connectionState: "connected",
subscriptionState: "eose",
eventCount: 10,
});
}
// 15 disconnect
for (let i = 0; i < 15; i++) {
relays.set(`wss://disconnect${i}.com`, {
url: `wss://disconnect${i}.com`,
connectionState: "disconnected",
subscriptionState: "waiting",
eventCount: 0,
});
}
// 5 error
for (let i = 0; i < 5; i++) {
relays.set(`wss://error${i}.com`, {
url: `wss://error${i}.com`,
connectionState: "error",
subscriptionState: "error",
eventCount: 0,
});
}
const state = deriveOverallState(relays, true, true, queryStartedAt);
expect(state.status).toBe("partial");
expect(state.totalRelays).toBe(30);
expect(state.connectedCount).toBe(10);
expect(state.disconnectedCount).toBe(15);
expect(state.errorCount).toBe(5);
});
it("NEW: All relays disconnect before EOSE, no events (streaming)", () => {
// THE CRITICAL BUG: Stuck in LOADING when all relays disconnect
const relays = new Map<string, ReqRelayState>([
[
"wss://relay1.com",
{
url: "wss://relay1.com",
connectionState: "disconnected",
subscriptionState: "waiting", // Never got to receiving/eose
eventCount: 0,
},
],
[
"wss://relay2.com",
{
url: "wss://relay2.com",
connectionState: "disconnected",
subscriptionState: "waiting",
eventCount: 0,
},
],
]);
const state = deriveOverallState(relays, false, true, queryStartedAt);
// Should be FAILED, not LOADING
expect(state.status).toBe("failed");
expect(state.connectedCount).toBe(0);
expect(state.hasReceivedEvents).toBe(false);
});
it("NEW: All relays disconnect before EOSE, with events (streaming)", () => {
// Relays sent some events then disconnected before EOSE
const relays = new Map<string, ReqRelayState>([
[
"wss://relay1.com",
{
url: "wss://relay1.com",
connectionState: "disconnected",
subscriptionState: "receiving", // Was receiving
eventCount: 5,
},
],
[
"wss://relay2.com",
{
url: "wss://relay2.com",
connectionState: "disconnected",
subscriptionState: "receiving",
eventCount: 3,
},
],
]);
const state = deriveOverallState(relays, false, true, queryStartedAt);
// Should be OFFLINE (had events but all disconnected)
expect(state.status).toBe("offline");
expect(state.connectedCount).toBe(0);
expect(state.hasReceivedEvents).toBe(true);
});
it("NEW: All relays disconnect before EOSE, with events (non-streaming)", () => {
// Same as above but non-streaming
const relays = new Map<string, ReqRelayState>([
[
"wss://relay1.com",
{
url: "wss://relay1.com",
connectionState: "disconnected",
subscriptionState: "receiving",
eventCount: 5,
},
],
]);
const state = deriveOverallState(relays, false, false, queryStartedAt);
// Should be CLOSED (non-streaming completes)
expect(state.status).toBe("closed");
expect(state.hasReceivedEvents).toBe(true);
});
it("NEW: Some relays EOSE, others disconnect before EOSE", () => {
// Partial success before overall EOSE
const relays = new Map<string, ReqRelayState>([
[
"wss://relay1.com",
{
url: "wss://relay1.com",
connectionState: "connected",
subscriptionState: "eose",
eventCount: 10,
},
],
[
"wss://relay2.com",
{
url: "wss://relay2.com",
connectionState: "disconnected",
subscriptionState: "receiving",
eventCount: 3,
},
],
[
"wss://relay3.com",
{
url: "wss://relay3.com",
connectionState: "error",
subscriptionState: "error",
eventCount: 0,
},
],
]);
const state = deriveOverallState(relays, false, true, queryStartedAt);
// Should be PARTIAL (some succeeded, some failed, but not all terminal)
expect(state.status).toBe("partial");
expect(state.connectedCount).toBe(1);
expect(state.eoseCount).toBe(1);
});
it("NEW: Mix of EOSE and errors, all terminal", () => {
const relays = new Map<string, ReqRelayState>([
[
"wss://relay1.com",
{
url: "wss://relay1.com",
connectionState: "connected",
subscriptionState: "eose",
eventCount: 10,
},
],
[
"wss://relay2.com",
{
url: "wss://relay2.com",
connectionState: "error",
subscriptionState: "error",
eventCount: 0,
},
],
]);
const state = deriveOverallState(relays, false, true, queryStartedAt);
// All terminal (eose + error), should be PARTIAL
expect(state.status).toBe("partial");
expect(state.connectedCount).toBe(1);
});
});
});
describe("getStatusText", () => {
const baseState = {
totalRelays: 5,
connectedCount: 3,
receivingCount: 2,
eoseCount: 1,
errorCount: 0,
disconnectedCount: 0,
hasReceivedEvents: true,
hasActiveRelays: true,
allRelaysFailed: false,
queryStartedAt: Date.now(),
};
it("should return correct text for each status", () => {
expect(getStatusText({ ...baseState, status: "discovering" })).toBe(
"DISCOVERING",
);
expect(getStatusText({ ...baseState, status: "connecting" })).toBe(
"CONNECTING",
);
expect(getStatusText({ ...baseState, status: "loading" })).toBe("LOADING");
expect(getStatusText({ ...baseState, status: "live" })).toBe("LIVE");
expect(getStatusText({ ...baseState, status: "partial" })).toBe("PARTIAL");
expect(getStatusText({ ...baseState, status: "offline" })).toBe("OFFLINE");
expect(getStatusText({ ...baseState, status: "closed" })).toBe("CLOSED");
expect(getStatusText({ ...baseState, status: "failed" })).toBe("FAILED");
});
});
describe("getStatusTooltip", () => {
const baseState = {
totalRelays: 5,
connectedCount: 3,
receivingCount: 2,
eoseCount: 1,
errorCount: 0,
disconnectedCount: 0,
hasReceivedEvents: true,
hasActiveRelays: true,
allRelaysFailed: false,
queryStartedAt: Date.now(),
};
it("should provide detailed tooltips", () => {
const discovering = getStatusTooltip({
...baseState,
status: "discovering",
});
expect(discovering).toContain("NIP-65");
const loading = getStatusTooltip({ ...baseState, status: "loading" });
expect(loading).toContain("3/5");
const live = getStatusTooltip({ ...baseState, status: "live" });
expect(live).toContain("Streaming");
expect(live).toContain("3/5");
const offline = getStatusTooltip({ ...baseState, status: "offline" });
expect(offline).toContain("disconnected");
});
});
describe("getStatusColor", () => {
it("should return correct colors for each status", () => {
expect(getStatusColor("discovering")).toBe("text-yellow-500");
expect(getStatusColor("connecting")).toBe("text-yellow-500");
expect(getStatusColor("loading")).toBe("text-yellow-500");
expect(getStatusColor("live")).toBe("text-green-500");
expect(getStatusColor("partial")).toBe("text-yellow-500");
expect(getStatusColor("closed")).toBe("text-muted-foreground");
expect(getStatusColor("offline")).toBe("text-red-500");
expect(getStatusColor("failed")).toBe("text-red-500");
});
});
describe("shouldAnimate", () => {
it("should animate active states", () => {
expect(shouldAnimate("discovering")).toBe(true);
expect(shouldAnimate("connecting")).toBe(true);
expect(shouldAnimate("loading")).toBe(true);
expect(shouldAnimate("live")).toBe(true);
});
it("should not animate terminal states", () => {
expect(shouldAnimate("partial")).toBe(false);
expect(shouldAnimate("closed")).toBe(false);
expect(shouldAnimate("offline")).toBe(false);
expect(shouldAnimate("failed")).toBe(false);
});
});
describe("getRelayStateBadge", () => {
it("should return receiving badge", () => {
const badge = getRelayStateBadge({
url: "wss://relay.com",
connectionState: "connected",
subscriptionState: "receiving",
eventCount: 5,
});
expect(badge?.text).toBe("RECEIVING");
expect(badge?.color).toBe("text-green-500");
});
it("should return eose badge", () => {
const badge = getRelayStateBadge({
url: "wss://relay.com",
connectionState: "connected",
subscriptionState: "eose",
eventCount: 10,
});
expect(badge?.text).toBe("EOSE");
expect(badge?.color).toBe("text-blue-500");
});
it("should return error badge", () => {
const badge = getRelayStateBadge({
url: "wss://relay.com",
connectionState: "error",
subscriptionState: "error",
eventCount: 0,
});
expect(badge?.text).toBe("ERROR");
expect(badge?.color).toBe("text-red-500");
});
it("should return offline badge for disconnected", () => {
const badge = getRelayStateBadge({
url: "wss://relay.com",
connectionState: "disconnected",
subscriptionState: "waiting",
eventCount: 0,
});
expect(badge?.text).toBe("OFFLINE");
expect(badge?.color).toBe("text-muted-foreground");
});
it("should return null for connected waiting state", () => {
const badge = getRelayStateBadge({
url: "wss://relay.com",
connectionState: "connected",
subscriptionState: "waiting",
eventCount: 0,
});
expect(badge).toBeNull();
});
});

View File

@@ -0,0 +1,268 @@
import type {
ReqRelayState,
ReqOverallState,
ReqOverallStatus,
} from "@/types/req-state";
/**
* Derive overall query status from individual relay states
*
* This function implements the core state machine logic that determines
* the overall status of a REQ subscription based on the states of individual
* relays. It handles edge cases like all-relays-disconnected, partial failures,
* and distinguishes between CLOSED and OFFLINE states.
*
* @param relayStates - Map of relay URLs to their current states
* @param overallEoseReceived - Whether the group subscription emitted EOSE
* @param isStreaming - Whether this is a streaming subscription (stream=true)
* @param queryStartedAt - Timestamp when the query started
* @returns Aggregated state for the entire query
*/
export function deriveOverallState(
relayStates: Map<string, ReqRelayState>,
overallEoseReceived: boolean,
isStreaming: boolean,
queryStartedAt: number,
): ReqOverallState {
const states = Array.from(relayStates.values());
// Count relay states
const totalRelays = states.length;
const connectedCount = states.filter(
(s) => s.connectionState === "connected",
).length;
const receivingCount = states.filter(
(s) => s.subscriptionState === "receiving",
).length;
const eoseCount = states.filter((s) => s.subscriptionState === "eose").length;
const errorCount = states.filter((s) => s.connectionState === "error").length;
const disconnectedCount = states.filter(
(s) => s.connectionState === "disconnected",
).length;
// Calculate flags
const hasReceivedEvents = states.some((s) => s.eventCount > 0);
const hasActiveRelays = connectedCount > 0;
const allRelaysFailed = totalRelays > 0 && errorCount === totalRelays;
const allDisconnected =
totalRelays > 0 && disconnectedCount + errorCount === totalRelays;
// Timing
const firstEventAt = states
.map((s) => s.firstEventAt)
.filter((t): t is number => t !== undefined)
.sort((a, b) => a - b)[0];
const allEoseAt = overallEoseReceived ? Date.now() : undefined;
// Check if all relays are in terminal states (won't make further progress)
const allRelaysTerminal = states.every(
(s) =>
s.subscriptionState === "eose" ||
s.connectionState === "error" ||
s.connectionState === "disconnected",
);
// Derive status based on relay states and flags
const status: ReqOverallStatus = (() => {
// No relays selected yet (NIP-65 discovery in progress)
if (totalRelays === 0) {
return "discovering";
}
// All relays failed to connect, no events received
if (allRelaysFailed && !hasReceivedEvents) {
return "failed";
}
// All relays are in terminal states (done trying)
// This handles the case where relays disconnect before EOSE
if (allRelaysTerminal && !overallEoseReceived) {
if (!hasReceivedEvents) {
// All relays gave up before sending events
return "failed";
}
if (!hasActiveRelays) {
// Received events but all relays disconnected before EOSE
if (isStreaming) {
return "offline"; // Was trying to stream, now offline
} else {
return "closed"; // Non-streaming query, relays closed
}
}
// Some relays still active but all others terminated
// This is a partial success scenario
return "partial";
}
// No relays connected and no events received yet
if (!hasActiveRelays && !hasReceivedEvents) {
return "connecting";
}
// Had events and EOSE, but all relays disconnected now
if (allDisconnected && hasReceivedEvents && overallEoseReceived) {
if (isStreaming) {
return "offline"; // Was live, now offline
} else {
return "closed"; // Completed and closed (expected)
}
}
// EOSE not received yet, still loading initial data
if (!overallEoseReceived) {
return "loading";
}
// EOSE received, but some relays have issues (check this before "live")
if (overallEoseReceived && (errorCount > 0 || disconnectedCount > 0)) {
if (hasActiveRelays) {
return "partial"; // Some working, some not
} else {
return "offline"; // All disconnected after EOSE
}
}
// EOSE received, streaming mode, all relays healthy and connected
if (overallEoseReceived && isStreaming && hasActiveRelays) {
return "live";
}
// EOSE received, not streaming, all done
if (overallEoseReceived && !isStreaming) {
return "closed";
}
// Default fallback (should rarely hit this)
return "loading";
})();
return {
status,
totalRelays,
connectedCount,
receivingCount,
eoseCount,
errorCount,
disconnectedCount,
hasReceivedEvents,
hasActiveRelays,
allRelaysFailed,
queryStartedAt,
firstEventAt,
allEoseAt,
};
}
/**
* Get user-friendly status text for display
*/
export function getStatusText(state: ReqOverallState): string {
switch (state.status) {
case "discovering":
return "DISCOVERING";
case "connecting":
return "CONNECTING";
case "loading":
return "LOADING";
case "live":
return "LIVE";
case "partial":
return "PARTIAL";
case "offline":
return "OFFLINE";
case "closed":
return "CLOSED";
case "failed":
return "FAILED";
}
}
/**
* Get detailed status description for tooltips
*/
export function getStatusTooltip(state: ReqOverallState): string {
const { status, connectedCount, totalRelays, hasReceivedEvents } = state;
switch (status) {
case "discovering":
return "Selecting optimal relays using NIP-65";
case "connecting":
return `Connecting to ${totalRelays} relay${totalRelays !== 1 ? "s" : ""}...`;
case "loading":
return hasReceivedEvents
? `Loading events from ${connectedCount}/${totalRelays} relays`
: `Waiting for events from ${connectedCount}/${totalRelays} relays`;
case "live":
return `Streaming live events from ${connectedCount}/${totalRelays} relays`;
case "partial":
return `${connectedCount}/${totalRelays} relays active, some failed or disconnected`;
case "offline":
return "All relays disconnected. Showing cached results.";
case "closed":
return "Query completed, all relays closed";
case "failed":
return `Failed to connect to any of ${totalRelays} relays`;
}
}
/**
* Get status indicator color class
*/
export function getStatusColor(status: ReqOverallStatus): string {
switch (status) {
case "discovering":
case "connecting":
case "loading":
return "text-yellow-500";
case "live":
return "text-green-500";
case "partial":
return "text-yellow-500";
case "closed":
return "text-muted-foreground";
case "offline":
case "failed":
return "text-red-500";
}
}
/**
* Should the status indicator pulse/animate?
*/
export function shouldAnimate(status: ReqOverallStatus): boolean {
return ["discovering", "connecting", "loading", "live"].includes(status);
}
/**
* Get relay subscription state badge text
*/
export function getRelayStateBadge(
relay: ReqRelayState,
): { text: string; color: string } | null {
const { subscriptionState, connectionState } = relay;
// Prioritize subscription state
if (subscriptionState === "receiving") {
return { text: "RECEIVING", color: "text-green-500" };
}
if (subscriptionState === "eose") {
return { text: "EOSE", color: "text-blue-500" };
}
if (subscriptionState === "error") {
return { text: "ERROR", color: "text-red-500" };
}
// Show connection state if not connected
if (connectionState === "connecting") {
return { text: "CONNECTING", color: "text-yellow-500" };
}
if (connectionState === "error") {
return { text: "ERROR", color: "text-red-500" };
}
if (connectionState === "disconnected") {
return { text: "OFFLINE", color: "text-muted-foreground" };
}
return null;
}

View File

@@ -52,11 +52,12 @@ function extractRelayContext(event: NostrEvent): {
}
// Aggregator relays for better event discovery
// IMPORTANT: URLs must be normalized (trailing slash, lowercase) to match RelayStateManager keys
export const AGGREGATOR_RELAYS = [
"wss://relay.nostr.band",
"wss://nos.lol",
"wss://purplepag.es",
"wss://relay.primal.net",
"wss://relay.nostr.band/",
"wss://nos.lol/",
"wss://purplepag.es/",
"wss://relay.primal.net/",
];
// Base event loader (used internally)

91
src/types/req-state.ts Normal file
View File

@@ -0,0 +1,91 @@
/**
* Types for REQ subscription state tracking
*
* Provides per-relay and overall state for REQ subscriptions to enable
* accurate status indicators that distinguish between EOSE, disconnection,
* timeout, and error states.
*/
/**
* Connection state from RelayStateManager
*/
export type RelayConnectionState =
| "pending" // Not yet attempted
| "connecting" // Connection in progress
| "connected" // WebSocket connected
| "disconnected" // Disconnected (expected or unexpected)
| "error"; // Connection error
/**
* Subscription state specific to this REQ
*/
export type RelaySubscriptionState =
| "waiting" // Connected but no events yet
| "receiving" // Events being received
| "eose" // EOSE received (real or timeout)
| "error"; // Subscription error
/**
* Per-relay state for a single REQ subscription
*/
export interface ReqRelayState {
url: string;
// Connection state (from RelayStateManager)
connectionState: RelayConnectionState;
// Subscription state (tracked by enhanced hook)
subscriptionState: RelaySubscriptionState;
// Event tracking
eventCount: number;
firstEventAt?: number;
lastEventAt?: number;
// Timing
connectedAt?: number;
eoseAt?: number;
disconnectedAt?: number;
// Error handling
errorMessage?: string;
errorType?: "connection" | "protocol" | "timeout" | "auth";
}
/**
* Overall query state derived from individual relay states
*/
export type ReqOverallStatus =
| "discovering" // Selecting relays (NIP-65)
| "connecting" // Waiting for first relay to connect
| "loading" // Loading initial events
| "live" // Streaming after EOSE, relays connected
| "partial" // Some relays ok, some failed
| "closed" // All relays completed and closed
| "failed" // All relays failed
| "offline"; // All relays disconnected after being live
/**
* Aggregated state for the entire query
*/
export interface ReqOverallState {
status: ReqOverallStatus;
// Relay counts
totalRelays: number;
connectedCount: number;
receivingCount: number;
eoseCount: number;
errorCount: number;
disconnectedCount: number;
// Timing
queryStartedAt: number;
firstEventAt?: number;
allEoseAt?: number;
// Flags
hasReceivedEvents: boolean;
hasActiveRelays: boolean;
allRelaysFailed: boolean;
}