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