mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-06-06 10:41:21 +02:00
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:
@@ -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>
|
||||
|
||||
|
||||
340
src/hooks/useReqTimelineEnhanced.ts
Normal file
340
src/hooks/useReqTimelineEnhanced.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
681
src/lib/req-state-machine.test.ts
Normal file
681
src/lib/req-state-machine.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
268
src/lib/req-state-machine.ts
Normal file
268
src/lib/req-state-machine.ts
Normal 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;
|
||||
}
|
||||
@@ -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
91
src/types/req-state.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user