show relay auth requests

add default relay auth option
This commit is contained in:
hzrd149 2024-05-30 11:06:47 -05:00
parent 0b887ac495
commit b4c4c7a9f2
24 changed files with 415 additions and 164 deletions

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Show relay authentication requests

View File

@ -68,7 +68,7 @@
"nanoid": "^5.0.4", "nanoid": "^5.0.4",
"ngeohash": "^0.6.3", "ngeohash": "^0.6.3",
"nostr-idb": "^2.1.4", "nostr-idb": "^2.1.4",
"nostr-tools": "^2.5.2", "nostr-tools": "2.5.2",
"nostr-wasm": "^0.1.0", "nostr-wasm": "^0.1.0",
"prettier": "^3.2.5", "prettier": "^3.2.5",
"react": "^18.2.0", "react": "^18.2.0",

View File

@ -54,6 +54,13 @@ export default class ChunkedRequest {
async loadNextChunk() { async loadNextChunk() {
if (this.loading) return; if (this.loading) return;
// check if its possible to subscribe to this relay
if (!relayPoolService.canSubscribe(this.relay)) {
this.log("Cant subscribe to relay, aborting");
return;
}
this.loading = true; this.loading = true;
if (!this.relay.connected) { if (!this.relay.connected) {
@ -93,6 +100,9 @@ export default class ChunkedRequest {
this.process.active = false; this.process.active = false;
res(gotEvents); res(gotEvents);
}, },
onclose: (reason) => {
relayPoolService.handleRelayNotice(this.relay, reason);
},
}); });
}); });
} }

View File

@ -76,7 +76,6 @@ export default class MultiSubscription {
this.relays.add(relay); this.relays.add(relay);
} }
this.process.relays = new Set(this.relays);
this.updateSubscriptions(); this.updateSubscriptions();
} }

View File

