mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-10 15:36:53 +02:00
wip: relay pool view and auth
This commit is contained in:
@@ -16,5 +16,6 @@
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
}
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
|
||||
238
package-lock.json
generated
238
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
231
src/components/ConnViewer.tsx
Normal file
231
src/components/ConnViewer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
240
src/components/GlobalAuthPrompt.tsx
Normal file
240
src/components/GlobalAuthPrompt.tsx
Normal 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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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} />}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
28
src/components/ui/checkbox.tsx
Normal file
28
src/components/ui/checkbox.tsx
Normal 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 };
|
||||
@@ -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
28
src/components/ui/kbd.tsx
Normal 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 };
|
||||
30
src/components/ui/tooltip.tsx
Normal file
30
src/components/ui/tooltip.tsx
Normal 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 };
|
||||
@@ -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 {
|
||||
|
||||
74
src/hooks/useRelayState.ts
Normal file
74
src/hooks/useRelayState.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
18
src/main.tsx
18
src/main.tsx
@@ -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>,
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
467
src/services/relay-state-manager.ts
Normal file
467
src/services/relay-state-manager.ts
Normal 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;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
64
src/types/relay-state.ts
Normal 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>;
|
||||
}
|
||||
Reference in New Issue
Block a user