From 6568daf944908705eebdea7a79fdd79f78036755 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20G=C3=B3mez?= Date: Fri, 12 Dec 2025 23:26:57 +0100 Subject: [PATCH] wip: relay pool view and auth --- components.json | 3 +- package-lock.json | 238 +++++++++++++ package.json | 3 + src/components/ConnViewer.tsx | 231 +++++++++++++ src/components/DynamicWindowTitle.tsx | 19 ++ src/components/GlobalAuthPrompt.tsx | 240 +++++++++++++ src/components/GrimoireWelcome.tsx | 21 +- src/components/Home.tsx | 14 + src/components/ReqViewer.tsx | 14 +- src/components/WindowRenderer.tsx | 4 + src/components/nostr/RelayLink.tsx | 10 +- src/components/ui/checkbox.tsx | 28 ++ src/components/ui/dropdown-menu.tsx | 4 +- src/components/ui/kbd.tsx | 28 ++ src/components/ui/tooltip.tsx | 30 ++ src/constants/command-icons.ts | 5 + src/hooks/useRelayState.ts | 74 ++++ src/hooks/useReqTimeline.ts | 62 ++-- src/main.tsx | 18 +- src/services/db.ts | 10 +- src/services/relay-state-manager.ts | 467 ++++++++++++++++++++++++++ src/types/app.ts | 7 +- src/types/man.ts | 12 + src/types/relay-state.ts | 64 ++++ 24 files changed, 1556 insertions(+), 50 deletions(-) create mode 100644 src/components/ConnViewer.tsx create mode 100644 src/components/GlobalAuthPrompt.tsx create mode 100644 src/components/ui/checkbox.tsx create mode 100644 src/components/ui/kbd.tsx create mode 100644 src/components/ui/tooltip.tsx create mode 100644 src/hooks/useRelayState.ts create mode 100644 src/services/relay-state-manager.ts create mode 100644 src/types/relay-state.ts diff --git a/components.json b/components.json index 330f415..275d416 100644 --- a/components.json +++ b/components.json @@ -16,5 +16,6 @@ "ui": "@/components/ui", "lib": "@/lib", "hooks": "@/hooks" - } + }, + "iconLibrary": "lucide" } diff --git a/package-lock.json b/package-lock.json index 97115aa..537521a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index e4720e6..17c80e9 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/components/ConnViewer.tsx b/src/components/ConnViewer.tsx new file mode 100644 index 0000000..4423ad5 --- /dev/null +++ b/src/components/ConnViewer.tsx @@ -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 ( +
+ {/* Relay List */} +
+ {relayList.length === 0 && ( +
+ No relays in pool +
+ )} + + {/* Connected */} + {connectedRelays.length > 0 && ( + <> +
+ Connected ({connectedRelays.length}) +
+ {connectedRelays.map((relay) => ( + + ))} + + )} + + {/* Disconnected */} + {disconnectedRelays.length > 0 && ( + <> +
+ Disconnected ({disconnectedRelays.length}) +
+ {disconnectedRelays.map((relay) => ( + + ))} + + )} +
+
+ ); +} + +interface RelayCardProps { + relay: RelayState; +} + +function RelayCard({ relay }: RelayCardProps) { + const { setAuthPreference } = useRelayState(); + + const connectionIcon = () => { + const iconMap = { + connected: { + icon: , + label: "Connected", + }, + connecting: { + icon: , + label: "Connecting", + }, + disconnected: { + icon: , + label: "Disconnected", + }, + error: { + icon: , + label: "Connection Error", + }, + }; + return iconMap[relay.connectionState]; + }; + + const authIcon = () => { + const iconMap = { + authenticated: { + icon: , + label: "Authenticated", + }, + challenge_received: { + icon: , + label: "Challenge Received", + }, + authenticating: { + icon: , + label: "Authenticating", + }, + failed: { + icon: , + label: "Authentication Failed", + }, + rejected: { + icon: , + label: "Authentication Rejected", + }, + none: { + icon: , + label: "No Authentication", + }, + }; + return iconMap[relay.authStatus] || iconMap.none; + }; + + const connIcon = connectionIcon(); + const auth = authIcon(); + + const currentPreference = relay.authPreference || "ask"; + + return ( +
+
+ {/* Main Row */} +
+ +
+ {relay.authStatus !== "none" && ( + + +
{auth.icon}
+
+ +

{auth.label}

+
+
+ )} + + +
{connIcon.icon}
+
+ +

{connIcon.label}

+
+
+ + {/* Auth Settings Dropdown */} + + + + + + + + + +
+
{connIcon.icon}
+ {connIcon.label} +
+
+ + +
+ + Auth +
+
+ { + await setAuthPreference( + relay.url, + value as "always" | "never" | "ask", + ); + }} + > + Ask + + Always + + + Never + + +
+
+
+
+
+
+ ); +} diff --git a/src/components/DynamicWindowTitle.tsx b/src/components/DynamicWindowTitle.tsx index 754a43c..3f15cee 100644 --- a/src/components/DynamicWindowTitle.tsx +++ b/src/components/DynamicWindowTitle.tsx @@ -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, ]); } diff --git a/src/components/GlobalAuthPrompt.tsx b/src/components/GlobalAuthPrompt.tsx new file mode 100644 index 0000000..e689a68 --- /dev/null +++ b/src/components/GlobalAuthPrompt.tsx @@ -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; + onReject: (remember: boolean) => Promise; + onDismiss: () => void; +} + +function AuthToast({ + relayUrl, + challenge, + onAuthenticate, + onReject, + onDismiss, +}: AuthToastProps) { + const [remember, setRemember] = useState(false); + const [loading, setLoading] = useState(false); + + return ( +
+
+ +
+
+
+ Authentication Request +
+ +
+ +
+ setRemember(checked === true)} + /> + +
+ +
+ + +
+
+ + +
+
+ ); +} + +/** + * 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>(new Map()); + const [authenticatingRelays, setAuthenticatingRelays] = useState>( + 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) => ( + { + 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 +} diff --git a/src/components/GrimoireWelcome.tsx b/src/components/GrimoireWelcome.tsx index 6c4e44f..059e640 100644 --- a/src/components/GrimoireWelcome.tsx +++ b/src/components/GrimoireWelcome.tsx @@ -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 */}

- Press{" "} - - Cmd+K - {" "} - or + Press + + Cmd + + + K + + or + + Ctrl + + + K +

diff --git a/src/components/Home.tsx b/src/components/Home.tsx index 7f0f72d..b4403a0 100644 --- a/src/components/Home.tsx +++ b/src/components/Home.tsx @@ -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} /> +
+ {/* Query (Clickable) */}