Add NIP-46 connection initiated by client

This commit is contained in:
hzrd149
2024-04-18 08:53:32 -05:00
parent 368cc04614
commit 24c664eb08
4 changed files with 214 additions and 84 deletions

View File

@@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add NIP-46 connection initiated by client

View File

@@ -8,7 +8,6 @@ import { getPubkeyFromDecodeResult, isHexKey, normalizeToHexPubkey } from "../he
import { logger } from "../helpers/debug";
import { DraftNostrEvent, NostrEvent, isPTag } from "../types/nostr-event";
import createDefer, { Deferred } from "../classes/deferred";
import { truncatedId } from "../helpers/nostr/event";
import { NostrConnectAccount } from "./account";
import { safeRelayUrl } from "../helpers/relay";
import { alwaysVerify } from "./verify-event";
@@ -65,7 +64,7 @@ export class NostrConnectClient {
log = logger.extend("NostrConnectClient");
isConnected = false;
pubkey: string;
pubkey?: string;
provider?: string;
relays: string[];
@@ -74,8 +73,8 @@ export class NostrConnectClient {
supportedMethods: NostrConnectMethod[] | undefined;
constructor(pubkey: string, relays: string[], secretKey?: string, provider?: string) {
this.sub = new NostrMultiSubscription(`${pubkey}-nostr-connect`);
constructor(pubkey?: string, relays: string[] = [], secretKey?: string, provider?: string) {
this.sub = new NostrMultiSubscription();
this.pubkey = pubkey;
this.relays = relays;
this.provider = provider;
@@ -122,6 +121,17 @@ export class NostrConnectClient {
try {
const responseStr = await nip04.decrypt(this.secretKey, event.pubkey, event.content);
const response = JSON.parse(responseStr);
// handle client connections
if (!this.pubkey && response.result === "ack") {
this.log("Got ack response from", event.pubkey);
this.pubkey = event.pubkey;
this.isConnected = true;
this.listenPromise?.resolve(response.result);
this.listenPromise = null;
return;
}
if (response.id) {
const p = this.requests.get(response.id);
if (!p) return;
@@ -146,6 +156,7 @@ export class NostrConnectClient {
}
private createEvent(content: string, target = this.pubkey, kind = kinds.NostrConnect) {
if (!target) throw new Error("invalid target pubkey");
return finalizeEvent(
{
kind,
@@ -161,6 +172,7 @@ export class NostrConnectClient {
params: RequestParams[T],
kind = kinds.NostrConnect,
): Promise<ResponseResults[T]> {
if (!this.pubkey) throw new Error("pubkey not set");
const id = nanoid(8);
const request: NostrConnectRequest<T> = { id, method, params };
const encrypted = await nip04.encrypt(this.secretKey, this.pubkey, JSON.stringify(request));
@@ -191,6 +203,7 @@ export class NostrConnectClient {
}
async connect(token?: string) {
if (!this.pubkey) throw new Error("pubkey not set");
await this.open();
try {
const result = await this.makeRequest(NostrConnectMethod.Connect, [this.pubkey, token || "", Perms]);
@@ -203,6 +216,14 @@ export class NostrConnectClient {
}
}
listenPromise: Deferred<"ack"> | null = null;
listen(): Promise<"ack"> {
if (this.pubkey) throw new Error("Cant listen if there is already a pubkey");
this.open();
this.listenPromise = createDefer();
return this.listenPromise;
}
async createAccount(name: string, domain: string, email?: string) {
await this.open();
@@ -228,6 +249,8 @@ export class NostrConnectClient {
disconnect() {
return this.makeRequest(NostrConnectMethod.Disconnect, []);
}
// methods
getPublicKey() {
return this.makeRequest(NostrConnectMethod.GetPublicKey, []);
}

View File

@@ -1,13 +1,14 @@
import { useState } from "react";
import { ReactNode, useCallback, useMemo, useRef, useState } from "react";
import {
Button,
Divider,
Flex,
FormControl,
FormHelperText,
FormLabel,
IconButton,
Input,
Spinner,
Text,
useDisclosure,
useToast,
} from "@chakra-ui/react";
import { useNavigate } from "react-router-dom";
@@ -15,11 +16,87 @@ import { useNavigate } from "react-router-dom";
import accountService from "../../services/account";
import nostrConnectService, { NostrConnectClient } from "../../services/nostr-connect";
import QRCodeScannerButton from "../../components/qr-code/qr-code-scanner-button";
import { RelayUrlInput } from "../../components/relay-url-input";
import QrCodeSvg from "../../components/qr-code/qr-code-svg";
import { CopyIconButton } from "../../components/copy-icon-button";
function ClientConnectForm() {
const navigate = useNavigate();
const urlInputRef = useRef<HTMLInputElement | null>(null);
const [relay, setRelay] = useState("wss://relay.nsec.app/");
const [client, setClient] = useState<NostrConnectClient>();
const [listening, setListening] = useState(false);
const connectionURL = useMemo(() => {
if (!client || !relay) return "";
const host = location.protocol + "//" + location.host;
const params = new URLSearchParams();
params.set("relay", relay);
params.set("name", "noStrudel");
params.set("url", host);
params.set("image", new URL("/apple-touch-icon.png", host).toString());
return `nostrconnect://${client.publicKey}?` + params.toString();
}, [relay, client]);
const create = useCallback(() => {
const c = new NostrConnectClient(undefined, [relay]);
setClient(c);
c.listen().then(() => {
nostrConnectService.saveClient(c);
accountService.addFromNostrConnect(c);
accountService.switchAccount(c.pubkey!);
});
setListening(true);
}, [relay]);
return (
<>
{client ? (
<>
<QrCodeSvg content={connectionURL} />
<Flex gap="2">
<Input
value={connectionURL}
ref={urlInputRef}
onFocus={() => urlInputRef.current?.select()}
onChange={() => {}}
/>
<CopyIconButton value={connectionURL} aria-label="Copy connection URL" />
</Flex>
{listening && (
<Flex gap="2" justifyContent="center" alignItems="center" py="2">
<Spinner />
<Text>Waiting for connection</Text>
</Flex>
)}
</>
) : (
<>
<FormControl>
<FormLabel htmlFor="input">Connection Relay</FormLabel>
<RelayUrlInput value={relay} onChange={(e) => setRelay(e.target.value)} />
</FormControl>
<Button colorScheme="primary" onClick={create}>
Create Connection URL
</Button>
</>
)}
<Flex justifyContent="space-between" gap="2">
<Button variant="link" onClick={() => navigate("../")} py="2">
Back
</Button>
</Flex>
</>
);
}
export default function LoginNostrConnectView() {
const navigate = useNavigate();
const toast = useToast();
const [uri, setUri] = useState("");
const [connection, setConnection] = useState("");
const fromClient = useDisclosure();
const [loading, setLoading] = useState<string | undefined>();
const handleSubmit: React.FormEventHandler<HTMLDivElement> = async (e) => {
@@ -28,55 +105,74 @@ export default function LoginNostrConnectView() {
try {
setLoading("Connecting...");
let client: NostrConnectClient;
if (uri.startsWith("bunker://")) {
if (uri.includes("@")) client = nostrConnectService.fromBunkerAddress(uri);
else client = nostrConnectService.fromBunkerURI(uri);
if (connection.startsWith("bunker://")) {
if (connection.includes("@")) client = nostrConnectService.fromBunkerAddress(connection);
else client = nostrConnectService.fromBunkerURI(connection);
await client.connect(new URL(uri).searchParams.get('secret') ?? undefined);
} else if (uri.startsWith("npub")) {
client = nostrConnectService.fromBunkerToken(uri);
const [npub, hexToken] = uri.split("#");
await client.connect(new URL(connection).searchParams.get("secret") ?? undefined);
} else if (connection.startsWith("npub")) {
client = nostrConnectService.fromBunkerToken(connection);
const [npub, hexToken] = connection.split("#");
await client.connect(hexToken);
} else throw new Error("Unknown format");
nostrConnectService.saveClient(client);
accountService.addFromNostrConnect(client);
accountService.switchAccount(client.pubkey);
accountService.switchAccount(client.pubkey!);
} catch (e) {
if (e instanceof Error) toast({ status: "error", description: e.message });
}
setLoading(undefined);
};
let content: ReactNode = null;
if (fromClient.isOpen) {
content = <ClientConnectForm />;
} else {
content = (
<>
{loading && <Text fontSize="lg">{loading}</Text>}
{!loading && (
<FormControl>
<FormLabel htmlFor="input">Remote Signer URL</FormLabel>
<Flex gap="2">
<Input
id="nostr-connect"
name="nostr-connect"
placeholder="bunker://<pubkey>?relay=wss://relay.example.com"
isRequired
value={connection}
onChange={(e) => setConnection(e.target.value)}
autoComplete="off"
/>
<QRCodeScannerButton onData={(v) => setConnection(v)} />
</Flex>
</FormControl>
)}
<Flex justifyContent="space-between" gap="2">
<Button variant="link" onClick={() => navigate("../")} py="2">
Back
</Button>
<Button colorScheme="primary" type="submit" isLoading={!!loading}>
Connect
</Button>
</Flex>
<Flex w="full" alignItems="center" gap="4">
<Divider />
<Text fontWeight="bold">OR</Text>
<Divider />
</Flex>
<Button variant="outline" onClick={fromClient.onOpen}>
Create Connection URL
</Button>
</>
);
}
return (
<Flex as="form" direction="column" gap="4" onSubmit={handleSubmit} w="full">
{loading && <Text fontSize="lg">{loading}</Text>}
{!loading && (
<FormControl>
<FormLabel htmlFor="input">Connect URI</FormLabel>
<Flex gap="2">
<Input
id="nostr-connect"
name="nostr-connect"
placeholder="bunker://<pubkey>?relay=wss://relay.example.com"
isRequired
value={uri}
onChange={(e) => setUri(e.target.value)}
autoComplete="off"
/>
<QRCodeScannerButton onData={(v) => setUri(v)} />
</Flex>
<FormHelperText>A bunker connect URI</FormHelperText>
</FormControl>
)}
<Flex justifyContent="space-between" gap="2">
<Button variant="link" onClick={() => navigate("../")}>
Back
</Button>
<Button colorScheme="primary" ml="auto" type="submit" isLoading={!!loading}>
Connect
</Button>
</Flex>
<Flex as="form" direction="column" gap="2" onSubmit={handleSubmit} w="full">
{content}
</Flex>
);
}

View File

@@ -1,22 +1,9 @@
import { useState } from "react";
import {
Badge,
Button,
ButtonGroup,
Flex,
IconButton,
Link,
Spinner,
Text,
useDisclosure,
useToast,
} from "@chakra-ui/react";
import { Badge, Button, ButtonGroup, Divider, Flex, IconButton, Link, Spinner, Text, useToast } from "@chakra-ui/react";
import { Link as RouterLink, useLocation } from "react-router-dom";
import Key01 from "../../components/icons/key-01";
import Diamond01 from "../../components/icons/diamond-01";
import ChevronDown from "../../components/icons/chevron-down";
import ChevronUp from "../../components/icons/chevron-up";
import UsbFlashDrive from "../../components/icons/usb-flash-drive";
import HelpCircle from "../../components/icons/help-circle";
@@ -27,12 +14,13 @@ import amberSignerService from "../../services/amber-signer";
import { AtIcon } from "../../components/icons";
import { getRelaysFromExt } from "../../helpers/nip07";
import { safeRelayUrls } from "../../helpers/relay";
import Package from "../../components/icons/package";
import Eye from "../../components/icons/eye";
export default function LoginStartView() {
const location = useLocation();
const toast = useToast();
const [loading, setLoading] = useState(false);
const advanced = useDisclosure();
const signinWithExtension = async () => {
if (window.nostr) {
@@ -150,31 +138,49 @@ export default function LoginStartView() {
/>
</ButtonGroup>
)}
<Button
variant="link"
onClick={advanced.onToggle}
py="2"
w="full"
rightIcon={advanced.isOpen ? <ChevronUp /> : <ChevronDown />}
>
Show Advanced
</Button>
{advanced.isOpen && (
<>
<Button as={RouterLink} to="./nostr-connect" state={location.state} w="full">
Nostr Connect / Bunker
</Button>
<Button as={RouterLink} to="./npub" state={location.state} w="full">
Public key (npub)
<Badge ml="2" colorScheme="blue">
read-only
</Badge>
</Button>
<Button as={RouterLink} to="./nsec" state={location.state} w="full">
Secret key (nsec)
</Button>
</>
)}
<Flex w="full" alignItems="center" gap="4">
<Divider />
<Text fontWeight="bold">OR</Text>
<Divider />
</Flex>
<Flex gap="2">
<Button
flexDirection="column"
h="auto"
p="4"
as={RouterLink}
to="./nostr-connect"
state={location.state}
variant="outline"
>
<Package boxSize={12} />
Nostr Connect
</Button>
<Button
flexDirection="column"
h="auto"
p="4"
as={RouterLink}
to="./nsec"
state={location.state}
variant="outline"
>
<Key01 boxSize={12} />
Private key
</Button>
<Button
flexDirection="column"
h="auto"
p="4"
as={RouterLink}
to="./npub"
state={location.state}
variant="outline"
>
<Eye boxSize={12} />
Public key
</Button>
</Flex>
<Text fontWeight="bold" mt="4">
Don't have an account?
</Text>