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