mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-08 22:47:02 +02:00
ui: improve relays view in req viewer
This commit is contained in:
@@ -5,16 +5,33 @@ import {
|
||||
Radio,
|
||||
FileText,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
Loader2,
|
||||
XCircle,
|
||||
ShieldCheck,
|
||||
ShieldAlert,
|
||||
ShieldX,
|
||||
ShieldQuestion,
|
||||
Shield,
|
||||
Filter as FilterIcon,
|
||||
Circle,
|
||||
} from "lucide-react";
|
||||
import { Virtuoso } from "react-virtuoso";
|
||||
import { useReqTimeline } from "@/hooks/useReqTimeline";
|
||||
import { useGrimoire } from "@/core/state";
|
||||
import { useProfile } from "@/hooks/useProfile";
|
||||
import { useRelayState } from "@/hooks/useRelayState";
|
||||
import { FeedEvent } from "./nostr/Feed";
|
||||
import { KindBadge } from "./KindBadge";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "./ui/dropdown-menu";
|
||||
import { RelayLink } from "./nostr/RelayLink";
|
||||
import type { NostrFilter } from "@/types/nostr";
|
||||
import type { RelayState } from "@/types/relay-state";
|
||||
import {
|
||||
formatEventIds,
|
||||
formatDTags,
|
||||
@@ -30,6 +47,70 @@ const MemoizedFeedEvent = memo(
|
||||
(prev, next) => prev.event.id === next.event.id,
|
||||
);
|
||||
|
||||
// Helper functions for relay status icons
|
||||
function getConnectionIcon(relay: RelayState | undefined) {
|
||||
if (!relay) {
|
||||
return {
|
||||
icon: <WifiOff className="size-3 text-muted-foreground" />,
|
||||
label: "Unknown",
|
||||
};
|
||||
}
|
||||
|
||||
const iconMap = {
|
||||
connected: {
|
||||
icon: <Wifi className="size-3 text-green-500" />,
|
||||
label: "Connected",
|
||||
},
|
||||
connecting: {
|
||||
icon: <Loader2 className="size-3 text-yellow-500 animate-spin" />,
|
||||
label: "Connecting",
|
||||
},
|
||||
disconnected: {
|
||||
icon: <WifiOff className="size-3 text-muted-foreground" />,
|
||||
label: "Disconnected",
|
||||
},
|
||||
error: {
|
||||
icon: <XCircle className="size-3 text-red-500" />,
|
||||
label: "Connection Error",
|
||||
},
|
||||
};
|
||||
return iconMap[relay.connectionState];
|
||||
}
|
||||
|
||||
function getAuthIcon(relay: RelayState | undefined) {
|
||||
if (!relay || relay.authStatus === "none") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const iconMap = {
|
||||
authenticated: {
|
||||
icon: <ShieldCheck className="size-3 text-green-500" />,
|
||||
label: "Authenticated",
|
||||
},
|
||||
challenge_received: {
|
||||
icon: <ShieldQuestion className="size-3 text-yellow-500" />,
|
||||
label: "Challenge Received",
|
||||
},
|
||||
authenticating: {
|
||||
icon: <Loader2 className="size-3 text-yellow-500 animate-spin" />,
|
||||
label: "Authenticating",
|
||||
},
|
||||
failed: {
|
||||
icon: <ShieldX className="size-3 text-red-500" />,
|
||||
label: "Authentication Failed",
|
||||
},
|
||||
rejected: {
|
||||
icon: <ShieldAlert className="size-3 text-muted-foreground" />,
|
||||
label: "Authentication Rejected",
|
||||
},
|
||||
none: {
|
||||
icon: <Shield className="size-3 text-muted-foreground" />,
|
||||
label: "No Authentication",
|
||||
},
|
||||
};
|
||||
return iconMap[relay.authStatus] || iconMap.none;
|
||||
}
|
||||
|
||||
interface ReqViewerProps {
|
||||
filter: NostrFilter;
|
||||
relays?: string[];
|
||||
@@ -220,6 +301,7 @@ export default function ReqViewer({
|
||||
nip05PTags,
|
||||
}: ReqViewerProps) {
|
||||
const { state } = useGrimoire();
|
||||
const { relays: relayStates } = useRelayState();
|
||||
|
||||
// NIP-05 resolution already happened in argParser before window creation
|
||||
// The filter prop already contains resolved pubkeys
|
||||
@@ -232,6 +314,15 @@ export default function ReqViewer({
|
||||
? state.activeAccount.relays.inbox.map((r) => r.url)
|
||||
: ["wss://theforest.nostr1.com"]);
|
||||
|
||||
// Get relay state for each relay and calculate connected count
|
||||
const relayStatesForReq = defaultRelays.map((url) => ({
|
||||
url,
|
||||
state: relayStates[url],
|
||||
}));
|
||||
const connectedCount = relayStatesForReq.filter(
|
||||
(r) => r.state?.connectionState === "connected",
|
||||
).length;
|
||||
|
||||
// Streaming is the default behavior, closeOnEose inverts it
|
||||
const stream = !closeOnEose;
|
||||
|
||||
@@ -242,7 +333,6 @@ export default function ReqViewer({
|
||||
{ limit: filter.limit || 50, stream },
|
||||
);
|
||||
|
||||
const [showRelays, setShowRelays] = useState(false);
|
||||
const [showQuery, setShowQuery] = useState(false);
|
||||
|
||||
return (
|
||||
@@ -291,19 +381,62 @@ export default function ReqViewer({
|
||||
<span>{events.length}</span>
|
||||
</div>
|
||||
|
||||
{/* Relay Count (Clickable) */}
|
||||
<button
|
||||
onClick={() => setShowRelays(!showRelays)}
|
||||
className="flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{showRelays ? (
|
||||
<ChevronDown className="size-3" />
|
||||
) : (
|
||||
<ChevronRight className="size-3" />
|
||||
)}
|
||||
<Wifi className="size-3" />
|
||||
<span>{defaultRelays.length}</span>
|
||||
</button>
|
||||
{/* Relay Count (Dropdown) */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors">
|
||||
<Wifi className="size-3" />
|
||||
<span>
|
||||
{connectedCount}/{defaultRelays.length}
|
||||
</span>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-80">
|
||||
{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>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Query (Clickable) */}
|
||||
<button
|
||||
@@ -320,22 +453,6 @@ export default function ReqViewer({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expandable Relays */}
|
||||
{showRelays && (
|
||||
<div className="border-b border-border px-4 py-2 bg-muted">
|
||||
<div className="flex flex-col gap-2">
|
||||
{defaultRelays.map((relay) => (
|
||||
<div key={relay} className="flex items-center gap-2">
|
||||
<Circle className="size-2 fill-green-500 text-green-500" />
|
||||
<span className="text-xs font-mono text-muted-foreground">
|
||||
{relay}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Expandable Query */}
|
||||
{showQuery && (
|
||||
<QueryDropdown
|
||||
|
||||
Reference in New Issue
Block a user