wip: relay pool view and auth

This commit is contained in:
Alejandro Gómez
2025-12-12 23:26:57 +01:00
parent 53c07fb4a8
commit 6568daf944
24 changed files with 1556 additions and 50 deletions

View File

@@ -16,5 +16,6 @@
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
}
},
"iconLibrary": "lucide"
}

238
package-lock.json generated
View File

@@ -9,11 +9,13 @@
"version": "0.1.0",
"dependencies": {
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tooltip": "^1.2.8",
"applesauce-accounts": "^4.1.0",
"applesauce-content": "^4.0.0",
"applesauce-core": "latest",
@@ -36,6 +38,7 @@
"react-virtuoso": "^4.17.0",
"remark-gfm": "^4.0.1",
"rxjs": "^7.8.1",
"sonner": "^2.0.7",
"tailwind-merge": "^2.5.5"
},
"devDependencies": {
@@ -1693,6 +1696,77 @@
}
}
},
"node_modules/@radix-ui/react-checkbox": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz",
"integrity": "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-use-previous": "1.1.1",
"@radix-ui/react-use-size": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-primitive": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-collection": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
@@ -2619,6 +2693,81 @@
}
}
},
"node_modules/@radix-ui/react-tooltip": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz",
"integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-dismissable-layer": "1.1.11",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-popper": "1.2.8",
"@radix-ui/react-portal": "1.1.9",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-visually-hidden": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-primitive": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
@@ -2722,6 +2871,21 @@
}
}
},
"node_modules/@radix-ui/react-use-previous": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz",
"integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-rect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz",
@@ -2758,6 +2922,70 @@
}
}
},
"node_modules/@radix-ui/react-visually-hidden": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz",
"integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-visually-hidden/node_modules/@radix-ui/react-primitive": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-visually-hidden/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/rect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz",
@@ -8355,6 +8583,16 @@
"node": ">=18"
}
},
"node_modules/sonner": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",
"integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==",
"license": "MIT",
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc",
"react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",

View File

