mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-04-10 12:49:29 +02:00
rebuild zap modal
This commit is contained in:
parent
759edb33ad
commit
5ba19391c1
@ -15,7 +15,6 @@ export const CopyIconButton = ({ text, ...props }: { text?: string } & Omit<Icon
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
}}
|
||||
size="xs"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
@ -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} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -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]);
|
||||
|
||||
|
@ -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} />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
237
src/components/zap-modal.tsx
Normal file
237
src/components/zap-modal.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
|
Loading…
x
Reference in New Issue
Block a user