rebuild zap modal

This commit is contained in:
hzrd149 2023-02-24 17:20:41 -06:00
parent 759edb33ad
commit 5ba19391c1
6 changed files with 280 additions and 158 deletions

View File

@ -15,7 +15,6 @@ export const CopyIconButton = ({ text, ...props }: { text?: string } & Omit<Icon
setTimeout(() => setCopied(false), 2000);
}
}}
size="xs"
{...props}
/>
);

View File

@ -1,22 +1,17 @@
import { Button, ButtonProps, useToast } from "@chakra-ui/react";
import { makeZapRequest } from "nostr-tools/nip57";
import { useMemo, useRef, useState } from "react";
import { random } from "../../helpers/array";
import { parsePaymentRequest, readableAmountInSats } from "../../helpers/bolt11";
import { Button, ButtonProps, useDisclosure } from "@chakra-ui/react";
import { useMemo } from "react";
import { readableAmountInSats } from "../../helpers/bolt11";
import { parseZapNote, totalZaps } from "../../helpers/nip-57";
import { useCurrentAccount } from "../../hooks/use-current-account";
import useEventZaps from "../../hooks/use-event-zaps";
import { useUserMetadata } from "../../hooks/use-user-metadata";
import { useSigningContext } from "../../providers/signing-provider";
import clientRelaysService from "../../services/client-relays";
import { getEventRelays } from "../../services/event-relays";
import eventZapsService from "../../services/event-zaps";
import lnurlMetadataService from "../../services/lnurl-metadata";
import { NostrEvent } from "../../types/nostr-event";
import { LightningIcon } from "../icons";
import ZapModal from "../zap-modal";
export default function NoteZapButton({ note, ...props }: { note: NostrEvent } & Omit<ButtonProps, "children">) {
const { requestSignature } = useSigningContext();
const account = useCurrentAccount();
const metadata = useUserMetadata(note.pubkey);
const zaps = useEventZaps(note.id) ?? [];
@ -29,101 +24,27 @@ export default function NoteZapButton({ note, ...props }: { note: NostrEvent } &
}
return parsed;
}, [zaps]);
const toast = useToast();
const [loading, setLoading] = useState(false);
const timeout = useRef(0);
const zapAmount = useRef(0);
const { isOpen, onOpen, onClose } = useDisclosure();
const hasZapped = parsedZaps.some((zapRequest) => zapRequest.request.pubkey === account.pubkey);
const tipAddress = metadata?.lud06 || metadata?.lud16;
const handleClick = () => {
if (!tipAddress) return;
if (timeout.current) {
window.clearTimeout(timeout.current);
}
zapAmount.current += 21;
timeout.current = window.setTimeout(async () => {
setLoading(true);
try {
const eventRelays = getEventRelays(note.id).value;
const readRelays = clientRelaysService.getReadUrls();
const lnurlMetadata = await lnurlMetadataService.requestMetadata(tipAddress);
const amount = zapAmount.current * 1000;
if (lnurlMetadata && lnurlMetadata.allowsNostr && lnurlMetadata.nostrPubkey) {
const zapRequest = makeZapRequest({
profile: note.pubkey,
event: note.id,
// pick a random relay from the event and one of our read relays
relays: [random(eventRelays), random(readRelays)],
amount,
comment: "",
});
const signed = await requestSignature(zapRequest);
if (signed) {
if (amount > lnurlMetadata.maxSendable) throw new Error("amount to large");
if (amount < lnurlMetadata.minSendable) throw new Error("amount to small");
const url = new URL(lnurlMetadata.callback);
url.searchParams.append("amount", String(zapAmount.current * 1000));
url.searchParams.append("nostr", JSON.stringify(signed));
const { pr: payRequest } = await fetch(url).then((res) => res.json());
if (payRequest as string) {
const parsed = parsePaymentRequest(payRequest);
if (parsed.amount !== amount) throw new Error("incorrect amount");
if (window.webln) {
await window.webln.enable();
await window.webln.sendPayment(payRequest);
// fetch the zaps again
eventZapsService.requestZaps(note.id, readRelays, true);
} else {
window.addEventListener(
"focus",
() => {
// when the window regains focus, fetch the zaps again
eventZapsService.requestZaps(note.id, readRelays, true);
},
{ once: true }
);
window.open("lightning:" + payRequest);
}
}
}
} else {
// show standard tipping
}
} catch (e) {
if (e instanceof Error) {
console.log(e);
toast({
status: "error",
description: e.message,
});
}
}
zapAmount.current = 0;
setLoading(false);
}, 1500);
};
const invoicePaid = () => eventZapsService.requestZaps(note.id, clientRelaysService.getReadUrls(), true);
return (
<Button
leftIcon={<LightningIcon color="yellow.500" />}
aria-label="Zap Note"
title="Zap Note"
colorScheme={hasZapped ? "brand" : undefined}
{...props}
isLoading={loading}
onClick={handleClick}
isDisabled={!tipAddress}
>
{readableAmountInSats(totalZaps(zaps), false)}
</Button>
<>
<Button
leftIcon={<LightningIcon color="yellow.500" />}
aria-label="Zap Note"
title="Zap Note"
colorScheme={hasZapped ? "brand" : undefined}
{...props}
onClick={onOpen}
isDisabled={!tipAddress}
>
{readableAmountInSats(totalZaps(zaps), false)}
</Button>
{isOpen && <ZapModal isOpen={isOpen} onClose={onClose} event={note} onPaid={invoicePaid} pubkey={note.pubkey} />}
</>
);
}

