diff --git a/.changeset/strong-balloons-wash.md b/.changeset/strong-balloons-wash.md new file mode 100644 index 000000000..2cdd04043 --- /dev/null +++ b/.changeset/strong-balloons-wash.md @@ -0,0 +1,5 @@ +--- +"nostrudel": minor +--- + +Add NIP-46 connection initiated by client diff --git a/src/services/nostr-connect.ts b/src/services/nostr-connect.ts index 6d3f69a7e..f58813493 100644 --- a/src/services/nostr-connect.ts +++ b/src/services/nostr-connect.ts @@ -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 { + if (!this.pubkey) throw new Error("pubkey not set"); const id = nanoid(8); const request: NostrConnectRequest = { 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, []); } diff --git a/src/views/signin/nostr-connect.tsx b/src/views/signin/nostr-connect.tsx index 3ca9fb189..d06e8da27 100644 --- a/src/views/signin/nostr-connect.tsx +++ b/src/views/signin/nostr-connect.tsx @@ -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(null); + const [relay, setRelay] = useState("wss://relay.nsec.app/"); + const [client, setClient] = useState(); + 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 ? ( + <> + + + urlInputRef.current?.select()} + onChange={() => {}} + /> + + + {listening && ( + + + Waiting for connection + + )} + + ) : ( + <> + + Connection Relay + setRelay(e.target.value)} /> + + + + )} + + + + + ); +} 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(); const handleSubmit: React.FormEventHandler = 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 = ; + } else { + content = ( + <> + {loading && {loading}} + {!loading && ( + + Remote Signer URL + + setConnection(e.target.value)} + autoComplete="off" + /> + setConnection(v)} /> + + + )} + + + + + + + + OR + + + + + ); + } + return ( - - {loading && {loading}} - {!loading && ( - - Connect URI - - setUri(e.target.value)} - autoComplete="off" - /> - setUri(v)} /> - - A bunker connect URI - - )} - - - - + + {content} ); } diff --git a/src/views/signin/start.tsx b/src/views/signin/start.tsx index 4ebaa9cd8..2d2c64fc2 100644 --- a/src/views/signin/start.tsx +++ b/src/views/signin/start.tsx @@ -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() { /> )} - - {advanced.isOpen && ( - <> - - - - - )} + + + OR + + + + + + + Don't have an account?