diff --git a/README.md b/README.md index 801b02e..ee8a191 100644 --- a/README.md +++ b/README.md @@ -574,7 +574,7 @@ client.listen(async (payload) => { if (success) { // Make ZAP request - const { p: invoice, event } = await recipient.makeZapRequest( + const { pr: invoice, event } = await recipient.makeZapRequest( { relayUrls: ["wss://nostr.rocks"], amount: 1000, diff --git a/client-web/package.json b/client-web/package.json index d671c4a..6af50a0 100644 --- a/client-web/package.json +++ b/client-web/package.json @@ -23,6 +23,7 @@ "idb": "^7.1.1", "mdi-react": "^9.2.0", "nanoid": "^3.0.0", + "qrcode.react": "^3.1.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-inlinesvg": "^4.0.3", diff --git a/client-web/src/components/event.tsx b/client-web/src/components/event.tsx index 1899eeb..3bb2a9f 100644 --- a/client-web/src/components/event.tsx +++ b/client-web/src/components/event.tsx @@ -26,6 +26,7 @@ import { NSFWContentToggle } from "./event/nsfw-toggle"; import { EventCardFooter } from "./event/card-footer"; import { EventBanner } from "./event/banner"; import { User } from "./user"; +import { ZapModal } from "./event/zap-modal"; export interface EventProps { data: LightProcessedEvent; @@ -90,6 +91,12 @@ export function Event({ data, level }: EventProps) { onClose: onRepliesClose, } = useDisclosure(); + const { + isOpen: zapModalIsOpen, + onOpen: onZapModalOpen, + onClose: onZapModalClose, + } = useDisclosure(); + const toast = useToast(); useEffect(() => { @@ -158,7 +165,10 @@ export function Event({ data, level }: EventProps) { /** * Quote or react to an event */ - const newAction = async (type: "quote" | "reaction", reaction?: string) => { + const newAction = async ( + type: "quote" | "reaction" | "zap", + reaction?: string + ) => { const relay = await relatedRelay(); let ev: NEvent; @@ -181,6 +191,9 @@ export function Event({ data, level }: EventProps) { relayUrl: relay ? relay.url : undefined, }); break; + case "zap": + onZapModalOpen(); + return; default: return; } @@ -275,6 +288,12 @@ export function Event({ data, level }: EventProps) { isOpen={isInfoModalOpen} onClose={onInfoModalClose} /> + ); } diff --git a/client-web/src/components/event/action-buttons.tsx b/client-web/src/components/event/action-buttons.tsx index 4b15689..b450a10 100644 --- a/client-web/src/components/event/action-buttons.tsx +++ b/client-web/src/components/event/action-buttons.tsx @@ -19,7 +19,7 @@ export interface EventActionButtonsProps { isReplyOpen: boolean; onReplyOpen: () => void; onReplyClose: () => void; - onAction: (type: "quote" | "reaction", reaction?: string) => void; + onAction: (type: "quote" | "reaction" | "zap", reaction?: string) => void; } export function EventActionButtons({ @@ -87,6 +87,7 @@ export function EventActionButtons({ color="gray.500" aria-label="ZAP" leftIcon={} + onClick={() => onAction("zap")} isDisabled={!isReady} > {zapReceiptCount} ({zapReceiptAmount}) diff --git a/client-web/src/components/event/card-footer.tsx b/client-web/src/components/event/card-footer.tsx index 45d273c..e7ff9a3 100644 --- a/client-web/src/components/event/card-footer.tsx +++ b/client-web/src/components/event/card-footer.tsx @@ -34,7 +34,7 @@ export interface CardFooterProps { onInfoModalOpen: () => void; onInfoModalClose: () => void; - onAction: (type: "quote" | "reaction", reaction?: string) => void; + onAction: (type: "quote" | "reaction" | "zap", reaction?: string) => void; } export const EventCardFooter = ({ diff --git a/client-web/src/components/event/zap-modal.tsx b/client-web/src/components/event/zap-modal.tsx new file mode 100644 index 0000000..7d1f307 --- /dev/null +++ b/client-web/src/components/event/zap-modal.tsx @@ -0,0 +1,48 @@ +import { + Button, + Modal, + ModalBody, + ModalCloseButton, + ModalContent, + ModalFooter, + ModalHeader, + ModalOverlay, +} from "@chakra-ui/react"; +import { Zap } from "./zap"; +import { UserBase, EventBaseSigned } from "@nostr-ts/common"; + +interface ZapModalProps { + user: UserBase; + relatedEvent?: EventBaseSigned; + isOpen: boolean; + onClose: () => void; +} + +export const ZapModal = ({ + user, + relatedEvent, + isOpen, + onClose, +}: ZapModalProps) => { + return ( + + + + Send sats (WIP) + + + + + + + + + + ); +}; diff --git a/client-web/src/components/event/zap.tsx b/client-web/src/components/event/zap.tsx new file mode 100644 index 0000000..1e133dd --- /dev/null +++ b/client-web/src/components/event/zap.tsx @@ -0,0 +1,283 @@ +import { + EventBaseSigned, + LnurlInvoiceResponse, + NEvent, + NewZapRequest, + UserBase, + encodeLnurl, + iNewZAPRequest, + makeLnurlZapRequestUrl, +} from "@nostr-ts/common"; +import { NUser } from "@nostr-ts/web"; +import { useEffect, useState } from "react"; +import { useNClient } from "../../state/client"; +import { + Box, + Button, + FormLabel, + Input, + InputGroup, + InputLeftAddon, + Text, + VStack, + useToast, +} from "@chakra-ui/react"; +import { QRCodeSVG } from "qrcode.react"; + +interface ZapProps { + user: UserBase; + relayUrl?: string; + relatedEvent?: EventBaseSigned; + onConfirmPayment: () => void; +} + +export const Zap = ({ + user, + relatedEvent, + onConfirmPayment, + relayUrl, +}: ZapProps) => { + const [nUser, setNUser] = useState(undefined); + const [hasLud, setHasLud] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [amount, setAmount] = useState(0); + const [bolt11, setBolt11] = useState(""); + const [description, setDescription] = useState(""); + + const toast = useToast(); + + const [zapRequestResult, setZapRequestResult] = useState< + | { + // bech32-serialized lightning invoice + pr: string; + event: NEvent; + } + | undefined + >(undefined); + + const requestZap = async () => { + if (!nUser) return; + if (amount <= 0) { + toast({ + title: "Error", + description: "Please enter a valid amount", + status: "error", + duration: 9000, + isClosable: true, + }); + return; + } + setIsLoading(true); + const tempUser = nUser; + if (!tempUser) return; + const lud = tempUser?.getLud16Or06Url(); + if (!lud) return; + + if (!tempUser?.hasZapInfo()) { + try { + await tempUser?.getZapCallbackInfo(lud.url); + } catch (e) { + console.error(e); + const error = e as Error; + toast({ + title: "Error", + description: `Could not get zap callback info: ${error.message}`, + status: "error", + duration: 9000, + isClosable: true, + }); + + setIsLoading(false); + return; + } + } + + const lnurl = + lud.type === "lud16" + ? encodeLnurl(tempUser.getLud16() as string) + : (tempUser.getLud06() as string); + + const reqNotSigned: iNewZAPRequest = { + amount, + relayUrls: relayUrl ? [relayUrl] : [], + recipientPubkey: tempUser?.pubkey, + lnurl, + }; + + const nEv = NewZapRequest(reqNotSigned); + + // SIGN REQUEST + const nEvSigned = await useNClient.getState().signEvent(nEv); + + const invoiceUrl = makeLnurlZapRequestUrl({ + callback: tempUser?.lightningZapInfo?.callback, + amount: reqNotSigned.amount, + event: nEvSigned.toURI(), + lnurl: reqNotSigned.lnurl, + }); + + let inv: LnurlInvoiceResponse | undefined; + + try { + inv = await tempUser.getLightningInvoice(reqNotSigned, invoiceUrl); + } catch (e) { + console.error(e); + const error = e as Error; + toast({ + title: "Error", + description: `Could not get lightning invoice: ${error.message}`, + status: "error", + duration: 9000, + isClosable: true, + }); + setIsLoading(false); + return; + } + + if (inv && inv.pr) { + setZapRequestResult({ + pr: inv.pr, + event: nEvSigned, + }); + } + + setIsLoading(false); + }; + + const confirmPayment = async () => { + if (!zapRequestResult) return; + if (bolt11 === "" || description === "") { + toast({ + title: "Error", + description: "Please fill in all fields", + status: "error", + duration: 9000, + isClosable: true, + }); + return; + } + setIsLoading(true); + const ev = zapRequestResult.event; + const receipt = ev.newZapReceipt({ + bolt11, + description, + }); + + if (relatedEvent) { + receipt.addEventTag({ + eventId: relatedEvent.id, + }); + } + + await useNClient.getState().signAndSendEvent({ + event: receipt, + }); + + setIsLoading(false); + toast({ + title: "Success", + description: "Payment confirmed. Keep stracking!", + status: "success", + duration: 9000, + isClosable: true, + }); + onConfirmPayment(); + }; + + useEffect(() => { + const nUser = new NUser(user); + const hasLud = nUser.getLud16Or06Url() !== undefined; + setNUser(nUser); + setHasLud(hasLud); + }, [user]); + + const NoLud = ( + <> + {!hasLud && ( + + The user does not have a supported ligning address. + + )} + + ); + + const RequestInvoice = ( + <> + {hasLud && !zapRequestResult && ( + + + Request a ligning invoice, pay it with your ligtning wallet, and + enter the bolt11 in the next step. + + + + setAmount(parseInt(e.target.value))} + /> + + + + )} + + ); + + const PayInvoice = ( + <> + {zapRequestResult && ( + <> + + Scan or copy the invoice to your lightning wallet. + + + + + setBolt11(zapRequestResult.pr)} + mb={2} + /> + + + Bolt11 + + + Copy the bolt11 invoice receipt from your lightning wallet. + + setBolt11(e.target.value)} + placeholder="lnbc..." + /> + + Description + + setDescription(e.target.value)} + placeholder="Keep stracking ..." + mb={2} + /> + + + + )} + + ); + + return ( + + {NoLud} + {RequestInvoice} + {PayInvoice} + + ); +}; diff --git a/client-web/src/components/user.tsx b/client-web/src/components/user.tsx index d5a733c..a8af685 100644 --- a/client-web/src/components/user.tsx +++ b/client-web/src/components/user.tsx @@ -12,6 +12,13 @@ import { MenuList, IconButton, Icon, + Button, + Popover, + PopoverArrow, + PopoverBody, + PopoverCloseButton, + PopoverContent, + PopoverTrigger, } from "@chakra-ui/react"; import { Link } from "react-router-dom"; import { useNClient } from "../state/client"; @@ -24,6 +31,7 @@ import DotsVerticalCircleOutlineIcon from "mdi-react/DotsVerticalCircleOutlineIc import { ListAssignmentModal } from "./list-assignment-modal"; import { useEffect, useState } from "react"; import Avatar from "boring-avatars"; +import { ZapModal } from "./event/zap-modal"; export function User({ user: { pubkey, data }, @@ -41,6 +49,9 @@ export function User({ const picture = data && data.picture ? data.picture : ""; const banner = data && data.banner ? data.banner : undefined; const about = data && data.about ? data.about : undefined; + const lud06 = data && data.lud06 ? data.lud06 : undefined; + const lud16 = data && data.lud16 ? data.lud16 : undefined; + const nip05 = data && data.nip05 ? data.nip05 : undefined; const displayNameEqName = displayName === name; @@ -66,6 +77,27 @@ export function User({ const { isOpen, onOpen, onClose } = useDisclosure(); + // TOOO: Finish implementing this + + const { isOpen: zapModalIsOpen, onClose: onZapModalClose } = useDisclosure(); + + const LUDPopover = ({ lud, name }: { lud: string; name: string }) => { + return ( + + + + + + + + {lud && {lud}} + + + ); + }; + return ( <> {showBanner && banner && ( @@ -92,9 +124,14 @@ export function User({ {displayName} - {!displayNameEqName && {name}} + + {!displayNameEqName && name} {nip05 && nip05} + + {lud06 && } + {lud16 && } + @@ -146,6 +183,11 @@ export function User({ {showAbout && about && {about}} + ); } diff --git a/client-web/src/state/client-types.ts b/client-web/src/state/client-types.ts index 0d7770a..8bbd668 100644 --- a/client-web/src/state/client-types.ts +++ b/client-web/src/state/client-types.ts @@ -75,6 +75,7 @@ export interface NClient extends NClientBase { isLive: boolean ) => Promise; sendEvent: (events: PublishingRequest) => Promise; + signEvent: (eventWithId: NEvent) => Promise; signAndSendEvent: (event: PublishingRequest) => Promise; setMaxEvents: (max: number) => Promise; diff --git a/client-web/src/state/client.ts b/client-web/src/state/client.ts index bfb14b9..4bbce4b 100644 --- a/client-web/src/state/client.ts +++ b/client-web/src/state/client.ts @@ -674,14 +674,47 @@ export const useNClient = create((set, get) => ({ sendEvent: async (event: PublishingRequest) => { return get().store.sendEvent(event); }, + signEvent: async (event: NEvent) => { + const keystore = get().keystore; + + if (!event.pubkey) { + event.pubkey = get().keypair.publicKey; + } + + if (!event.id) { + event.generateId(); + } + + if (keystore === "localstore") { + const keypair = get().keypair; + if (!keypair) { + throw new Error("Keypair not initialized"); + } + event.sign({ + privateKey: keypair.privateKey || "", + publicKey: keypair.publicKey, + }); + return event; + } else if (keystore === "nos2x") { + if (window.nostr && window.nostr.signEvent) { + const signedEv = await window.nostr.signEvent(event.ToObj()); + if (!signedEv.sig) { + throw new Error("No signature"); + } + event.sig = signedEv.sig; + return event; + } else { + throw new Error("Nostr not initialized"); + } + } else { + throw new Error("Invalid keystore"); + } + }, signAndSendEvent: async (payload: PublishingRequest) => { const keypair = get().keypair; if (!keypair) { throw new Error("Keypair not initialized"); } - - const keystore = get().keystore; - let ev = payload.event; ev.pubkey = keypair.publicKey; ev.generateId(); @@ -733,24 +766,7 @@ export const useNClient = create((set, get) => ({ } } - if (keystore === "localstore") { - ev.sign({ - privateKey: keypair.privateKey || "", - publicKey: keypair.publicKey, - }); - } else if (keystore === "nos2x") { - if (window.nostr && window.nostr.signEvent) { - const signedEv = await window.nostr.signEvent(ev.ToObj()); - if (!signedEv.sig) { - throw new Error("No signature"); - } - ev.sig = signedEv.sig; - } else { - throw new Error("Nostr not initialized"); - } - } else { - throw new Error("Invalid keystore"); - } + ev = await get().signEvent(ev); ev.isReadyToPublishOrThrow(); diff --git a/packages/common/src/classes/event.ts b/packages/common/src/classes/event.ts index b142bf4..d833d1a 100644 --- a/packages/common/src/classes/event.ts +++ b/packages/common/src/classes/event.ts @@ -847,7 +847,10 @@ export function NewZapRequest(opts: iNewZAPRequest) { kind: NEVENT_KIND.ZAP_REQUEST, }); - nEv.addRelaysTag(opts.relayUrls); + if (opts.relayUrls && opts.relayUrls.length > 0) { + nEv.addRelaysTag(opts.relayUrls); + } + nEv.addAmountTag(opts.amount.toString()); nEv.addLnurlTag(opts.lnurl); nEv.addPublicKeyTag(opts.recipientPubkey); diff --git a/packages/web/src/classes/user.ts b/packages/web/src/classes/user.ts index 185cdd1..42a8a46 100644 --- a/packages/web/src/classes/user.ts +++ b/packages/web/src/classes/user.ts @@ -18,6 +18,39 @@ export class NUser extends NUserBase { super(data); } + public async getZapCallbackInfo( + url: string + ): Promise { + const info = (await makeRequest(url)) as LnurlEndpointResponse; + if (!isValidLnurlEndpointResponse(info)) { + throw new Error( + `Lnurl endpoint does not allow Nostr payments. Expected to find 'allowsNostr' in response.` + ); + } + this.lightningZapInfo = info; + console.log("LnurlEndpointResponse", this.lightningZapInfo); + return this.lightningZapInfo; + } + + public async getLightningInvoice( + zapRequest: iNewZAPRequest, + invoiceUrl: string + ): Promise { + const res = await makeRequest(invoiceUrl); + + if (res && res.status === "ERROR") { + throw new Error(`Error getting lightning invoice: ${res.reason}`); + } + if (!isValidLnurlInvoiceResponse(zapRequest, res)) { + throw new Error( + `Lnurl invoice response is invalid or does not match your request.` + ); + } + + console.log("LnurlInvoiceResponse", res); + return res; + } + /** * Make a zap request to get lightning invoice * 1. Fetch callback url and spec @@ -36,15 +69,8 @@ export class NUser extends NUserBase { if (lud) { try { if (!this.hasZapInfo()) { - const info = (await makeRequest(lud.url)) as LnurlEndpointResponse; - if (!isValidLnurlEndpointResponse(info)) { - throw new Error( - `Lnurl endpoint does not allow Nostr payments. Expected to find 'allowsNostr' in response.` - ); - } - this.lightningZapInfo = info; + await this.getZapCallbackInfo(lud.url); } - console.log("LnurlEndpointResponse", this.lightningZapInfo); const reqSigned: iNewZAPRequest = { ...opts, @@ -62,15 +88,7 @@ export class NUser extends NUserBase { keypair ); - // Fetch the invoice - const inv = (await makeRequest(req.invoiceUrl)) as LnurlInvoiceResponse; - if (!isValidLnurlInvoiceResponse(reqSigned, inv)) { - throw new Error( - `Lnurl invoice response is invalid or does not match your request.` - ); - } - - console.log("LnurlInvoiceResponse", inv); + const inv = await this.getLightningInvoice(reqSigned, req.invoiceUrl); return { ...inv, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 527fa04..de0f4d2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -71,6 +71,9 @@ importers: nanoid: specifier: ^3.0.0 version: 3.3.6 + qrcode.react: + specifier: ^3.1.0 + version: 3.1.0(react@18.2.0) react: specifier: ^18.2.0 version: 18.2.0 @@ -7062,6 +7065,14 @@ packages: resolution: {integrity: sha512-6Yg0ekpKICSjPswYOuC5sku/TSWaRYlA0qsXqJgM/d/4pLPHPuTxK7Nbf7jFKzAeedUhR8C7K9Uv63FBsSo8xQ==} dev: true + /qrcode.react@3.1.0(react@18.2.0): + resolution: {integrity: sha512-oyF+Urr3oAMUG/OiOuONL3HXM+53wvuH3mtIWQrYmsXoAq0DkvZp2RYUWFSMFtbdOpuS++9v+WAkzNVkMlNW6Q==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + dependencies: + react: 18.2.0 + dev: false + /querystringify@2.2.0: resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} dev: true