diff --git a/src/components/ReqViewer.tsx b/src/components/ReqViewer.tsx index ed78813..df8d1a4 100644 --- a/src/components/ReqViewer.tsx +++ b/src/components/ReqViewer.tsx @@ -18,7 +18,7 @@ import { Send, } 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 +69,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 { + getStatusText, + getStatusTooltip, + getStatusColor, + shouldAnimate, + getRelayStateBadge, +} from "@/lib/req-state-machine"; import { resolveFilterAliases, getTagValues } from "@/lib/nostr-utils"; import { useNostrEvent } from "@/hooks/useNostrEvent"; import { MemoizedCompactEventRow } from "./nostr/CompactEventRow"; @@ -739,7 +746,7 @@ export default function ReqViewer({ // 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, @@ -915,48 +922,23 @@ export default function ReqViewer({ {/* Compact Header */}
{/* Left: Status Indicator */} -
- - - {relaySelectionPhase === "discovering" - ? "DISCOVERING RELAYS" - : relaySelectionPhase === "selecting" - ? "SELECTING RELAYS" - : loading && eoseReceived && stream - ? "LIVE" - : loading && !eoseReceived && events.length === 0 - ? "CONNECTING" - : loading && !eoseReceived - ? "LOADING" - : eoseReceived - ? "CLOSED" - : "CONNECTING"} - -
+ + +
+ + + {getStatusText(overallState)} + +
+
+ +

{getStatusTooltip(overallState)}

+
+
{/* Right: Stats */}
@@ -991,7 +973,7 @@ export default function ReqViewer({ @@ -999,58 +981,9 @@ export default function ReqViewer({ align="end" className="w-80 max-h-96 overflow-y-auto" > - {/* Connection Status */} -
-
- Connection Status -
- {relayStatesForReq.map(({ url, state }) => { - const connIcon = getConnectionIcon(state); - const authIcon = getAuthIcon(state); - - return ( - - -
e.stopPropagation()} - > - {authIcon && ( - - -
{authIcon.icon}
-
- -

{authIcon.label}

-
-
- )} - - - -
{connIcon.icon}
-
- -

{connIcon.label}

-
-
-
-
- ); - })} -
- - {/* Relay Selection */} - {!relays && reasoning && reasoning.length > 0 && ( + {/* Relay Status (condensed: connection + subscription + NIP-65) */} + {!relays && reasoning && reasoning.length > 0 ? ( + /* NIP-65 Relay Selection with status */
Relay Selection @@ -1071,38 +1004,182 @@ export default function ReqViewer({ )}
- {/* Flat list of relays with icons and counts */} + {/* Relay list with connection, subscription, and NIP-65 info */}
- {reasoning.map((r, i) => ( -
- -
- {r.readers.length > 0 && ( -
- - {r.readers.length} -
- )} - {r.writers.length > 0 && ( -
- - {r.writers.length} -
- )} - {r.isFallback && ( - - fallback - - )} + {reasoning.map((r, i) => { + const globalState = relayStates[r.relay]; + const reqState = reqRelayStates.get(r.relay); + const connIcon = getConnectionIcon(globalState); + const authIcon = getAuthIcon(globalState); + const badge = reqState ? getRelayStateBadge(reqState) : null; + + return ( +
+ +
+ {/* Event count */} + {reqState && reqState.eventCount > 0 && ( + + +
+ + {reqState.eventCount} +
+
+ + {reqState.eventCount} events received + +
+ )} + + {/* Subscription state badge */} + {badge && ( + + {badge.text} + + )} + + {/* NIP-65 inbox/outbox indicators */} + {r.readers.length > 0 && ( + + +
+ + {r.readers.length} +
+
+ + Inbox relay for {r.readers.length} author{r.readers.length !== 1 ? 's' : ''} + +
+ )} + {r.writers.length > 0 && ( + + +
+ + {r.writers.length} +
+
+ + Outbox relay for {r.writers.length} author{r.writers.length !== 1 ? 's' : ''} + +
+ )} + + {/* Fallback indicator */} + {r.isFallback && ( + + fallback + + )} + + {/* Auth icon */} + {authIcon && ( + + +
{authIcon.icon}
+
+ +

{authIcon.label}

+
+
+ )} + + {/* Connection icon */} + + +
{connIcon.icon}
+
+ +

{connIcon.label}

+
+
+
-
- ))} + ); + })} +
+
+ ) : ( + /* Explicit relays: show simple status list */ +
+
+ Relay Status +
+
+ {finalRelays.map((url) => { + const globalState = relayStates[url]; + const reqState = reqRelayStates.get(url); + const connIcon = getConnectionIcon(globalState); + const authIcon = getAuthIcon(globalState); + const badge = reqState ? getRelayStateBadge(reqState) : null; + + return ( +
+ +
+ {/* Event count */} + {reqState && reqState.eventCount > 0 && ( + + +
+ + {reqState.eventCount} +
+
+ + {reqState.eventCount} events received + +
+ )} + + {/* Subscription state badge */} + {badge && ( + + {badge.text} + + )} + + {/* Auth icon */} + {authIcon && ( + + +
{authIcon.icon}
+
+ +

{authIcon.label}

+
+
+ )} + + {/* Connection icon */} + + +
{connIcon.icon}
+
+ +

{connIcon.label}

+
+
+
+
+ ); + })}
)} diff --git a/src/hooks/useReqTimelineEnhanced.ts b/src/hooks/useReqTimelineEnhanced.ts new file mode 100644 index 0000000..33eac05 --- /dev/null +++ b/src/hooks/useReqTimelineEnhanced.ts @@ -0,0 +1,265 @@ +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; + 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(null); + const [eoseReceived, setEoseReceived] = useState(false); + const [eventsMap, setEventsMap] = useState>( + new Map(), + ); + + // Enhanced: Per-relay state tracking + const [relayStates, setRelayStates] = useState>( + new Map(), + ); + const queryStartedAt = useRef(Date.now()); + + // 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(); + 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(() => { + setRelayStates((prev) => { + const next = new Map(prev); + let changed = false; + + for (const [url, state] of prev) { + const globalState = globalRelayStates[url]; + if ( + globalState && + globalState.connectionState !== state.connectionState + ) { + next.set(url, { + ...state, + connectionState: globalState.connectionState as any, + connectedAt: globalState.lastConnected, + disconnectedAt: globalState.lastDisconnected, + }); + changed = true; + } + } + + return changed ? next : prev; + }); + }, [globalRelayStates]); + + // 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, + })); + + const observable = pool.subscription(relays, filtersWithLimit, { + retries: 5, + reconnect: 5, + resubscribe: true, + eventStore, + }); + + const subscription = observable.subscribe( + (response) => { + // Response can be an event or 'EOSE' string + if (typeof response === "string") { + console.log("REQ Enhanced: EOSE received"); + setEoseReceived(true); + if (!stream) { + setLoading(false); + } + + // Mark all connected relays as having received EOSE + // Note: We can't tell which specific relay sent EOSE due to + // applesauce-relay's catchError bug that converts errors to EOSE. + // We mark all connected relays as a best-effort approximation. + setRelayStates((prev) => { + const next = new Map(prev); + let changed = false; + + for (const [url, state] of prev) { + if ( + state.connectionState === "connected" && + state.subscriptionState !== "eose" + ) { + next.set(url, { + ...state, + subscriptionState: "eose", + eoseAt: Date.now(), + }); + changed = true; + } + } + + return changed ? next : prev; + }); + } else if (isNostrEvent(response)) { + // Event received - store and track per relay + const event = response as NostrEvent & { _relay?: string }; + const relayUrl = event._relay; + + // 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 + if (relayUrl) { + setRelayStates((prev) => { + const state = prev.get(relayUrl); + if (!state) return prev; + + const now = Date.now(); + const next = new Map(prev); + next.set(relayUrl, { + ...state, + subscriptionState: "receiving", + eventCount: state.eventCount + 1, + firstEventAt: state.firstEventAt ?? now, + lastEventAt: now, + }); + return next; + }); + } + } else { + console.warn("REQ Enhanced: Unexpected response type:", response); + } + }, + (err: Error) => { + console.error("REQ Enhanced: Error", err); + setError(err); + setLoading(false); + }, + () => { + // Observable completed + if (!stream) { + setLoading(false); + } + }, + ); + + return () => { + subscription.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, + }; +} diff --git a/src/lib/req-state-machine.test.ts b/src/lib/req-state-machine.test.ts new file mode 100644 index 0000000..b046cb8 --- /dev/null +++ b/src/lib/req-state-machine.test.ts @@ -0,0 +1,539 @@ +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([ + [ + "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([ + [ + "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([ + [ + "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([ + [ + "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([ + [ + "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([ + [ + "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([ + [ + "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([ + [ + "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([ + [ + "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([ + [ + "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([ + [ + "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([ + [ + "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([ + [ + "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(); + 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(); + 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(); + // 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); + }); + }); +}); + +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(); + }); +}); diff --git a/src/lib/req-state-machine.ts b/src/lib/req-state-machine.ts new file mode 100644 index 0000000..43fd035 --- /dev/null +++ b/src/lib/req-state-machine.ts @@ -0,0 +1,244 @@ +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, + 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; + + // 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"; + } + + // 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; +} diff --git a/src/types/req-state.ts b/src/types/req-state.ts new file mode 100644 index 0000000..dae05bb --- /dev/null +++ b/src/types/req-state.ts @@ -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; +}