@ -44,6 +44,9 @@ export default class PersistentSubscription {
// this.subscription.close(); // this.subscription.close();
// } // }
// check if its possible to subscribe to this relay
if (!relayPoolService.canSubscribe(this.relay)) return;
this.closed = false; this.closed = false;
this.eosed = false; this.eosed = false;
this.process.active = true; this.process.active = true;
@ -59,7 +62,9 @@ export default class PersistentSubscription {
onclose: (reason) => { onclose: (reason) => {
if (!this.closed) { if (!this.closed) {
// unexpected close, reconnect? // unexpected close, reconnect?
console.log("Unexpected closed", this.relay, reason); // console.log("Unexpected closed", this.relay, reason);
relayPoolService.handleRelayNotice(this.relay, reason);
this.closed = true; this.closed = true;
this.process.active = false; this.process.active = false;

View File

@ -8,6 +8,8 @@ import Subject, { PersistentSubject } from "./subject";
import verifyEventMethod from "../services/verify-event"; import verifyEventMethod from "../services/verify-event";
import SuperMap from "./super-map"; import SuperMap from "./super-map";
import processManager from "../services/process-manager"; import processManager from "../services/process-manager";
import signingService from "../services/signing";
import accountService from "../services/account";
export type Notice = { export type Notice = {
message: string; message: string;
@ -15,6 +17,8 @@ export type Notice = {
relay: AbstractRelay; relay: AbstractRelay;
}; };
export type RelayAuthMode = "always" | "ask" | "never";
export default class RelayPool { export default class RelayPool {
relays = new Map<string, AbstractRelay>(); relays = new Map<string, AbstractRelay>();
onRelayCreated = new Subject<AbstractRelay>(); onRelayCreated = new Subject<AbstractRelay>();
@ -25,9 +29,12 @@ export default class RelayPool {
connectionErrors = new SuperMap<AbstractRelay, Error[]>(() => []); connectionErrors = new SuperMap<AbstractRelay, Error[]>(() => []);
connecting = new SuperMap<AbstractRelay, PersistentSubject<boolean>>(() => new PersistentSubject(false)); connecting = new SuperMap<AbstractRelay, PersistentSubject<boolean>>(() => new PersistentSubject(false));
challenges = new SuperMap<AbstractRelay, Subject<string>>(() => new Subject<string>());
authForPublish = new SuperMap<AbstractRelay, Subject<boolean>>(() => new Subject()); authForPublish = new SuperMap<AbstractRelay, Subject<boolean>>(() => new Subject());
authForSubscribe = new SuperMap<AbstractRelay, Subject<boolean>>(() => new Subject()); authForSubscribe = new SuperMap<AbstractRelay, Subject<boolean>>(() => new Subject());
authenticated = new SuperMap<AbstractRelay, Subject<boolean>>(() => new Subject());
log = logger.extend("RelayPool"); log = logger.extend("RelayPool");
getRelay(relayOrUrl: string | URL | AbstractRelay) { getRelay(relayOrUrl: string | URL | AbstractRelay) {
@ -62,7 +69,10 @@ export default class RelayPool {
const key = url.toString(); const key = url.toString();
if (!this.relays.has(key)) { if (!this.relays.has(key)) {
const r = new AbstractRelay(key, { verifyEvent: verifyEventMethod }); const r = new AbstractRelay(key, { verifyEvent: verifyEventMethod });
r._onauth = (challenge) => this.onRelayChallenge.next([r, challenge]); r._onauth = (challenge) => {
this.onRelayChallenge.next([r, challenge]);
this.challenges.get(r).next(challenge);
};
r.onnotice = (notice) => this.handleRelayNotice(r, notice); r.onnotice = (notice) => this.handleRelayNotice(r, notice);
this.relays.set(key, r); this.relays.set(key, r);
@ -112,12 +122,94 @@ export default class RelayPool {
} }
} }
getRelayAuthStorageKey(relayOrUrl: string | URL | AbstractRelay) {
let relay = this.getRelay(relayOrUrl);
return `${relay!.url}-auth-mode`;
}
getRelayAuthMode(relayOrUrl: string | URL | AbstractRelay): RelayAuthMode | undefined {
let relay = this.getRelay(relayOrUrl);
if (!relay) return;
const defaultMode = (localStorage.getItem(`default-auth-mode`) as RelayAuthMode) ?? undefined;
const mode = (localStorage.getItem(this.getRelayAuthStorageKey(relay)) as RelayAuthMode) ?? undefined;
return mode || defaultMode;
}
setRelayAuthMode(relayOrUrl: string | URL | AbstractRelay, mode: RelayAuthMode) {
let relay = this.getRelay(relayOrUrl);
if (!relay) return;
localStorage.setItem(this.getRelayAuthStorageKey(relay), mode);
}
pendingAuth = new Map<AbstractRelay, Promise<string | undefined>>();
async authenticate(
relayOrUrl: string | URL | AbstractRelay,
sign: Parameters<AbstractRelay["auth"]>[0],
quite = true,
) {
let relay = this.getRelay(relayOrUrl);
if (!relay) return;
const pending = this.pendingAuth.get(relay);
if (pending) return pending;
if (this.getRelayAuthMode(relay) === "never") throw new Error("Auth disabled for relay");
if (!relay.connected) throw new Error("Not connected");
const promise = new Promise<string | undefined>(async (res) => {
if (!relay) return;
try {
const message = await relay.auth(sign);
this.authenticated.get(relay).next(true);
res(message);
} catch (e) {
e = e || new Error("Unknown error");
if (e instanceof Error) {
this.log(`Failed to authenticate to ${relay.url}`, e.message);
}
this.authenticated.get(relay).next(false);
if (!quite) throw e;
}
this.pendingAuth.delete(relay);
});
this.pendingAuth.set(relay, promise);
return await promise;
}
canSubscribe(relayOrUrl: string | URL | AbstractRelay) {
let relay = this.getRelay(relayOrUrl);
if (!relay) return false;
return this.authForSubscribe.get(relay).value !== false;
}
handleRelayNotice(relay: AbstractRelay, message: string) { handleRelayNotice(relay: AbstractRelay, message: string) {
const subject = this.notices.get(relay); const subject = this.notices.get(relay);
subject.next([...subject.value, { message, date: dayjs().unix(), relay }]); subject.next([...subject.value, { message, date: dayjs().unix(), relay }]);
if (message.includes("auth-required")) {
const authForSubscribe = this.authForSubscribe.get(relay); const authForSubscribe = this.authForSubscribe.get(relay);
if (!authForSubscribe.value) authForSubscribe.next(true); if (!authForSubscribe.value) authForSubscribe.next(true);
const account = accountService.current.value;
if (account) {
const authMode = this.getRelayAuthMode(relay);
if (authMode === "always") {
this.authenticate(relay, (draft) => {
return signingService.requestSignature(draft, account);
}).then(() => {
this.log(`Automatically authenticated to ${relay.url}`);
});
}
}
}
} }
disconnectFromUnused() { disconnectFromUnused() {

View File

@ -12,7 +12,7 @@ import useSubject from "../../hooks/use-subject";
import { offlineMode } from "../../services/offline-mode"; import { offlineMode } from "../../services/offline-mode";
import WifiOff from "../icons/wifi-off"; import WifiOff from "../icons/wifi-off";
import { useTaskManagerContext } from "../../views/task-manager/provider"; import { useTaskManagerContext } from "../../views/task-manager/provider";
import TaskManagerButton from "./task-manager-button"; import TaskManagerButtons from "./task-manager-buttons";
const hideScrollbar = css` const hideScrollbar = css`
-ms-overflow-style: none; -ms-overflow-style: none;
@ -89,7 +89,7 @@ export default function DesktopSideNav(props: Omit<FlexProps, "children">) {
</Button> </Button>
)} )}
</Flex> </Flex>
<TaskManagerButton mt="auto" flexShrink={0} py="2" /> <TaskManagerButtons mt="auto" flexShrink={0} />
</Flex> </Flex>
); );
} }

View File

@ -15,7 +15,7 @@ import { Link as RouterLink } from "react-router-dom";
import AccountSwitcher from "./account-switcher"; import AccountSwitcher from "./account-switcher";
import useCurrentAccount from "../../hooks/use-current-account"; import useCurrentAccount from "../../hooks/use-current-account";
import NavItems from "./nav-items"; import NavItems from "./nav-items";
import TaskManagerButton from "./task-manager-button"; import TaskManagerButtons from "./task-manager-buttons";
export default function MobileSideDrawer({ ...props }: Omit<DrawerProps, "children">) { export default function MobileSideDrawer({ ...props }: Omit<DrawerProps, "children">) {
const account = useCurrentAccount(); const account = useCurrentAccount();
@ -40,7 +40,7 @@ export default function MobileSideDrawer({ ...props }: Omit<DrawerProps, "childr
Sign in Sign in
</Button> </Button>
)} )}
<TaskManagerButton mt="auto" flexShrink={0} py="2" /> <TaskManagerButtons mt="auto" flexShrink={0} />
</DrawerBody> </DrawerBody>
</DrawerContent> </DrawerContent>
</Drawer> </Drawer>

View File

@ -1,23 +0,0 @@
import { useContext } from "react";
import { Button, ButtonProps } from "@chakra-ui/react";
import { PublishContext } from "../../providers/global/publish-provider";
import { useTaskManagerContext } from "../../views/task-manager/provider";
import PublishActionStatusTag from "../../views/task-manager/publish-log/action-status-tag";
export default function TaskManagerButton({ ...props }: Omit<ButtonProps, "children">) {
const { log } = useContext(PublishContext);
const { openTaskManager } = useTaskManagerContext();
return (
<Button
variant="link"
justifyContent="space-between"
onClick={() => openTaskManager(log.length === 0 ? "/relays" : "/publish-log")}
{...props}
>
Task Manager
{log.length > 0 && <PublishActionStatusTag action={log[log.length - 1]} />}
</Button>
);
}

View File

@ -0,0 +1,46 @@
import { useContext } from "react";
import { Button, Flex, FlexProps, IconButton } from "@chakra-ui/react";
import { PublishContext } from "../../providers/global/publish-provider";
import { useTaskManagerContext } from "../../views/task-manager/provider";
import PublishActionStatusTag from "../../views/task-manager/publish-log/action-status-tag";
import PasscodeLock from "../icons/passcode-lock";
import relayPoolService from "../../services/relay-pool";
export default function TaskManagerButtons({ ...props }: Omit<FlexProps, "children">) {
const { log } = useContext(PublishContext);
const { openTaskManager } = useTaskManagerContext();
const pendingAuth = Array.from(relayPoolService.challenges.entries()).filter(
([r, c]) => r.connected && !!c.value && !relayPoolService.authenticated.get(r).value,
);
return (
<Flex gap="2" {...props}>
<Button
justifyContent="space-between"
onClick={() => openTaskManager(log.length === 0 ? "/relays" : "/publish-log")}
py="2"
variant="link"
w="full"
>
Task Manager
{log.length > 0 && <PublishActionStatusTag action={log[log.length - 1]} />}
</Button>
{pendingAuth.length > 0 && (
<Button
leftIcon={<PasscodeLock boxSize={5} />}
aria-label="Pending Auth"
title="Pending Auth"
ml="auto"
size="sm"
variant="ghost"
color="red"
onClick={() => openTaskManager({ pathname: "/relays", search: "?tab=auth" })}
>
{pendingAuth.length}
</Button>
)}
</Flex>
);
}

View File

@ -1,43 +1,83 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useState } from "react";
import {
Button,
ButtonProps,
IconButton,
IconButtonProps,
useForceUpdate,
useInterval,
useToast,
} from "@chakra-ui/react";
import { AbstractRelay } from "nostr-tools"; import { AbstractRelay } from "nostr-tools";
import { Button, useToast } from "@chakra-ui/react";
import relayPoolService from "../../services/relay-pool"; import relayPoolService from "../../services/relay-pool";
import { useSigningContext } from "../../providers/global/signing-provider"; import { useSigningContext } from "../../providers/global/signing-provider";
import PasscodeLock from "../icons/passcode-lock";
import useSubject from "../../hooks/use-subject";
import CheckCircleBroken from "../icons/check-circle-broken";
export default function RelayAuthButton({ relay }: { relay: string | URL | AbstractRelay }) { export function useRelayChallenge(relay: AbstractRelay) {
return useSubject(relayPoolService.challenges.get(relay));
}
export function useRelayAuthMethod(relay: AbstractRelay) {
const toast = useToast(); const toast = useToast();
const { requestSignature } = useSigningContext(); const { requestSignature } = useSigningContext();
const r = relayPoolService.getRelay(relay); const challenge = useRelayChallenge(relay);
if (!r) return null;
// @ts-expect-error const authenticated = useSubject(relayPoolService.authenticated.get(relay));
const [challenge, setChallenge] = useState(r.challenge ?? "");
useEffect(() => {
const sub = relayPoolService.onRelayChallenge.subscribe(([relay, challenge]) => {
if (r === relay) setChallenge(challenge);
});
return () => sub.unsubscribe();
}, [r]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const auth = useCallback(async () => { const auth = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
const message = await r.auth(requestSignature); const message = await relayPoolService.authenticate(relay, requestSignature, false);
toast({ description: message || "Success", status: "success" }); toast({ description: message || "Success", status: "success" });
} catch (error) { } catch (error) {
if (error instanceof Error) toast({ status: "error", description: error.message }); if (error instanceof Error) toast({ status: "error", description: error.message });
} }
setLoading(false); setLoading(false);
}, [r, requestSignature]); }, [relay, requestSignature]);
if (challenge) return { loading, auth, challenge, authenticated };
}
export function IconRelayAuthButton({
relay,
...props
}: { relay: string | URL | AbstractRelay } & Omit<IconButtonProps, "icon" | "aria-label" | "title">) {
const r = relayPoolService.getRelay(relay);
if (!r) return null;
const update = useForceUpdate();
useInterval(update, 500);
const { challenge, auth, loading, authenticated } = useRelayAuthMethod(r);
if (authenticated) {
return ( return (
<Button onClick={auth} isLoading={loading}> <IconButton
Authenticate icon={<CheckCircleBroken boxSize={6} />}
</Button> aria-label="Authenticated"
title="Authenticated"
colorScheme="green"
{...props}
/>
); );
}
if (r.connected && challenge) {
return (
<IconButton
icon={<PasscodeLock boxSize={6} />}
onClick={auth}
isLoading={loading}
aria-label="Authenticate with relay"
title="Authenticate"
{...props}
/>
);
}
return null; return null;
} }

View File

@ -0,0 +1,36 @@
import { ChangeEventHandler } from "react";
import { Switch, useForceUpdate, useInterval, useToast } from "@chakra-ui/react";
import { AbstractRelay } from "nostr-tools";
import relayPoolService from "../../services/relay-pool";
import useSubject from "../../hooks/use-subject";
export default function RelayConnectSwitch({ relay }: { relay: string | URL | AbstractRelay }) {
const toast = useToast();
const r = relayPoolService.getRelay(relay);
if (!r) return null;
const update = useForceUpdate();
useInterval(update, 500);
const connecting = useSubject(relayPoolService.connecting.get(r));
const onChange: ChangeEventHandler<HTMLInputElement> = async (e) => {
try {
if (e.target.checked && !r.connected) await relayPoolService.requestConnect(r);
else if (r.connected) r.close();
} catch (error) {
if (error instanceof Error) toast({ status: "error", description: error.message });
}
};
return (
<Switch
isDisabled={connecting}
isChecked={r.connected || connecting}
onChange={onChange}
colorScheme={r.connected ? "green" : "red"}
/>
);
}

View File

@ -1,9 +1,9 @@
import { Badge, useForceUpdate } from "@chakra-ui/react"; import { Badge, useForceUpdate } from "@chakra-ui/react";
import { useInterval } from "react-use"; import { useInterval } from "react-use";
import relayPoolService from "../services/relay-pool"; import relayPoolService from "../../services/relay-pool";
import { AbstractRelay } from "nostr-tools"; import { AbstractRelay } from "nostr-tools";
import useSubject from "../hooks/use-subject"; import useSubject from "../../hooks/use-subject";
const getStatusText = (relay: AbstractRelay, connecting = false) => { const getStatusText = (relay: AbstractRelay, connecting = false) => {
if (connecting) return "Connecting..."; if (connecting) return "Connecting...";

View File

@ -6,7 +6,7 @@ import { ChevronLeftIcon } from "../icons";
export default function BackButton({ ...props }: Omit<IconButtonProps, "onClick" | "children" | "aria-label">) { export default function BackButton({ ...props }: Omit<IconButtonProps, "onClick" | "children" | "aria-label">) {
const navigate = useNavigate(); const navigate = useNavigate();
return ( return (
<IconButton icon={<ChevronLeftIcon />} aria-label="Back" {...props} onClick={() => navigate(-1)}> <IconButton icon={<ChevronLeftIcon boxSize={6} />} aria-label="Back" {...props} onClick={() => navigate(-1)}>
Back Back
</IconButton> </IconButton>
); );
@ -14,5 +14,7 @@ export default function BackButton({ ...props }: Omit<IconButtonProps, "onClick"
export function BackIconButton({ ...props }: Omit<IconButtonProps, "onClick" | "children" | "aria-label">) { export function BackIconButton({ ...props }: Omit<IconButtonProps, "onClick" | "children" | "aria-label">) {
const navigate = useNavigate(); const navigate = useNavigate();
return <IconButton icon={<ChevronLeftIcon />} aria-label="Back" {...props} onClick={() => navigate(-1)} />; return (
<IconButton icon={<ChevronLeftIcon boxSize={6} />} aria-label="Back" {...props} onClick={() => navigate(-1)} />
);
} }

View File

@ -15,7 +15,7 @@ export function getPageTopic(page: NostrEvent) {
export function getPageSummary(page: NostrEvent, fallback = true) { export function getPageSummary(page: NostrEvent, fallback = true) {
const summary = page.tags.find((t) => t[0] === "summary")?.[1]; const summary = page.tags.find((t) => t[0] === "summary")?.[1];
return summary || (fallback ? page.content.split("\n")[0] : ''); return summary || (fallback ? page.content.split("\n")[0] : "");
} }
export function getPageForks(page: NostrEvent) { export function getPageForks(page: NostrEvent) {

View File

@ -97,27 +97,6 @@ export function splitQueryByPubkeys(query: NostrQuery, relayPubkeyMap: Record<st
return filtersByRelay; return filtersByRelay;
} }
// NOTE: this is a hack because nostr-tools does not expose the "challenge" field on relays
export function getChallenge(relay: AbstractRelay): string {
// @ts-expect-error
return relay.challenge;
}
export function relayRequest(relay: SimpleRelay, filters: Filter[], opts: SubscriptionOptions = {}) {
return new Promise<NostrEvent[]>((res) => {
const events: NostrEvent[] = [];
const sub = relay.subscribe(filters, {
...opts,
onevent: (e) => events.push(e),
oneose: () => {
sub.close();
res(events);
},
onclose: () => res(events),
});
});
}
// copied from nostr-tools, SimplePool#subscribeMany // copied from nostr-tools, SimplePool#subscribeMany
export function subscribeMany(relays: string[], filters: Filter[], params: SubscribeManyParams): SubCloser { export function subscribeMany(relays: string[], filters: Filter[], params: SubscribeManyParams): SubCloser {
const _knownIds = new Set<string>(); const _knownIds = new Set<string>();

View File

@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef } from 'react'; import { useCallback, useEffect, useMemo, useRef } from "react";
import { createMemoryRouter } from 'react-router-dom'; import { createMemoryRouter } from "react-router-dom";
type Router = ReturnType<typeof createMemoryRouter>; type Router = ReturnType<typeof createMemoryRouter>;
@ -11,8 +11,8 @@ export default function useRouterMarker(router: Router) {
useEffect(() => { useEffect(() => {
return router.subscribe((event) => { return router.subscribe((event) => {
if (index.current === null) return; if (index.current === null) return;
if (event.historyAction === 'PUSH') index.current++; if (event.historyAction === "PUSH") index.current++;
else if (event.historyAction === 'POP') index.current--; else if (event.historyAction === "POP") index.current--;
}); });
}, [router]); }, [router]);

View File

@ -1 +0,0 @@

View File

@ -302,13 +302,7 @@ export default function CacheRelayView() {
</Text> </Text>
<InternalRelay /> <InternalRelay />
{WasmRelay.SUPPORTED && <WasmWorkerRelay />} {WasmRelay.SUPPORTED && <WasmWorkerRelay />}
{ {navigator.userAgent.includes("Android") ? <CitrineRelay /> : <NostrRelayTray />}
navigator.userAgent.includes("Android") ? (
<CitrineRelay />
) : (
<NostrRelayTray />
)
}
{window.satellite && <SatelliteRelay />} {window.satellite && <SatelliteRelay />}
{window.CACHE_RELAY_ENABLED && <HostedRelay />} {window.CACHE_RELAY_ENABLED && <HostedRelay />}
<Button w="full" variant="link" p="4" onClick={showAdvanced.onToggle}> <Button w="full" variant="link" p="4" onClick={showAdvanced.onToggle}>

View File

@ -1,3 +1,4 @@
import { useLocalStorage } from "react-use";
import { import {
Flex, Flex,
FormControl, FormControl,
@ -13,12 +14,14 @@ import {
FormErrorMessage, FormErrorMessage,
Code, Code,
Switch, Switch,
Select,
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import { useFormContext } from "react-hook-form"; import { useFormContext } from "react-hook-form";
import { safeUrl } from "../../helpers/parse"; import { safeUrl } from "../../helpers/parse";
import { AppSettings } from "../../services/settings/migrations"; import { AppSettings } from "../../services/settings/migrations";
import { createRequestProxyUrl } from "../../helpers/request"; import { createRequestProxyUrl } from "../../helpers/request";
import { SpyIcon } from "../../components/icons"; import { SpyIcon } from "../../components/icons";
import { RelayAuthMode } from "../../classes/relay-pool";
async function validateInvidiousUrl(url?: string) { async function validateInvidiousUrl(url?: string) {
if (!url) return true; if (!url) return true;
@ -45,6 +48,10 @@ async function validateRequestProxy(url?: string) {
export default function PrivacySettings() { export default function PrivacySettings() {
const { register, formState } = useFormContext<AppSettings>(); const { register, formState } = useFormContext<AppSettings>();
const [defaultAuthMode, setDefaultAuthMode] = useLocalStorage<RelayAuthMode>("default-relay-auth-mode", "ask", {
raw: true,
});
return ( return (
<AccordionItem> <AccordionItem>
<h2> <h2>
@ -58,6 +65,23 @@ export default function PrivacySettings() {
</h2> </h2>
<AccordionPanel> <AccordionPanel>
<Flex direction="column" gap="4"> <Flex direction="column" gap="4">
<FormControl>
<FormLabel>Default authorization behavior</FormLabel>
<Select
size="sm"
w="xs"
rounded="md"
flexShrink={0}
value={defaultAuthMode || "ask"}
onChange={(e) => setDefaultAuthMode(e.target.value as RelayAuthMode)}
>
<option value="always">Always authenticate</option>
<option value="ask">Ask every time</option>
<option value="never">Never authenticate</option>
</Select>
<FormHelperText>How should the app handle relays requesting identification</FormHelperText>
</FormControl>
<FormControl isInvalid={!!formState.errors.twitterRedirect}> <FormControl isInvalid={!!formState.errors.twitterRedirect}>
<FormLabel>Nitter instance</FormLabel> <FormLabel>Nitter instance</FormLabel>
<Input <Input

View File

@ -1,8 +1,11 @@
import { import {
Badge,
Box, Box,
Flex, Flex,
Heading, Link,
LinkBox, LinkBox,
Select,
SimpleGrid,
Spacer, Spacer,
Tab, Tab,
TabIndicator, TabIndicator,
@ -16,32 +19,82 @@ import {
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import { Link as RouterLink } from "react-router-dom"; import { Link as RouterLink } from "react-router-dom";
import { AbstractRelay } from "nostr-tools"; import { AbstractRelay } from "nostr-tools";
import { useLocalStorage } from "react-use";
import relayPoolService from "../../../services/relay-pool"; import relayPoolService from "../../../services/relay-pool";
import { RelayFavicon } from "../../../components/relay-favicon"; import { RelayFavicon } from "../../../components/relay-favicon";
import { RelayStatus } from "../../../components/relay-status";
import HoverLinkOverlay from "../../../components/hover-link-overlay"; import HoverLinkOverlay from "../../../components/hover-link-overlay";
import { localRelay } from "../../../services/local-relay"; import { localRelay } from "../../../services/local-relay";
import useSubjects from "../../../hooks/use-subjects"; import useSubjects from "../../../hooks/use-subjects";
import { IconRelayAuthButton, useRelayAuthMethod } from "../../../components/relays/relay-auth-button";
import RelayConnectSwitch from "../../../components/relays/relay-connect-switch";
import useRouteSearchValue from "../../../hooks/use-route-search-value";
import processManager from "../../../services/process-manager";
import { RelayAuthMode } from "../../../classes/relay-pool";
import Timestamp from "../../../components/timestamp"; import Timestamp from "../../../components/timestamp";
function RelayRow({ relay }: { relay: AbstractRelay }) { function RelayCard({ relay }: { relay: AbstractRelay }) {
return ( return (
<LinkBox display="flex" gap="2" p="2" alignItems="center"> <Flex gap="2" p="2" alignItems="center" borderWidth={1} rounded="md">
<RelayFavicon relay={relay.url} size="sm" mr="2" /> <RelayFavicon relay={relay.url} size="sm" mr="2" />
<HoverLinkOverlay as={RouterLink} to={`/r/${encodeURIComponent(relay.url)}`} isTruncated fontWeight="bold"> <Link as={RouterLink} to={`/r/${encodeURIComponent(relay.url)}`} isTruncated fontWeight="bold" py="1" pr="10">
{relay.url} {relay.url}
</HoverLinkOverlay> </Link>
<Spacer /> <Spacer />
<RelayStatus relay={relay} /> <IconRelayAuthButton relay={relay} size="sm" variant="ghost" />
</LinkBox> <RelayConnectSwitch relay={relay} />
</Flex>
); );
} }
function RelayAuthCard({ relay }: { relay: AbstractRelay }) {
const { authenticated } = useRelayAuthMethod(relay);
const processes = processManager.getRootProcessesForRelay(relay);
const [authMode, setAuthMode] = useLocalStorage<RelayAuthMode>(
relayPoolService.getRelayAuthStorageKey(relay),
"ask",
{ raw: true },
);
return (
<Flex gap="2" p="2" alignItems="center" borderWidth={1} rounded="md">
<RelayFavicon relay={relay.url} size="sm" mr="2" />
<Box isTruncated>
<Link as={RouterLink} to={`/r/${encodeURIComponent(relay.url)}`} fontWeight="bold">
{relay.url}
</Link>
<br />
{authenticated ? <Badge colorScheme="green">Authenticated</Badge> : <Text>{processes.size} Processes</Text>}
</Box>
<Spacer />
<Select
size="sm"
w="auto"
rounded="md"
flexShrink={0}
value={authMode || "ask"}
onChange={(e) => setAuthMode(e.target.value as RelayAuthMode)}
>
<option value="always">Always</option>
<option value="ask">Ask</option>
<option value="never">Never</option>
</Select>
<IconRelayAuthButton relay={relay} variant="ghost" flexShrink={0} />
</Flex>
);
}
const TABS = ["relays", "auth", "notices"];
export default function TaskManagerRelays() { export default function TaskManagerRelays() {
const update = useForceUpdate(); const update = useForceUpdate();
useInterval(update, 2000); useInterval(update, 2000);
const { value: tab, setValue: setTab } = useRouteSearchValue("tab", TABS[0]);
const tabIndex = TABS.indexOf(tab);
const relays = Array.from(relayPoolService.relays.values()) const relays = Array.from(relayPoolService.relays.values())
.filter((r) => r !== localRelay) .filter((r) => r !== localRelay)
.sort((a, b) => +b.connected - +a.connected || a.url.localeCompare(b.url)); .sort((a, b) => +b.connected - +a.connected || a.url.localeCompare(b.url));
@ -50,34 +103,40 @@ export default function TaskManagerRelays() {
.flat() .flat()
.sort((a, b) => b.date - a.date); .sort((a, b) => b.date - a.date);
const challenges = Array.from(relayPoolService.challenges.entries()).filter(([r, c]) => r.connected && !!c.value);
return ( return (
<Tabs position="relative" variant="unstyled"> <Tabs position="relative" variant="unstyled" index={tabIndex} onChange={(i) => setTab(TABS[i])} isLazy>
<TabList> <TabList>
<Tab>Relays ({relays.length})</Tab> <Tab>Relays ({relays.length})</Tab>
<Tab>Authentication ({challenges.length})</Tab>
<Tab>Notices ({notices.length})</Tab> <Tab>Notices ({notices.length})</Tab>
</TabList> </TabList>
<TabIndicator mt="-1.5px" height="2px" bg="primary.500" borderRadius="1px" /> <TabIndicator mt="-1.5px" height="2px" bg="primary.500" borderRadius="1px" />
<TabPanels> <TabPanels>
<TabPanel p="0"> <TabPanel p="0">
<Flex direction="column"> <SimpleGrid spacing="2" columns={{ base: 1, md: 2 }} p="2">
{localRelay instanceof AbstractRelay && <RelayRow relay={localRelay} />} {localRelay instanceof AbstractRelay && <RelayCard relay={localRelay} />}
{relays.map((relay) => ( {relays.map((relay) => (
<RelayRow key={relay.url} relay={relay} /> <RelayCard key={relay.url} relay={relay} />
))} ))}
</Flex> </SimpleGrid>
</TabPanel>
<TabPanel p="0">
<SimpleGrid spacing="2" columns={{ base: 1, md: 2 }} p="2">
{challenges.map(([relay, challenge]) => (
<RelayAuthCard key={relay.url} relay={relay} />
))}
</SimpleGrid>
</TabPanel> </TabPanel>
<TabPanel p="0"> <TabPanel p="0">
{notices.map((notice) => ( {notices.map((notice) => (
<LinkBox key={notice.date + notice.message} px="2" py="1"> <LinkBox key={notice.date + notice.message} px="2" py="1" fontFamily="monospace">
<HoverLinkOverlay <HoverLinkOverlay as={RouterLink} to={`/r/${encodeURIComponent(notice.relay.url)}`} fontWeight="bold">
as={RouterLink}
to={`/r/${encodeURIComponent(notice.relay.url)}`}
fontFamily="monospace"
fontWeight="bold"
>
{notice.relay.url} {notice.relay.url}
</HoverLinkOverlay> </HoverLinkOverlay>
<Timestamp timestamp={notice.date} ml={2} />
<Text fontFamily="monospace">{notice.message}</Text> <Text fontFamily="monospace">{notice.message}</Text>
</LinkBox> </LinkBox>
))} ))}

View File

@ -1,7 +1,5 @@
import { useCallback, useMemo } from "react"; import { useMemo } from "react";
import { import {
Button,
ButtonGroup,
Flex, Flex,
Heading, Heading,
Spacer, Spacer,
@ -14,7 +12,6 @@ import {
Text, Text,
useForceUpdate, useForceUpdate,
useInterval, useInterval,
useToast,
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
@ -25,12 +22,12 @@ import useSubject from "../../../hooks/use-subject";
import ProcessBranch from "../processes/process/process-tree"; import ProcessBranch from "../processes/process/process-tree";
import processManager from "../../../services/process-manager"; import processManager from "../../../services/process-manager";
import RelayAuthButton from "../../../components/relays/relay-auth-button"; import { IconRelayAuthButton } from "../../../components/relays/relay-auth-button";
import { RelayStatus } from "../../../components/relay-status"; import { RelayStatus } from "../../../components/relays/relay-status";
import Timestamp from "../../../components/timestamp"; import Timestamp from "../../../components/timestamp";
import RelayConnectSwitch from "../../../components/relays/relay-connect-switch";
export default function InspectRelayView() { export default function InspectRelayView() {
const toast = useToast();
const { url } = useParams(); const { url } = useParams();
if (!url) throw new Error("Missing url param"); if (!url) throw new Error("Missing url param");
@ -38,15 +35,6 @@ export default function InspectRelayView() {
useInterval(update, 500); useInterval(update, 500);
const relay = useMemo(() => relayPoolService.requestRelay(url, false), [url]); const relay = useMemo(() => relayPoolService.requestRelay(url, false), [url]);
const connecting = useSubject(relayPoolService.connecting.get(relay));
const connect = useCallback(async () => {
try {
await relayPoolService.requestConnect(relay, false);
} catch (error) {
if (error instanceof Error) toast({ status: "error", description: error.message });
}
}, [toast]);
const rootProcesses = processManager.getRootProcessesForRelay(relay); const rootProcesses = processManager.getRootProcessesForRelay(relay);
const notices = useSubject(relayPoolService.notices.get(relay)); const notices = useSubject(relayPoolService.notices.get(relay));
@ -58,12 +46,8 @@ export default function InspectRelayView() {
<Heading size="md">{url}</Heading> <Heading size="md">{url}</Heading>
<RelayStatus relay={relay} /> <RelayStatus relay={relay} />
<Spacer /> <Spacer />
<ButtonGroup size="sm"> <IconRelayAuthButton relay={relay} size="sm" variant="ghost" />
<RelayAuthButton relay={relay} /> <RelayConnectSwitch relay={relay} />
<Button variant="outline" colorScheme={connecting ? "orange" : "green"} onClick={connect}>
{connecting ? "Connecting..." : relay.connected ? "Connected" : "Connect"}
</Button>
</ButtonGroup>
</Flex> </Flex>
<Tabs position="relative" variant="unstyled"> <Tabs position="relative" variant="unstyled">
@ -84,9 +68,9 @@ export default function InspectRelayView() {
))} ))}
</TabPanel> </TabPanel>
<TabPanel p="0"> <TabPanel p="0">
{notices.map((notice) => ( {notices.map((notice, i) => (
<Text fontFamily="monospace" key={notice.date + notice.message}> <Text fontFamily="monospace" key={notice.date + i}>
[<Timestamp timestamp={notice.date} />] {notice.message} {notice.message} <Timestamp timestamp={notice.date} color="gray.500" />
</Text> </Text>
))} ))}
</TabPanel> </TabPanel>

View File

@ -6218,6 +6218,20 @@ nostr-idb@^2.1.4:
idb "^8.0.0" idb "^8.0.0"
nostr-tools "^2.1.3" nostr-tools "^2.1.3"
nostr-tools@2.5.2:
version "2.5.2"
resolved "https://registry.yarnpkg.com/nostr-tools/-/nostr-tools-2.5.2.tgz#54b445380ac2a7740ad90ed3b044bca93ecf23bd"
integrity sha512-Ls2FKh694eudBye6q89yJ5JhXjQle1MWp1yD2sBZ5j9M3IOBEW8ia9IED5W6daSAjlT/Z/pV77yTkdF45c1Rbg==
dependencies:
"@noble/ciphers" "^0.5.1"
"@noble/curves" "1.2.0"
"@noble/hashes" "1.3.1"
"@scure/base" "1.1.1"
"@scure/bip32" "1.3.1"
"@scure/bip39" "1.2.1"
optionalDependencies:
nostr-wasm v0.1.0
nostr-tools@^1.17.0: nostr-tools@^1.17.0:
version "1.17.0" version "1.17.0"
resolved "https://registry.yarnpkg.com/nostr-tools/-/nostr-tools-1.17.0.tgz#b6f62e32fedfd9e68ec0a7ce57f74c44fc768e8c" resolved "https://registry.yarnpkg.com/nostr-tools/-/nostr-tools-1.17.0.tgz#b6f62e32fedfd9e68ec0a7ce57f74c44fc768e8c"
@ -6258,20 +6272,6 @@ nostr-tools@^2.3.2:
optionalDependencies: optionalDependencies:
nostr-wasm v0.1.0 nostr-wasm v0.1.0
nostr-tools@^2.5.2:
version "2.5.2"
resolved "https://registry.yarnpkg.com/nostr-tools/-/nostr-tools-2.5.2.tgz#54b445380ac2a7740ad90ed3b044bca93ecf23bd"
integrity sha512-Ls2FKh694eudBye6q89yJ5JhXjQle1MWp1yD2sBZ5j9M3IOBEW8ia9IED5W6daSAjlT/Z/pV77yTkdF45c1Rbg==
dependencies:
"@noble/ciphers" "^0.5.1"
"@noble/curves" "1.2.0"
"@noble/hashes" "1.3.1"
"@scure/base" "1.1.1"
"@scure/bip32" "1.3.1"
"@scure/bip39" "1.2.1"
optionalDependencies:
nostr-wasm v0.1.0
nostr-wasm@^0.1.0, nostr-wasm@v0.1.0: nostr-wasm@^0.1.0, nostr-wasm@v0.1.0:
version "0.1.0" version "0.1.0"
resolved "https://registry.yarnpkg.com/nostr-wasm/-/nostr-wasm-0.1.0.tgz#17af486745feb2b7dd29503fdd81613a24058d94" resolved "https://registry.yarnpkg.com/nostr-wasm/-/nostr-wasm-0.1.0.tgz#17af486745feb2b7dd29503fdd81613a24058d94"