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 && }
+