mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-10 07:27:23 +02:00
fix: add EOSE indicator, mute all icons, and fix relay URL normalization bug
Critical fixes for ReqViewer relay state accuracy:
1. **URL Normalization Fix** (fixes mismatch with CONN):
- Added normalizeRelayURL to normalize all relay URLs in finalRelays
- RelayStateManager normalizes URLs (adds trailing slash, lowercase) but
finalRelays did not, causing lookup failures in relayStates
- Now normalizedRelays is used for all state lookups and passed to
useReqTimelineEnhanced to ensure consistency
- This fixes the bug where ReqViewer showed different connected relay
counts than CONN viewer
2. **EOSE Indicator**:
- Added back EOSE indicator to relay dropdown (was removed in UI redesign)
- Shows subtle "EOSE" text when relay has sent End of Stored Events
- Includes tooltip explaining "End of stored events received"
3. **Muted Icons** (per user request for subtlety):
- Type indicators: blue-500/purple-500 → muted-foreground/60
- Strategy header icons: all → muted-foreground/60
- Section headers: green-500 → muted-foreground
- Connection icons: green-500/yellow-500/red-500 → /70 opacity variants
- Auth icons: same color reduction for consistency
- Maintains semantic meaning while reducing visual noise
All 639 tests passing.
This commit is contained in:
@@ -72,12 +72,12 @@ 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,
|
||||
getRelayStateBadge,
|
||||
} from "@/lib/req-state-machine";
|
||||
import { resolveFilterAliases, getTagValues } from "@/lib/nostr-utils";
|
||||
import { useNostrEvent } from "@/hooks/useNostrEvent";
|
||||
@@ -712,7 +712,6 @@ export default function ReqViewer({
|
||||
const {
|
||||
relays: selectedRelays,
|
||||
reasoning,
|
||||
isOptimized,
|
||||
phase: relaySelectionPhase,
|
||||
} = useOutboxRelays(resolvedFilter, outboxOptions);
|
||||
|
||||
@@ -733,6 +732,20 @@ export default function ReqViewer({
|
||||
return selectedRelays;
|
||||
}, [relays, relaySelectionPhase, selectedRelays]);
|
||||
|
||||
// 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;
|
||||
|
||||
@@ -746,7 +759,7 @@ export default function ReqViewer({
|
||||
} = useReqTimelineEnhanced(
|
||||
`req-${JSON.stringify(filter)}-${closeOnEose}`,
|
||||
resolvedFilter,
|
||||
finalRelays,
|
||||
normalizedRelays,
|
||||
{ limit: resolvedFilter.limit || 50, stream },
|
||||
);
|
||||
|
||||
@@ -978,16 +991,22 @@ export default function ReqViewer({
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="w-80 max-h-96 overflow-y-auto"
|
||||
className="w-96 max-h-96 overflow-y-auto"
|
||||
>
|
||||
{/* Relay Status - shows ALL queried relays (outbox + fallback or explicit) */}
|
||||
<div className="py-1">
|
||||
<div className="px-3 py-1 text-xs font-semibold text-muted-foreground">
|
||||
{!relays && isOptimized ? (
|
||||
{/* 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
|
||||
<>
|
||||
Relay Selection{" "}
|
||||
<span className="ml-1.5 font-normal">
|
||||
(
|
||||
<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) => {
|
||||
@@ -995,168 +1014,202 @@ export default function ReqViewer({
|
||||
addWindow("nip", { number: "65" });
|
||||
}}
|
||||
>
|
||||
NIP-65
|
||||
</button>
|
||||
)
|
||||
NIP-65 Outbox
|
||||
</button>{" "}
|
||||
({finalRelays.length} relays)
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
"Relay Status"
|
||||
// Fallback relays
|
||||
<>
|
||||
<Inbox className="size-3 text-muted-foreground/60" />
|
||||
<span>Fallback Relays ({finalRelays.length})</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Always show ALL relays from finalRelays (what's actually queried) */}
|
||||
<div className="px-3 py-1 space-y-1">
|
||||
{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;
|
||||
{(() => {
|
||||
// Group relays by connection status
|
||||
// Use normalizedRelays for lookups to match RelayStateManager's keys
|
||||
const onlineRelays: string[] = [];
|
||||
const disconnectedRelays: string[] = [];
|
||||
|
||||
// Find NIP-65 info for this relay (if using outbox)
|
||||
const nip65Info = reasoning?.find((r) => r.relay === url);
|
||||
normalizedRelays.forEach((url) => {
|
||||
const globalState = relayStates[url];
|
||||
const isConnected =
|
||||
globalState?.connectionState === "connected";
|
||||
|
||||
// Determine relay type
|
||||
const relayType = relays
|
||||
? "explicit" // Explicitly specified relays
|
||||
: nip65Info && !nip65Info.isFallback
|
||||
? "outbox" // NIP-65 outbox relay
|
||||
: "fallback"; // Fallback relay
|
||||
if (isConnected) {
|
||||
onlineRelays.push(url);
|
||||
} else {
|
||||
disconnectedRelays.push(url);
|
||||
}
|
||||
});
|
||||
|
||||
// Type indicator icon
|
||||
const typeIcon = {
|
||||
explicit: (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<LinkIcon className="size-3 text-blue-500" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Explicit relay</TooltipContent>
|
||||
</Tooltip>
|
||||
),
|
||||
outbox: (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Sparkles className="size-3 text-purple-500" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>NIP-65 Outbox relay</TooltipContent>
|
||||
</Tooltip>
|
||||
),
|
||||
fallback: (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Inbox className="size-3 text-muted-foreground/60" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Fallback relay</TooltipContent>
|
||||
</Tooltip>
|
||||
),
|
||||
}[relayType];
|
||||
const renderRelay = (url: string) => {
|
||||
const globalState = relayStates[url];
|
||||
const reqState = reqRelayStates.get(url);
|
||||
const connIcon = getConnectionIcon(globalState);
|
||||
const authIcon = getAuthIcon(globalState);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={url}
|
||||
className="flex items-center gap-2 text-xs py-0.5"
|
||||
>
|
||||
<RelayLink
|
||||
url={url}
|
||||
showInboxOutbox={false}
|
||||
className="flex-1 truncate font-mono text-foreground/80"
|
||||
/>
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0 text-muted-foreground">
|
||||
{/* Relay type indicator */}
|
||||
{typeIcon}
|
||||
// Find NIP-65 info for this relay (if using outbox)
|
||||
const nip65Info = reasoning?.find((r) => r.relay === url);
|
||||
|
||||
{/* Event count */}
|
||||
{reqState && reqState.eventCount > 0 && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<FileText className="size-3" />
|
||||
<span className="text-[10px]">
|
||||
{reqState.eventCount}
|
||||
</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{reqState.eventCount} events received
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
// Determine relay type
|
||||
const relayType = relays
|
||||
? "explicit"
|
||||
: nip65Info && !nip65Info.isFallback
|
||||
? "outbox"
|
||||
: "fallback";
|
||||
|
||||
{/* Subscription state badge */}
|
||||
{badge && (
|
||||
<span className={`text-[10px] ${badge.color}`}>
|
||||
{badge.text}
|
||||
</span>
|
||||
)}
|
||||
// Type indicator icon (smaller, on left)
|
||||
const typeIcon = {
|
||||
explicit: (
|
||||
<LinkIcon className="size-3 text-muted-foreground/60 flex-shrink-0" />
|
||||
),
|
||||
outbox: (
|
||||
<Sparkles className="size-3 text-muted-foreground/60 flex-shrink-0" />
|
||||
),
|
||||
fallback: (
|
||||
<Inbox className="size-3 text-muted-foreground/60 flex-shrink-0" />
|
||||
),
|
||||
}[relayType];
|
||||
|
||||
{/* NIP-65 inbox/outbox indicators (if available) */}
|
||||
{nip65Info && nip65Info.readers.length > 0 && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<Mail className="w-3 h-3" />
|
||||
<span className="text-[10px]">
|
||||
{nip65Info.readers.length}
|
||||
</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Inbox relay for {nip65Info.readers.length}{" "}
|
||||
author
|
||||
{nip65Info.readers.length !== 1 ? "s" : ""}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{nip65Info && nip65Info.writers.length > 0 && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<Send className="w-3 h-3" />
|
||||
<span className="text-[10px]">
|
||||
{nip65Info.writers.length}
|
||||
</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Outbox relay for {nip65Info.writers.length}{" "}
|
||||
author
|
||||
{nip65Info.writers.length !== 1 ? "s" : ""}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
return (
|
||||
<div
|
||||
key={url}
|
||||
className="flex items-center gap-2 text-xs py-1 px-3 hover:bg-accent/5"
|
||||
>
|
||||
{/* Type icon on left */}
|
||||
{typeIcon}
|
||||
|
||||
{/* Auth icon */}
|
||||
{authIcon && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="cursor-help">
|
||||
{authIcon.icon}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{authIcon.label}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{/* Relay URL */}
|
||||
<RelayLink
|
||||
url={url}
|
||||
showInboxOutbox={false}
|
||||
className="flex-1 min-w-0 truncate font-mono text-foreground/80"
|
||||
/>
|
||||
|
||||
{/* Connection icon */}
|
||||
{/* Right side: stats and status */}
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0 text-muted-foreground">
|
||||
{/* Event count */}
|
||||
{reqState && reqState.eventCount > 0 && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="cursor-help">{connIcon.icon}</div>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<FileText className="size-3" />
|
||||
<span className="text-[10px]">
|
||||
{reqState.eventCount}
|
||||
</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{connIcon.label}</p>
|
||||
{reqState.eventCount} events received
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* EOSE indicator */}
|
||||
{reqState && reqState.subscriptionState === "eose" && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
EOSE
|
||||
</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
End of stored events received
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* NIP-65 inbox/outbox indicators (if available) */}
|
||||
{nip65Info && nip65Info.readers.length > 0 && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<Mail className="w-3 h-3" />
|
||||
<span className="text-[10px]">
|
||||
{nip65Info.readers.length}
|
||||
</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Inbox for {nip65Info.readers.length} author
|
||||
{nip65Info.readers.length !== 1 ? "s" : ""}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{nip65Info && nip65Info.writers.length > 0 && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<Send className="w-3 h-3" />
|
||||
<span className="text-[10px]">
|
||||
{nip65Info.writers.length}
|
||||
</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Outbox for {nip65Info.writers.length} author
|
||||
{nip65Info.writers.length !== 1 ? "s" : ""}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Auth icon */}
|
||||
{authIcon && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="cursor-help">{authIcon.icon}</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{authIcon.label}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Connection icon */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="cursor-help">{connIcon.icon}</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{connIcon.label}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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>
|
||||
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
};
|
||||
@@ -54,27 +54,27 @@ export function getAuthIcon(relay: RelayState | undefined) {
|
||||
|
||||
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" />,
|
||||
icon: <Shield className="size-3 text-muted-foreground/60" />,
|
||||
label: "No Authentication",
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user