@@ -17,11 +17,13 @@
},
"dependencies": {
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tooltip": "^1.2.8",
"applesauce-accounts": "^4.1.0",
"applesauce-content": "^4.0.0",
"applesauce-core": "latest",
@@ -44,6 +46,7 @@
"react-virtuoso": "^4.17.0",
"remark-gfm": "^4.0.1",
"rxjs": "^7.8.1",
"sonner": "^2.0.7",
"tailwind-merge": "^2.5.5"
},
"devDependencies": {

View File

@@ -0,0 +1,231 @@
import {
Wifi,
WifiOff,
Loader2,
ShieldCheck,
ShieldAlert,
ShieldX,
ShieldQuestion,
Shield,
XCircle,
Settings,
} from "lucide-react";
import { useRelayState } from "@/hooks/useRelayState";
import type { RelayState } from "@/types/relay-state";
import { RelayLink } from "./nostr/RelayLink";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "./ui/dropdown-menu";
/**
* CONN viewer - displays connection and auth status for all relays in the pool
*/
export default function ConnViewer() {
const { relays } = useRelayState();
const relayList = Object.values(relays);
// Group by connection state
const connectedRelays = relayList
.filter((r) => r.connectionState === "connected")
.sort((a, b) => a.url.localeCompare(b.url));
const disconnectedRelays = relayList
.filter((r) => r.connectionState !== "connected")
.sort((a, b) => a.url.localeCompare(b.url));
return (
<div className="h-full w-full flex flex-col bg-background text-foreground">
{/* Relay List */}
<div className="flex-1 overflow-y-auto">
{relayList.length === 0 && (
<div className="text-center text-muted-foreground font-mono text-sm p-4">
No relays in pool
</div>
)}
{/* Connected */}
{connectedRelays.length > 0 && (
<>
<div className="px-4 py-2 bg-muted/30 text-xs font-semibold text-muted-foreground">
Connected ({connectedRelays.length})
</div>
{connectedRelays.map((relay) => (
<RelayCard key={relay.url} relay={relay} />
))}
</>
)}
{/* Disconnected */}
{disconnectedRelays.length > 0 && (
<>
<div className="px-4 py-2 bg-muted/30 text-xs font-semibold text-muted-foreground">
Disconnected ({disconnectedRelays.length})
</div>
{disconnectedRelays.map((relay) => (
<RelayCard key={relay.url} relay={relay} />
))}
</>
)}
</div>
</div>
);
}
interface RelayCardProps {
relay: RelayState;
}
function RelayCard({ relay }: RelayCardProps) {
const { setAuthPreference } = useRelayState();
const connectionIcon = () => {
const iconMap = {
connected: {
icon: <Wifi className="size-4 text-green-500" />,
label: "Connected",
},
connecting: {
icon: <Loader2 className="size-4 text-yellow-500 animate-spin" />,
label: "Connecting",
},
disconnected: {
icon: <WifiOff className="size-4 text-muted-foreground" />,
label: "Disconnected",
},
error: {
icon: <XCircle className="size-4 text-red-500" />,
label: "Connection Error",
},
};
return iconMap[relay.connectionState];
};
const authIcon = () => {
const iconMap = {
authenticated: {
icon: <ShieldCheck className="size-4 text-green-500" />,
label: "Authenticated",
},
challenge_received: {
icon: <ShieldQuestion className="size-4 text-yellow-500" />,
label: "Challenge Received",
},
authenticating: {
icon: <Loader2 className="size-4 text-yellow-500 animate-spin" />,
label: "Authenticating",
},
failed: {
icon: <ShieldX className="size-4 text-red-500" />,
label: "Authentication Failed",
},
rejected: {
icon: <ShieldAlert className="size-4 text-muted-foreground" />,
label: "Authentication Rejected",
},
none: {
icon: <Shield className="size-4 text-muted-foreground" />,
label: "No Authentication",
},
};
return iconMap[relay.authStatus] || iconMap.none;
};
const connIcon = connectionIcon();
const auth = authIcon();
const currentPreference = relay.authPreference || "ask";
return (
<div className="border-b border-border">
<div className="px-4 py-2 flex flex-col gap-2">
{/* Main Row */}
<div className="flex items-center gap-3 justify-between">
<RelayLink
url={relay.url}
showInboxOutbox={false}
className="line-clamp-1 hover:bg-transparent hover:underline hover:decoration-dotted"
iconClassname="size-4"
urlClassname="text-sm"
/>
<div className="flex items-center gap-2">
{relay.authStatus !== "none" && (
<Tooltip>
<TooltipTrigger asChild>
<div className="cursor-help">{auth.icon}</div>
</TooltipTrigger>
<TooltipContent>
<p>{auth.label}</p>
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<div className="cursor-help">{connIcon.icon}</div>
</TooltipTrigger>
<TooltipContent>
<p>{connIcon.label}</p>
</TooltipContent>
</Tooltip>
{/* Auth Settings Dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="text-muted-foreground hover:text-foreground transition-colors">
<Settings className="size-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>
<RelayLink
url={relay.url}
className="pointer-events-none"
iconClassname="size-4"
urlClassname="text-sm"
/>
</DropdownMenuLabel>
<DropdownMenuLabel>
<div className="flex flex-row items-center gap-2">
<div className="cursor-help size-4">{connIcon.icon}</div>
<span className="text-sm">{connIcon.label}</span>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuLabel>
<div className="flex flex-row gap-2 items-center">
<ShieldQuestion className="size-4 text-muted-foreground" />
<span>Auth</span>
</div>
</DropdownMenuLabel>
<DropdownMenuRadioGroup
value={currentPreference}
onValueChange={async (value) => {
await setAuthPreference(
relay.url,
value as "always" | "never" | "ask",
);
}}
>
<DropdownMenuRadioItem value="ask">Ask</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="always">
Always
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="never">
Never
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
</div>
);
}

View File

@@ -2,6 +2,7 @@ import { useMemo } from "react";
import { WindowInstance } from "@/types/app";
import { useProfile } from "@/hooks/useProfile";
import { useNostrEvent } from "@/hooks/useNostrEvent";
import { useRelayState } from "@/hooks/useRelayState";
import { getKindName, getKindIcon } from "@/constants/kinds";
import { getNipTitle } from "@/constants/nips";
import {
@@ -182,6 +183,9 @@ export function useDynamicWindowTitle(window: WindowInstance): WindowTitleData {
function useDynamicTitle(window: WindowInstance): WindowTitleData {
const { appId, props, title: staticTitle } = window;
// Get relay state for conn viewer
const { relays } = useRelayState();
// Profile titles
const profilePubkey = appId === "profile" ? props.pubkey : null;
const profile = useProfile(profilePubkey || "");
@@ -381,6 +385,16 @@ function useDynamicTitle(window: WindowInstance): WindowTitleData {
return "Debug";
}, [appId]);
// Conn viewer title with connection count
const connTitle = useMemo(() => {
if (appId !== "conn") return null;
const relayList = Object.values(relays);
const connectedCount = relayList.filter(
(r) => r.connectionState === "connected",
).length;
return `Relay Pool (${connectedCount}/${relayList.length})`;
}, [appId, relays]);
// Generate final title data with icon and tooltip
return useMemo(() => {
let title: string;
@@ -450,6 +464,10 @@ function useDynamicTitle(window: WindowInstance): WindowTitleData {
title = debugTitle;
icon = getCommandIcon("debug");
tooltip = rawCommand;
} else if (connTitle) {
title = connTitle;
icon = getCommandIcon("conn");
tooltip = rawCommand;
} else {
title = staticTitle;
tooltip = rawCommand;
@@ -473,6 +491,7 @@ function useDynamicTitle(window: WindowInstance): WindowTitleData {
winTitle,
kindsTitle,
debugTitle,
connTitle,
staticTitle,
]);
}

View File

@@ -0,0 +1,240 @@
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { Key, X } from "lucide-react";
import { Button } from "./ui/button";
import { Checkbox } from "./ui/checkbox";
import { useRelayState } from "@/hooks/useRelayState";
import { RelayLink } from "./nostr/RelayLink";
interface AuthToastProps {
relayUrl: string;
challenge: string;
onAuthenticate: (remember: boolean) => Promise<void>;
onReject: (remember: boolean) => Promise<void>;
onDismiss: () => void;
}
function AuthToast({
relayUrl,
challenge,
onAuthenticate,
onReject,
onDismiss,
}: AuthToastProps) {
const [remember, setRemember] = useState(false);
const [loading, setLoading] = useState(false);
return (
<div className="bg-background border border-border rounded-lg shadow-lg p-4 min-w-[350px] max-w-[500px]">
<div className="flex items-start gap-3">
<Key className="size-5 text-yellow-500 flex-shrink-0 mt-0.5" />
<div className="flex-1 space-y-3">
<div>
<div className="font-semibold text-sm text-foreground mb-1">
Authentication Request
</div>
<RelayLink
url={relayUrl}
showInboxOutbox={false}
variant="prompt"
iconClassname="size-4"
urlClassname="text-sm"
/>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id={`remember-${challenge}`}
checked={remember}
onCheckedChange={(checked) => setRemember(checked === true)}
/>
<label
htmlFor={`remember-${challenge}`}
className="text-xs text-muted-foreground cursor-pointer"
>
Remember my choice
</label>
</div>
<div className="flex gap-2">
<Button
size="sm"
onClick={async () => {
setLoading(true);
try {
await onAuthenticate(remember);
} finally {
setLoading(false);
}
}}
disabled={loading}
className="flex-1 bg-green-500 hover:bg-green-600 text-white h-8"
>
Yes
</Button>
<Button
size="sm"
variant="outline"
onClick={async () => {
setLoading(true);
try {
await onReject(remember);
} finally {
setLoading(false);
}
}}
disabled={loading}
className="flex-1 h-8"
>
No
</Button>
</div>
</div>
<button
onClick={onDismiss}
disabled={loading}
className="text-muted-foreground hover:text-foreground transition-colors"
>
<X className="size-4" />
</button>
</div>
</div>
);
}
/**
* Global auth prompt using sonner toast - shows when any relay requests authentication
* Displays pending auth challenges as minimalistic toasts with infinite duration
*/
export function GlobalAuthPrompt() {
const {
pendingChallenges,
authenticateRelay,
rejectAuth,
setAuthPreference,
relays,
} = useRelayState();
const activeToasts = useRef<Map<string, string | number>>(new Map());
const [authenticatingRelays, setAuthenticatingRelays] = useState<Set<string>>(
new Set(),
);
// Watch for authentication success and show toast
useEffect(() => {
authenticatingRelays.forEach((relayUrl) => {
const relayState = relays[relayUrl];
if (relayState && relayState.authStatus === "authenticated") {
toast.success(`Authenticated with ${relayUrl}`, {
duration: 3000,
});
setAuthenticatingRelays((prev) => {
const next = new Set(prev);
next.delete(relayUrl);
return next;
});
}
});
}, [relays, authenticatingRelays]);
useEffect(() => {
// Show toasts for new challenges
pendingChallenges.forEach((challenge) => {
const key = challenge.relayUrl;
// Skip if we already have a toast for this relay
if (activeToasts.current.has(key)) {
return;
}
const toastId = toast.custom(
(t) => (
<AuthToast
relayUrl={challenge.relayUrl}
challenge={challenge.challenge}
onAuthenticate={async (remember) => {
console.log(
`[AuthPrompt] Authenticate with remember=${remember}`,
);
if (remember) {
console.log(
`[AuthPrompt] Setting preference to "always" for ${challenge.relayUrl}`,
);
await setAuthPreference(challenge.relayUrl, "always");
}
activeToasts.current.delete(key);
toast.dismiss(t);
setAuthenticatingRelays((prev) =>
new Set(prev).add(challenge.relayUrl),
);
try {
await authenticateRelay(challenge.relayUrl);
} catch (error) {
console.error("Auth failed:", error);
setAuthenticatingRelays((prev) => {
const next = new Set(prev);
next.delete(challenge.relayUrl);
return next;
});
toast.error(
`Authentication failed: ${error instanceof Error ? error.message : "Unknown error"}`,
{
duration: 5000,
},
);
}
}}
onReject={async (remember) => {
console.log(`[AuthPrompt] Reject with remember=${remember}`);
if (remember) {
console.log(
`[AuthPrompt] Setting preference to "never" for ${challenge.relayUrl}`,
);
await setAuthPreference(challenge.relayUrl, "never");
}
rejectAuth(challenge.relayUrl, !remember);
activeToasts.current.delete(key);
toast.dismiss(t);
const message = remember
? `Will never prompt auth for ${challenge.relayUrl}`
: `Won't ask again this session for ${challenge.relayUrl}`;
toast.info(message, {
duration: 2000,
});
}}
onDismiss={() => {
rejectAuth(challenge.relayUrl, true);
activeToasts.current.delete(key);
toast.dismiss(t);
}}
/>
),
{
duration: Infinity,
position: "top-right",
},
);
activeToasts.current.set(key, toastId);
});
// Dismiss toasts for challenges that are no longer pending
activeToasts.current.forEach((toastId, relayUrl) => {
const stillPending = pendingChallenges.some(
(c) => c.relayUrl === relayUrl,
);
if (!stillPending) {
toast.dismiss(toastId);
activeToasts.current.delete(relayUrl);
}
});
}, [pendingChallenges, authenticateRelay, rejectAuth, setAuthPreference]);
return null; // No UI needed - toasts handle everything
}

View File

@@ -1,4 +1,6 @@
import { Terminal } from "lucide-react";
import { Button } from "./ui/button";
import { Kbd, KbdGroup } from "./ui/kbd";
interface GrimoireWelcomeProps {
onLaunchCommand: () => void;
@@ -45,14 +47,21 @@ export function GrimoireWelcome({ onLaunchCommand }: GrimoireWelcomeProps) {
{/* Launch button */}
<div className="flex flex-col items-center gap-3">
<p className="text-muted-foreground text-sm font-mono mb-2">
Press{" "}
<kbd className="px-2 py-1 bg-muted border border-border text-xs">
Cmd+K
</kbd>{" "}
or
<span>Press </span>
<KbdGroup>
<Kbd>Cmd</Kbd>
<span>+</span>
<Kbd>K</Kbd>
</KbdGroup>
<span> or </span>
<KbdGroup>
<Kbd>Ctrl</Kbd>
<span>+</span>
<Kbd>K</Kbd>
</KbdGroup>
</p>
<Button onClick={onLaunchCommand} variant="outline">
<span></span>
<Terminal />
<span>Launch Command</span>
</Button>
</div>

View File

@@ -1,6 +1,8 @@
import { useState, useEffect } from "react";
import { useGrimoire } from "@/core/state";
import { useAccountSync } from "@/hooks/useAccountSync";
import { useRelayState } from "@/hooks/useRelayState";
import relayStateManager from "@/services/relay-state-manager";
import { TabBar } from "./TabBar";
import { Mosaic, MosaicWindow, MosaicBranch } from "react-mosaic-component";
import CommandLauncher from "./CommandLauncher";
@@ -9,6 +11,7 @@ import { WindowTile } from "./WindowTitle";
import { Terminal } from "lucide-react";
import UserMenu from "./nostr/user-menu";
import { GrimoireWelcome } from "./GrimoireWelcome";
import { GlobalAuthPrompt } from "./GlobalAuthPrompt";
export default function Home() {
const { state, updateLayout, removeWindow } = useGrimoire();
@@ -17,6 +20,16 @@ export default function Home() {
// Sync active account and fetch relay lists
useAccountSync();
// Initialize global relay state manager
useEffect(() => {
relayStateManager.initialize().catch((err) => {
console.error("Failed to initialize relay state manager:", err);
});
}, []);
// Sync relay state with Jotai
useRelayState();
// Keyboard shortcut: Cmd/Ctrl+K
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
@@ -68,6 +81,7 @@ export default function Home() {
open={commandLauncherOpen}
onOpenChange={setCommandLauncherOpen}
/>
<GlobalAuthPrompt />
<main className="h-screen w-screen flex flex-col bg-background text-foreground">
<header className="flex flex-row items-center justify-between px-1 border-b border-border">
<button

View File

@@ -1,5 +1,4 @@
import { useState, memo } from "react";
import { Virtuoso } from "react-virtuoso";
import {
ChevronDown,
ChevronRight,
@@ -9,6 +8,7 @@ import {
Filter as FilterIcon,
Circle,
} from "lucide-react";
import { Virtuoso } from "react-virtuoso";
import { useReqTimeline } from "@/hooks/useReqTimeline";
import { useGrimoire } from "@/core/state";
import { FeedEvent } from "./nostr/Feed";
@@ -107,6 +107,7 @@ export default function ReqViewer({
<FileText className="size-3" />
<span>{events.length}</span>
</div>
{/* Relay Count (Clickable) */}
<button
onClick={() => setShowRelays(!showRelays)}
@@ -120,6 +121,7 @@ export default function ReqViewer({
<Wifi className="size-3" />
<span>{defaultRelays.length}</span>
</button>
{/* Query (Clickable) */}
<button
onClick={() => setShowQuery(!showQuery)}
@@ -163,6 +165,7 @@ export default function ReqViewer({
))}
</div>
)}
{/* Authors with NIP-05 info */}
{filter.authors && filter.authors.length > 0 && (
<div className="flex flex-col gap-1">
@@ -178,6 +181,7 @@ export default function ReqViewer({
)}
</div>
)}
{/* #p Tags with NIP-05 info */}
{filter["#p"] && filter["#p"].length > 0 && (
<div className="flex flex-col gap-1">
@@ -193,6 +197,7 @@ export default function ReqViewer({
)}
</div>
)}
{/* Limit */}
{filter.limit && (
<div className="flex items-center gap-2">
@@ -201,6 +206,7 @@ export default function ReqViewer({
</span>
</div>
)}
{/* Stream Mode */}
{stream && (
<div className="flex items-center gap-2">
@@ -209,6 +215,7 @@ export default function ReqViewer({
</span>
</div>
)}
{/* Raw Query */}
<details className="text-xs">
<summary className="cursor-crosshair text-muted-foreground hover:text-foreground">
@@ -237,21 +244,24 @@ export default function ReqViewer({
Loading events...
</div>
)}
{!loading && !stream && events.length === 0 && !error && (
<div className="text-center text-muted-foreground font-mono text-sm p-4">
No events found matching filter
</div>
)}
{stream && events.length === 0 && !loading && (
<div className="text-center text-muted-foreground font-mono text-sm p-4">
Waiting for events...
</div>
)}
{events.length > 0 && (
<Virtuoso
style={{ height: "100%" }}
data={events}
computeItemKey={(_index, event) => event.id}
computeItemKey={(_index, item) => item.id}
itemContent={(_index, event) => <MemoizedFeedEvent event={event} />}
/>
)}

View File

@@ -15,6 +15,7 @@ import KindsViewer from "./KindsViewer";
import Feed from "./nostr/Feed";
import { WinViewer } from "./WinViewer";
import { DebugViewer } from "./DebugViewer";
import ConnViewer from "./ConnViewer";
interface WindowRendererProps {
window: WindowInstance;
@@ -137,6 +138,9 @@ export function WindowRenderer({ window, onClose }: WindowRendererProps) {
case "debug":
content = <DebugViewer />;
break;
case "conn":
content = <ConnViewer />;
break;
default:
content = (
<div className="p-4 text-muted-foreground">

View File

@@ -16,6 +16,7 @@ export interface RelayLinkProps {
className?: string;
urlClassname?: string;
iconClassname?: string;
variant?: "default" | "prompt";
}
/**
@@ -31,6 +32,7 @@ export function RelayLink({
write = false,
showInboxOutbox = true,
className,
variant = "default",
}: RelayLinkProps) {
const { addWindow } = useGrimoire();
const relayInfo = useRelayInfo(url);
@@ -39,10 +41,16 @@ export function RelayLink({
addWindow("relay", { url }, `Relay ${url}`);
};
const variantStyles = {
default: "cursor-crosshair hover:bg-muted/50",
prompt: "cursor-crosshair hover:underline hover:decoration-dotted",
};
return (
<div
className={cn(
"flex items-center justify-between gap-2 cursor-crosshair hover:bg-muted/50",
"flex items-center justify-between gap-2",
variantStyles[variant],
className,
)}
onClick={handleClick}

View File

@@ -0,0 +1,28 @@
import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { Check } from "lucide-react";
import { cn } from "@/lib/utils";
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"grid place-content-center peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("grid place-content-center text-current")}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
));
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
export { Checkbox };

View File

@@ -81,7 +81,7 @@ const DropdownMenuItem = React.forwardRef<
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center gap-2 px-2 py-1.5 text-sm outline-none transition-colors focus:bg-muted/50 focus:text-primary data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
"relative flex cursor-default select-none items-center gap-2 px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent/10 focus:bg-accent/10 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
inset && "pl-8",
className,
)}
@@ -121,7 +121,7 @@ const DropdownMenuRadioItem = React.forwardRef<
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-muted/50 focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
"relative flex cursor-pointer select-none items-center py-1.5 pl-8 pr-2 text-sm outline-none transition-colors hover:bg-accent/10 focus:bg-accent/10 data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
{...props}

28
src/components/ui/kbd.tsx Normal file
View File

@@ -0,0 +1,28 @@
import { cn } from "@/lib/utils";
function Kbd({ className, ...props }: React.ComponentProps<"kbd">) {
return (
<kbd
data-slot="kbd"
className={cn(
"bg-muted text-muted-foreground pointer-events-none inline-flex h-5 w-fit min-w-5 select-none items-center justify-center gap-1 rounded-sm px-1 font-sans text-xs font-medium",
"[&_svg:not([class*='size-'])]:size-3",
"[[data-slot=tooltip-content]_&]:bg-background/20 [[data-slot=tooltip-content]_&]:text-background dark:[[data-slot=tooltip-content]_&]:bg-background/10",
className,
)}
{...props}
/>
);
}
function KbdGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<kbd
data-slot="kbd-group"
className={cn("inline-flex items-center gap-1", className)}
{...props}
/>
);
}
export { Kbd, KbdGroup };

View File

@@ -0,0 +1,30 @@
import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "@/lib/utils";
const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-tooltip-content-transform-origin]",
className,
)}
{...props}
/>
</TooltipPrimitive.Portal>
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };

View File

@@ -13,6 +13,7 @@ import {
Rss,
Layout,
Bug,
Wifi,
type LucideIcon,
} from "lucide-react";
@@ -89,6 +90,10 @@ export const COMMAND_ICONS: Record<string, CommandIcon> = {
icon: Bug,
description: "Display application state for debugging",
},
conn: {
icon: Wifi,
description: "View relay pool connection and authentication status",
},
};
export function getCommandIcon(command: string): LucideIcon {

View File

@@ -0,0 +1,74 @@
import { useEffect } from "react";
import { useAtom } from "jotai";
import { grimoireStateAtom } from "@/core/state";
import relayStateManager from "@/services/relay-state-manager";
import type { AuthPreference, RelayState } from "@/types/relay-state";
/**
* Hook for accessing and managing global relay state
*/
export function useRelayState() {
const [state, setState] = useAtom(grimoireStateAtom);
// Subscribe to relay state manager updates
useEffect(() => {
const unsubscribe = relayStateManager.subscribe((relayState) => {
setState((prev) => ({
...prev,
relayState,
}));
});
// Initialize state if not set
if (!state.relayState) {
setState((prev) => ({
...prev,
relayState: relayStateManager.getState(),
}));
}
return unsubscribe;
}, [setState, state.relayState]);
const relayState = state.relayState;
return {
// Current state
relayState,
relays: relayState?.relays || {},
pendingChallenges: relayState?.pendingChallenges || [],
authPreferences: relayState?.authPreferences || {},
// Get single relay state
getRelay: (url: string): RelayState | undefined => {
return relayState?.relays[url];
},
// Get auth preference
getAuthPreference: async (
url: string,
): Promise<AuthPreference | undefined> => {
return await relayStateManager.getAuthPreference(url);
},
// Set auth preference
setAuthPreference: async (url: string, preference: AuthPreference) => {
await relayStateManager.setAuthPreference(url, preference);
},
// Authenticate with relay
authenticateRelay: async (url: string) => {
await relayStateManager.authenticateRelay(url);
},
// Reject auth for relay
rejectAuth: (url: string, rememberForSession = true) => {
relayStateManager.rejectAuth(url, rememberForSession);
},
// Ensure relay is monitored
ensureRelayMonitored: (url: string) => {
relayStateManager.ensureRelayMonitored(url);
},
};
}

View File

@@ -1,6 +1,7 @@
import { useState, useEffect, useMemo } from "react";
import pool from "@/services/relay-pool";
import type { NostrEvent, Filter } from "nostr-tools";
import { useEventStore } from "applesauce-react/hooks";
interface UseReqTimelineOptions {
limit?: number;
@@ -29,26 +30,34 @@ export function useReqTimeline(
relays: string[],
options: UseReqTimelineOptions = { limit: 50 },
): UseReqTimelineReturn {
const eventStore = useEventStore();
const { limit, stream = false } = options;
const [events, setEvents] = useState<NostrEvent[]>([]);
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(),
);
// Sort events by created_at (newest first) and deduplicate by ID
const events = useMemo(() => {
return Array.from(eventsMap.values()).sort(
(a, b) => b.created_at - a.created_at,
);
}, [eventsMap]);
// Use pool.req() directly to query relays
useEffect(() => {
if (relays.length === 0) {
setLoading(false);
setEvents([]);
return;
}
console.log("REQ: Starting query", { relays, filters, limit, stream });
setLoading(true);
setError(null);
setEoseReceived(false);
const collectedEvents = new Map<string, NostrEvent>();
setEventsMap(new Map());
// Normalize filters to array
const filterArray = Array.isArray(filters) ? filters : [filters];
@@ -59,9 +68,12 @@ export function useReqTimeline(
limit: limit || f.limit,
}));
// Use pool.req() for direct relay querying
// pool.req() returns an Observable of events
const observable = pool.req(relays, filtersWithLimit);
const observable = pool.subscription(relays, filtersWithLimit, {
retries: 5,
reconnect: 5,
resubscribe: true,
eventStore,
});
const subscription = observable.subscribe(
(response) => {
@@ -73,12 +85,14 @@ export function useReqTimeline(
setLoading(false);
}
} else {
// It's an event - store in memory, deduplicate by ID
const event = response as NostrEvent;
console.log("REQ: Event received", event.id);
// Use Map to deduplicate by event ID
collectedEvents.set(event.id, event);
// Update state with deduplicated events
setEvents(Array.from(collectedEvents.values()));
eventStore.add(event);
setEventsMap((prev) => {
const next = new Map(prev);
next.set(event.id, event);
return next;
});
}
},
(err: Error) => {
@@ -87,10 +101,6 @@ export function useReqTimeline(
setLoading(false);
},
() => {
console.log("REQ: Query complete", {
total: collectedEvents.size,
stream,
});
// Only set loading to false if not streaming
if (!stream) {
setLoading(false);
@@ -98,29 +108,13 @@ export function useReqTimeline(
},
);
// Set a timeout to prevent infinite loading (only for non-streaming queries)
const timeout = !stream
? setTimeout(() => {
console.warn("REQ: Query timeout, forcing completion");
setLoading(false);
}, 10000)
: undefined;
return () => {
if (timeout) {
clearTimeout(timeout);
}
subscription.unsubscribe();
};
}, [id, JSON.stringify(filters), relays.join(","), limit, stream]);
// Sort events by created_at (newest first)
const sortedEvents = useMemo(() => {
return [...events].sort((a, b) => b.created_at - a.created_at);
}, [events]);
return {
events: sortedEvents,
events: events || [],
loading,
error,
eoseReceived,

View File

@@ -4,12 +4,28 @@ import Root from "./root";
import eventStore from "./services/event-store";
import "./index.css";
import "react-mosaic-component/react-mosaic-component.css";
import { Toaster } from "sonner";
import { TooltipProvider } from "./components/ui/tooltip";
// Add dark class to html element for default dark theme
document.documentElement.classList.add("dark");
createRoot(document.getElementById("root")!).render(
<EventStoreProvider eventStore={eventStore}>
<Root />
<TooltipProvider>
<Toaster
position="top-center"
theme="dark"
toastOptions={{
style: {
background: "hsl(var(--background))",
color: "hsl(var(--foreground))",
border: "1px solid hsl(var(--border))",
borderRadius: 0,
},
}}
/>
<Root />
</TooltipProvider>
</EventStoreProvider>,
);

View File

@@ -24,19 +24,27 @@ export interface RelayInfo {
fetchedAt: number;
}
export interface RelayAuthPreference {
url: string;
preference: "always" | "never" | "ask";
updatedAt: number;
}
class GrimoireDb extends Dexie {
profiles!: Table<Profile>;
nip05!: Table<Nip05>;
nips!: Table<Nip>;
relayInfo!: Table<RelayInfo>;
relayAuthPreferences!: Table<RelayAuthPreference>;
constructor(name: string) {
super(name);
this.version(4).stores({
this.version(5).stores({
profiles: "&pubkey",
nip05: "&nip05",
nips: "&id",
relayInfo: "&url",
relayAuthPreferences: "&url",
});
}
}

View File

@@ -0,0 +1,467 @@
import type { IRelay } from "applesauce-relay";
import { merge } from "rxjs";
import type {
RelayState,
GlobalRelayState,
AuthPreference,
} from "@/types/relay-state";
import pool from "./relay-pool";
import accountManager from "./accounts";
import db from "./db";
const MAX_NOTICES = 20;
const MAX_ERRORS = 20;
/**
* Singleton service for managing global relay state
* Subscribes to all relay observables and maintains state for all relays
*/
class RelayStateManager {
private relayStates: Map<string, RelayState> = new Map();
private subscriptions: Map<string, () => void> = new Map();
private listeners: Set<(state: GlobalRelayState) => void> = new Set();
private authPreferences: Map<string, AuthPreference> = new Map();
private sessionRejections: Set<string> = new Set();
private initialized = false;
constructor() {
this.loadAuthPreferences();
}
/**
* Initialize relay monitoring for all relays in the pool
*/
async initialize() {
if (this.initialized) return;
this.initialized = true;
// Load preferences from database
await this.loadAuthPreferences();
// Subscribe to existing relays
pool.relays.forEach((relay) => {
this.monitorRelay(relay);
});
// Poll for new relays every second
setInterval(() => {
pool.relays.forEach((relay) => {
if (!this.subscriptions.has(relay.url)) {
this.monitorRelay(relay);
}
});
}, 1000);
}
/**
* Ensure a relay is being monitored (call this when adding relays to pool)
*/
ensureRelayMonitored(relayUrl: string) {
const relay = pool.relay(relayUrl);
if (relay && !this.subscriptions.has(relayUrl)) {
this.monitorRelay(relay);
}
}
/**
* Subscribe to a single relay's observables
*/
private monitorRelay(relay: IRelay) {
const url = relay.url;
// Initialize state if not exists
if (!this.relayStates.has(url)) {
this.relayStates.set(url, this.createInitialState(url));
}
// Subscribe to all relay observables
const subscription = merge(
relay.connected$,
relay.notice$,
relay.challenge$,
relay.authenticated$,
).subscribe(() => {
console.log(
`[RelayStateManager] Observable triggered for ${url}, authenticated = ${relay.authenticated}, challenge = ${relay.challenge}`,
);
this.updateRelayState(url, relay);
});
// Store cleanup function
this.subscriptions.set(url, () => subscription.unsubscribe());
// Initial state update
this.updateRelayState(url, relay);
}
/**
* Create initial state for a relay
*/
private createInitialState(url: string): RelayState {
return {
url,
connectionState: "disconnected",
authStatus: "none",
authPreference: this.authPreferences.get(url),
notices: [],
errors: [],
stats: {
connectionsCount: 0,
authAttemptsCount: 0,
authSuccessCount: 0,
},
};
}
/**
* Update relay state based on current observable values
*/
private updateRelayState(url: string, relay: IRelay) {
const state = this.relayStates.get(url);
if (!state) return;
const now = Date.now();
// Update connection state
const wasConnected = state.connectionState === "connected";
const isConnected = relay.connected;
if (isConnected && !wasConnected) {
state.connectionState = "connected";
state.lastConnected = now;
state.stats.connectionsCount++;
} else if (!isConnected && wasConnected) {
state.connectionState = "disconnected";
state.lastDisconnected = now;
// Reset auth status when disconnecting
console.log(
`[RelayStateManager] ${url} disconnected, resetting auth status`,
);
state.authStatus = "none";
state.currentChallenge = undefined;
} else if (isConnected) {
state.connectionState = "connected";
} else {
state.connectionState = "disconnected";
}
// Update auth status
const challenge = relay.challenge;
// Use the getter property instead of observable value
const isAuthenticated = relay.authenticated;
if (isAuthenticated === true) {
// Successfully authenticated - this takes priority over everything
if (state.authStatus !== "authenticated") {
console.log(
`[RelayStateManager] ${url} authenticated (was: ${state.authStatus})`,
);
state.authStatus = "authenticated";
state.lastAuthenticated = now;
state.stats.authSuccessCount++;
}
state.currentChallenge = undefined;
} else if (challenge) {
// Challenge received
if (state.authStatus !== "authenticating") {
// Only update to challenge_received if not already authenticating
if (
!state.currentChallenge ||
state.currentChallenge.challenge !== challenge
) {
console.log(`[RelayStateManager] ${url} challenge received`);
state.currentChallenge = {
challenge,
receivedAt: now,
};
// Check if we should auto-authenticate
const preference = this.authPreferences.get(url);
if (preference === "always") {
console.log(
`[RelayStateManager] ${url} has "always" preference, auto-authenticating`,
);
state.authStatus = "authenticating";
// Trigger authentication asynchronously
this.authenticateRelay(url).catch((error) => {
console.error(
`[RelayStateManager] Auto-auth failed for ${url}:`,
error,
);
});
} else {
state.authStatus = "challenge_received";
}
}
}
// If we're authenticating and there's still a challenge, keep authenticating status
} else {
// No challenge and not authenticated
if (state.currentChallenge || state.authStatus === "authenticating") {
// Challenge was cleared or authentication didn't result in authenticated status
if (state.authStatus === "authenticating") {
// Authentication failed
console.log(
`[RelayStateManager] ${url} auth failed - no challenge and not authenticated`,
);
state.authStatus = "failed";
} else if (state.authStatus === "challenge_received") {
// Challenge was dismissed/rejected
console.log(`[RelayStateManager] ${url} challenge rejected`);
state.authStatus = "rejected";
}
state.currentChallenge = undefined;
}
}
// Add notices (bounded array)
const notices = relay.notices;
if (notices.length > 0) {
const notice = notices[0];
const lastNotice = state.notices[0];
if (!lastNotice || lastNotice.message !== notice) {
state.notices.unshift({ message: notice, timestamp: now });
if (state.notices.length > MAX_NOTICES) {
state.notices = state.notices.slice(0, MAX_NOTICES);
}
}
}
// Notify listeners
this.notifyListeners();
}
/**
* Get auth preference for a relay
*/
async getAuthPreference(
relayUrl: string,
): Promise<AuthPreference | undefined> {
// Check memory cache first
if (this.authPreferences.has(relayUrl)) {
return this.authPreferences.get(relayUrl);
}
// Load from database
const record = await db.relayAuthPreferences.get(relayUrl);
if (record) {
this.authPreferences.set(relayUrl, record.preference);
return record.preference;
}
return undefined;
}
/**
* Set auth preference for a relay
*/
async setAuthPreference(relayUrl: string, preference: AuthPreference) {
console.log(
`[RelayStateManager] Setting auth preference for ${relayUrl} to "${preference}"`,
);
// Update memory cache
this.authPreferences.set(relayUrl, preference);
// Save to database
try {
await db.relayAuthPreferences.put({
url: relayUrl,
preference,
updatedAt: Date.now(),
});
console.log(
`[RelayStateManager] Successfully saved preference to database`,
);
} catch (error) {
console.error(
`[RelayStateManager] Failed to save preference to database:`,
error,
);
throw error;
}
// Update relay state
const state = this.relayStates.get(relayUrl);
if (state) {
state.authPreference = preference;
this.notifyListeners();
console.log(
`[RelayStateManager] Updated relay state and notified listeners`,
);
}
}
/**
* Authenticate with a relay
*/
async authenticateRelay(relayUrl: string): Promise<void> {
const relay = pool.relay(relayUrl);
const state = this.relayStates.get(relayUrl);
if (!relay || !state) {
throw new Error(`Relay ${relayUrl} not found`);
}
if (!state.currentChallenge) {
throw new Error(`No auth challenge for ${relayUrl}`);
}
// Get active account
const account = accountManager.active;
if (!account) {
throw new Error("No active account to authenticate with");
}
// Update status to authenticating
state.authStatus = "authenticating";
state.stats.authAttemptsCount++;
this.notifyListeners();
try {
console.log(`[RelayStateManager] Authenticating with ${relayUrl}...`);
console.log(
`[RelayStateManager] Before auth - authenticated = ${relay.authenticated}, challenge = ${relay.challenge}`,
);
await relay.authenticate(account);
console.log(
`[RelayStateManager] After auth - authenticated = ${relay.authenticated}, challenge = ${relay.challenge}`,
);
// Wait a bit for the observable to update
await new Promise((resolve) => setTimeout(resolve, 200));
console.log(
`[RelayStateManager] After delay - authenticated = ${relay.authenticated}, challenge = ${relay.challenge}`,
);
// Force immediate state update after authentication
this.updateRelayState(relayUrl, relay);
} catch (error) {
state.authStatus = "failed";
state.errors.unshift({
message: `Auth failed: ${error}`,
timestamp: Date.now(),
});
if (state.errors.length > MAX_ERRORS) {
state.errors = state.errors.slice(0, MAX_ERRORS);
}
this.notifyListeners();
throw error;
}
}
/**
* Reject authentication for a relay
*/
rejectAuth(relayUrl: string, rememberForSession = true) {
const state = this.relayStates.get(relayUrl);
if (state) {
state.authStatus = "rejected";
state.currentChallenge = undefined;
if (rememberForSession) {
this.sessionRejections.add(relayUrl);
}
this.notifyListeners();
}
}
/**
* Check if a relay should be prompted for auth
*/
shouldPromptAuth(relayUrl: string): boolean {
// Check permanent preferences
const pref = this.authPreferences.get(relayUrl);
if (pref === "never") return false;
// Check session rejections
if (this.sessionRejections.has(relayUrl)) return false;
// Don't prompt if already authenticated (unless challenge changes)
const state = this.relayStates.get(relayUrl);
if (state?.authStatus === "authenticated") return false;
return true;
}
/**
* Get current global state
*/
getState(): GlobalRelayState {
const relays: Record<string, RelayState> = {};
this.relayStates.forEach((state, url) => {
relays[url] = state;
});
const pendingChallenges = Array.from(this.relayStates.values())
.filter(
(state) =>
state.authStatus === "challenge_received" &&
this.shouldPromptAuth(state.url),
)
.map((state) => ({
relayUrl: state.url,
challenge: state.currentChallenge!.challenge,
receivedAt: state.currentChallenge!.receivedAt,
}));
const authPreferences: Record<string, AuthPreference> = {};
this.authPreferences.forEach((pref, url) => {
authPreferences[url] = pref;
});
return {
relays,
pendingChallenges,
authPreferences,
};
}
/**
* Subscribe to state changes
*/
subscribe(listener: (state: GlobalRelayState) => void): () => void {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
}
/**
* Notify all listeners of state change
*/
private notifyListeners() {
const state = this.getState();
this.listeners.forEach((listener) => listener(state));
}
/**
* Load auth preferences from database into memory cache
*/
private async loadAuthPreferences() {
try {
const allPrefs = await db.relayAuthPreferences.toArray();
allPrefs.forEach((record) => {
this.authPreferences.set(record.url, record.preference);
});
console.log(
`[RelayStateManager] Loaded ${allPrefs.length} auth preferences from database`,
);
} catch (error) {
console.warn("Failed to load auth preferences:", error);
}
}
/**
* Cleanup all subscriptions
*/
destroy() {
this.subscriptions.forEach((unsubscribe) => unsubscribe());
this.subscriptions.clear();
this.listeners.clear();
}
}
// Singleton instance
const relayStateManager = new RelayStateManager();
export default relayStateManager;

View File

@@ -1,4 +1,5 @@
import { MosaicNode } from "react-mosaic-component";
import type { MosaicNode } from "react-mosaic-component";
import type { GlobalRelayState } from "./relay-state";
export type AppId =
| "nip"
@@ -15,7 +16,8 @@ export type AppId =
| "encode"
| "decode"
| "relay"
| "debug";
| "debug"
| "conn";
export interface WindowInstance {
id: string;
@@ -58,4 +60,5 @@ export interface GrimoireState {
timezone: string;
timeFormat: "12h" | "24h";
};
relayState?: GlobalRelayState;
}

View File

@@ -406,4 +406,16 @@ export const manPages: Record<string, ManPageEntry> = {
return parsed;
},
},
conn: {
name: "conn",
section: "1",
synopsis: "conn",
description:
"Monitor all relay connections in the pool. Displays real-time connection status, authentication state, pending auth challenges, relay notices, and connection statistics. Manage auth preferences per relay (always/never/ask).",
examples: ["conn View all relay connections and auth status"],
seeAlso: ["relay", "req"],
appId: "conn",
category: "System",
defaultProps: {},
},
};

64
src/types/relay-state.ts Normal file
View File

@@ -0,0 +1,64 @@
// Types for global relay state management
export type ConnectionState =
| "disconnected"
| "connecting"
| "connected"
| "error";
export type AuthStatus =
| "none" // No auth interaction yet
| "challenge_received" // Challenge received, waiting for user decision
| "authenticating" // Signing and sending AUTH event
| "authenticated" // Successfully authenticated
| "rejected" // User rejected auth
| "failed"; // Authentication failed
export type AuthPreference = "always" | "never" | "ask";
export interface RelayChallenge {
challenge: string;
receivedAt: number;
}
export interface RelayNotice {
message: string;
timestamp: number;
}
export interface RelayError {
message: string;
timestamp: number;
}
export interface RelayStats {
connectionsCount: number;
authAttemptsCount: number;
authSuccessCount: number;
}
export interface RelayState {
url: string;
connectionState: ConnectionState;
authStatus: AuthStatus;
authPreference?: AuthPreference;
currentChallenge?: RelayChallenge;
lastConnected?: number;
lastDisconnected?: number;
lastAuthenticated?: number;
notices: RelayNotice[];
errors: RelayError[];
stats: RelayStats;
}
export interface PendingAuthChallenge {
relayUrl: string;
challenge: string;
receivedAt: number;
}
export interface GlobalRelayState {
relays: Record<string, RelayState>;
pendingChallenges: PendingAuthChallenge[];
authPreferences: Record<string, AuthPreference>;
}