mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-09-19 12:00:32 +02:00
Add NIP-46 connection initiated by client
This commit is contained in:
5
.changeset/strong-balloons-wash.md
Normal file
5
.changeset/strong-balloons-wash.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Add NIP-46 connection initiated by client
|
@@ -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, []);
|
||||
}
|
||||
|
@@ -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>
|
||||
);
|
||||
}
|
||||
|
@@ -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>
|
||||
|
Reference in New Issue
Block a user