View File

@ -6,12 +6,12 @@ export default function QrCodeSvg({
content,
lightColor = "white",
darkColor = "black",
border,
border = 2,
}: {
content: string;
lightColor?: string;
darkColor?: string;
border: number;
border?: number;
}) {
const qrCode = useMemo(() => QrCode.encodeText(content, Ecc.LOW), [content]);

View File

@ -1,66 +1,31 @@
import { IconButton, IconButtonProps, useToast } from "@chakra-ui/react";
import { IconButton, IconButtonProps, useDisclosure, useToast } from "@chakra-ui/react";
import { useUserMetadata } from "../hooks/use-user-metadata";
import { LightningIcon } from "./icons";
import { useState } from "react";
import { encodeText } from "../helpers/bech32";
import ZapModal from "./zap-modal";
export const UserTipButton = ({ pubkey, ...props }: { pubkey: string } & Omit<IconButtonProps, "aria-label">) => {
const metadata = useUserMetadata(pubkey);
const [loading, setLoading] = useState(false);
const toast = useToast();
const { isOpen, onOpen, onClose } = useDisclosure();
if (!metadata) return null;
// use lud06 and lud16 fields interchangeably
let lnurl = metadata.lud06 || metadata.lud16;
if (lnurl && lnurl.includes("@")) {
//if its a lightning address convert it to a lnurl
const parts = lnurl.split("@");
if (parts[0] && parts[1]) {
lnurl = encodeText("lnurl", `https://${parts[1]}/.well-known/lnurlp/${parts[0]}`);
} else {
// failed to parse it. something is wrong...
lnurl = undefined;
}
}
let tipAddress = metadata.lud06 || metadata.lud16;
if (!lnurl) return null;
const handleClick = async () => {
if (!lnurl) return;
setLoading(true);
if (window.webln && window.webln.lnurl) {
try {
if (!window.webln.enabled) await window.webln.enable();
await window.webln.lnurl(lnurl);
toast({
title: "Tip sent",
status: "success",
duration: 1000,
});
} catch (e: any) {
toast({
status: "error",
description: e?.message,
isClosable: true,
});
}
} else {
window.open(`lnurl:${lnurl}`);
}
setLoading(false);
};
if (!tipAddress) return null;
return (
<IconButton
onClick={handleClick}
aria-label="Send Tip"
title="Send Tip"
icon={<LightningIcon />}
isLoading={loading}
color="yellow.400"
{...props}
/>
<>
<IconButton
onClick={onOpen}
aria-label="Send Tip"
title="Send Tip"
icon={<LightningIcon />}
color="yellow.400"
{...props}
/>
{isOpen && <ZapModal isOpen={isOpen} onClose={onClose} pubkey={pubkey} />}
</>
);
};

View File

@ -0,0 +1,237 @@
import {
Button,
ButtonGroup,
Flex,
IconButton,
Input,
InputGroup,
InputLeftElement,
Modal,
ModalBody,
ModalContent,
ModalOverlay,
ModalProps,
Text,
useDisclosure,
useToast,
} from "@chakra-ui/react";
import { useState } from "react";
import { getUserDisplayName } from "../helpers/user-metadata";
import { NostrEvent } from "../types/nostr-event";
import { SubmitHandler, useForm } from "react-hook-form";
import { UserAvatar } from "./user-avatar";
import { useUserMetadata } from "../hooks/use-user-metadata";
import { UserLink } from "./user-link";
import { parsePaymentRequest, readableAmountInSats } from "../helpers/bolt11";
import { ExternalLinkIcon, LightningIcon, QrCodeIcon } from "./icons";
import lnurlMetadataService from "../services/lnurl-metadata";
import { useAsync } from "react-use";
import { makeZapRequest } from "nostr-tools/nip57";
import clientRelaysService from "../services/client-relays";
import { getEventRelays } from "../services/event-relays";
import { useSigningContext } from "../providers/signing-provider";
import QrCodeSvg from "./qr-code-svg";
import { CopyIconButton } from "./copy-icon-button";
import { useIsMobile } from "../hooks/use-is-mobile";
type FormValues = {
amount: number;
comment: string;
};
export default function ZapModal({
event,
pubkey,
onClose,
onPaid,
...props
}: { event?: NostrEvent; pubkey: string; onPaid?: () => void } & Omit<ModalProps, "children">) {
const metadata = useUserMetadata(pubkey);
const { requestSignature } = useSigningContext();
const toast = useToast();
const [invoice, setInvoice] = useState<string>();
const { isOpen: showQr, onToggle: toggleQr } = useDisclosure();
const isMobile = useIsMobile();
const {
register,
handleSubmit,
watch,
setValue,
formState: { errors, isSubmitting },
} = useForm<FormValues>({
mode: "onBlur",
defaultValues: {
amount: 10,
comment: "",
},
});
const tipAddress = metadata?.lud06 || metadata?.lud16;
const { value: lnurlMetadata } = useAsync(
async () => (tipAddress ? lnurlMetadataService.requestMetadata(tipAddress) : undefined),
[tipAddress]
);
const canZap = lnurlMetadata?.allowsNostr && lnurlMetadata?.nostrPubkey;
const actionName = canZap ? "Zap" : "Tip";
const onSubmitZap: SubmitHandler<FormValues> = async (values) => {
try {
if (lnurlMetadata) {
const amountInMilisat = values.amount * 1000;
if (amountInMilisat > lnurlMetadata.maxSendable) throw new Error("amount to large");
if (amountInMilisat < lnurlMetadata.minSendable) throw new Error("amount to small");
if (canZap) {
const otherRelays = event ? getEventRelays(event.id).value : [];
const readRelays = clientRelaysService.getReadUrls();
const zapRequest = makeZapRequest({
profile: pubkey,
event: event?.id ?? null,
relays: [...otherRelays, ...readRelays],
amount: amountInMilisat,
comment: values.comment,
});
const signed = await requestSignature(zapRequest);
if (signed) {
const callbackUrl = new URL(lnurlMetadata.callback);
callbackUrl.searchParams.append("amount", String(amountInMilisat));
callbackUrl.searchParams.append("nostr", JSON.stringify(signed));
const { pr: payRequest } = await fetch(callbackUrl).then((res) => res.json());
if (payRequest as string) {
const parsed = parsePaymentRequest(payRequest);
if (parsed.amount !== amountInMilisat) throw new Error("incorrect amount");
setInvoice(payRequest);
} else throw new Error("Failed to get invoice");
}
} else {
const callbackUrl = new URL(lnurlMetadata.callback);
callbackUrl.searchParams.append("amount", String(amountInMilisat));
if (values.comment) callbackUrl.searchParams.append("comment", values.comment);
const { pr: payRequest } = await fetch(callbackUrl).then((res) => res.json());
if (payRequest as string) {
const parsed = parsePaymentRequest(payRequest);
if (parsed.amount !== amountInMilisat) throw new Error("incorrect amount");
setInvoice(payRequest);
} else throw new Error("Failed to get invoice");
}
} else throw new Error("No lightning address");
} catch (e) {
if (e instanceof Error) toast({ status: "error", description: e.message });
}
};
const payWithWebLn = async () => {
if (window.webln && invoice) {
if (!window.webln.enabled) await window.webln.enable();
await window.webln.sendPayment(invoice);
toast({
title: actionName + " sent",
status: "success",
duration: 3000,
});
if (onPaid) onPaid();
onClose();
}
};
const payWithApp = async () => {
window.open("lightning:" + invoice);
window.addEventListener(
"focus",
() => {
if (onPaid) onPaid();
onClose();
},
{ once: true }
);
};
return (
<Modal onClose={onClose} {...props}>
<ModalOverlay />
<ModalContent>
<ModalBody padding="4">
{invoice ? (
<Flex gap="4" direction="column">
{showQr && <QrCodeSvg content={invoice} />}
<Flex gap="2">
<Input value={invoice} readOnly />
<IconButton
icon={<QrCodeIcon />}
aria-label="Show QrCode"
onClick={toggleQr}
variant="solid"
size="md"
/>
<CopyIconButton text={invoice} aria-label="Copy Invoice" variant="solid" size="md" />
</Flex>
<Flex gap="2">
{window.webln && (
<Button onClick={payWithWebLn} flex={1} variant="solid" size="md">
Pay with WebLN
</Button>
)}
<Button leftIcon={<ExternalLinkIcon />} onClick={payWithApp} flex={1} variant="solid" size="md">
Open App
</Button>
</Flex>
</Flex>
) : (
<form onSubmit={handleSubmit(onSubmitZap)}>
<Flex gap="4" direction="column">
<Flex gap="2" alignItems="center">
<UserAvatar pubkey={pubkey} size="sm" />
<Text>{actionName}</Text>
<UserLink pubkey={pubkey} />
</Flex>
<Flex gap="2" alignItems="center">
<ButtonGroup>
<Button onClick={() => setValue("amount", 10)}>10</Button>
<Button onClick={() => setValue("amount", 100)}>100</Button>
<Button onClick={() => setValue("amount", 500)}>500</Button>
<Button onClick={() => setValue("amount", 1000)}>1K</Button>
</ButtonGroup>
<InputGroup maxWidth={32}>
{!isMobile && (
<InputLeftElement pointerEvents="none" color="gray.300" fontSize="1.2em">
<LightningIcon fontSize="1rem" />
</InputLeftElement>
)}
<Input
type="number"
placeholder="amount"
isInvalid={!!errors.amount}
step={1}
{...register("amount", { valueAsNumber: true, min: 1, required: true })}
/>
</InputGroup>
</Flex>
{(canZap || lnurlMetadata?.commentAllowed) && (
<Input
placeholder="Comment"
{...register("comment", { maxLength: lnurlMetadata?.commentAllowed ?? 150 })}
autoComplete="off"
/>
)}
<Button leftIcon={<LightningIcon />} type="submit" isLoading={isSubmitting} variant="solid" size="md">
{actionName} {getUserDisplayName(metadata, pubkey)} {readableAmountInSats(watch("amount") * 1000)}
</Button>
</Flex>
</form>
)}
</ModalBody>
</ModalContent>
</Modal>
);
}

View File

@ -1,5 +1,5 @@
import { useDisclosure } from "@chakra-ui/react";
import React, { useCallback, useMemo, useState } from "react";
import { ErrorBoundary } from "../components/error-boundary";
import { PostModal } from "../components/post-modal";
import { DraftNostrEvent } from "../types/nostr-event";
@ -12,20 +12,20 @@ export const PostModalContext = React.createContext<PostModalContextType>({
});
export const PostModalProvider = ({ children }: { children: React.ReactNode }) => {
const [open, setOpen] = useState(false);
const {isOpen, onOpen, onClose} = useDisclosure();
const [draft, setDraft] = useState<Partial<DraftNostrEvent> | undefined>(undefined);
const openModal = useCallback(
(draft?: Partial<DraftNostrEvent>) => {
setDraft(draft);
setOpen(true);
onOpen();
},
[setDraft, setOpen]
[setDraft, onOpen]
);
const context = useMemo(() => ({ openModal }), [openModal]);
return (
<PostModalContext.Provider value={context}>
{open && <PostModal isOpen initialDraft={draft} onClose={() => setOpen(false)} />}
{isOpen && <PostModal isOpen initialDraft={draft} onClose={onClose} />}
{children}
</PostModalContext.Provider>
);