mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-07-13 05:42:23 +02:00
Add support for paying zap splits
This commit is contained in:
5
.changeset/fair-bears-brake.md
Normal file
5
.changeset/fair-bears-brake.md
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"nostrudel": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Add support for paying zap splits
|
205
src/components/event-zap-modal/index.tsx
Normal file
205
src/components/event-zap-modal/index.tsx
Normal file
@ -0,0 +1,205 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
Modal,
|
||||||
|
ModalBody,
|
||||||
|
ModalCloseButton,
|
||||||
|
ModalContent,
|
||||||
|
ModalHeader,
|
||||||
|
ModalOverlay,
|
||||||
|
ModalProps,
|
||||||
|
} from "@chakra-ui/react";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import { Kind } from "nostr-tools";
|
||||||
|
|
||||||
|
import { DraftNostrEvent, NostrEvent, isDTag } from "../../types/nostr-event";
|
||||||
|
import clientRelaysService from "../../services/client-relays";
|
||||||
|
import { getEventRelays } from "../../services/event-relays";
|
||||||
|
import { getZapSplits } from "../../helpers/nostr/zaps";
|
||||||
|
import { unique } from "../../helpers/array";
|
||||||
|
import { RelayMode } from "../../classes/relay";
|
||||||
|
import relayScoreboardService from "../../services/relay-scoreboard";
|
||||||
|
import { getEventCoordinate, isReplaceable } from "../../helpers/nostr/events";
|
||||||
|
import { EmbedProps } from "../embed-event";
|
||||||
|
import userRelaysService from "../../services/user-relays";
|
||||||
|
import InputStep from "./input-step";
|
||||||
|
import lnurlMetadataService from "../../services/lnurl-metadata";
|
||||||
|
import userMetadataService from "../../services/user-metadata";
|
||||||
|
import signingService from "../../services/signing";
|
||||||
|
import accountService from "../../services/account";
|
||||||
|
import PayStep from "./pay-step";
|
||||||
|
import { getInvoiceFromCallbackUrl } from "../../helpers/lnurl";
|
||||||
|
|
||||||
|
export type PayRequest = { invoice?: string; pubkey: string; error?: any };
|
||||||
|
|
||||||
|
async function getPayRequestForPubkey(
|
||||||
|
pubkey: string,
|
||||||
|
event: NostrEvent | undefined,
|
||||||
|
amount: number,
|
||||||
|
comment?: string,
|
||||||
|
additionalRelays?: string[],
|
||||||
|
): Promise<PayRequest> {
|
||||||
|
const metadata = userMetadataService.getSubject(pubkey).value;
|
||||||
|
const address = metadata?.lud16 || metadata?.lud06;
|
||||||
|
if (!address) throw new Error("User missing lightning address");
|
||||||
|
const lnurlMetadata = await lnurlMetadataService.requestMetadata(address);
|
||||||
|
|
||||||
|
if (!lnurlMetadata) throw new Error("LNURL endpoint unreachable");
|
||||||
|
|
||||||
|
if (amount > lnurlMetadata.maxSendable) throw new Error("Amount to large");
|
||||||
|
if (amount < lnurlMetadata.minSendable) throw new Error("Amount to small");
|
||||||
|
|
||||||
|
const canZap = !!lnurlMetadata.allowsNostr && !!lnurlMetadata.nostrPubkey;
|
||||||
|
if (!canZap) {
|
||||||
|
// build LNURL callback url
|
||||||
|
const callback = new URL(lnurlMetadata.callback);
|
||||||
|
callback.searchParams.append("amount", String(amount));
|
||||||
|
if (comment) callback.searchParams.append("comment", comment);
|
||||||
|
|
||||||
|
const invoice = await getInvoiceFromCallbackUrl(callback);
|
||||||
|
|
||||||
|
return { invoice, pubkey };
|
||||||
|
}
|
||||||
|
|
||||||
|
const userInbox = relayScoreboardService
|
||||||
|
.getRankedRelays(
|
||||||
|
userRelaysService
|
||||||
|
.getRelays(pubkey)
|
||||||
|
.value?.relays.filter((r) => r.mode & RelayMode.READ)
|
||||||
|
.map((r) => r.url) ?? [],
|
||||||
|
)
|
||||||
|
.slice(0, 4);
|
||||||
|
const eventRelays = event ? relayScoreboardService.getRankedRelays(getEventRelays(event.id).value).slice(0, 4) : [];
|
||||||
|
const outbox = relayScoreboardService.getRankedRelays(clientRelaysService.getWriteUrls()).slice(0, 4);
|
||||||
|
const additional = relayScoreboardService.getRankedRelays(additionalRelays);
|
||||||
|
|
||||||
|
// create zap request
|
||||||
|
const zapRequest: DraftNostrEvent = {
|
||||||
|
kind: Kind.ZapRequest,
|
||||||
|
created_at: dayjs().unix(),
|
||||||
|
content: comment ?? "",
|
||||||
|
tags: [
|
||||||
|
["p", pubkey],
|
||||||
|
["relays", ...unique([...userInbox, ...eventRelays, ...outbox, ...additional])],
|
||||||
|
["amount", String(amount)],
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// attach "e" or "a" tag
|
||||||
|
if (event) {
|
||||||
|
if (isReplaceable(event.kind) && event.tags.some(isDTag)) {
|
||||||
|
zapRequest.tags.push(["a", getEventCoordinate(event)]);
|
||||||
|
} else zapRequest.tags.push(["e", event.id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: move this out to a separate step so the user can choose when to sign
|
||||||
|
const account = accountService.current.value;
|
||||||
|
if (!account) throw new Error("No Account");
|
||||||
|
const signed = await signingService.requestSignature(zapRequest, account);
|
||||||
|
|
||||||
|
// build LNURL callback url
|
||||||
|
const callback = new URL(lnurlMetadata.callback);
|
||||||
|
callback.searchParams.append("amount", String(amount));
|
||||||
|
callback.searchParams.append("nostr", JSON.stringify(signed));
|
||||||
|
|
||||||
|
const invoice = await getInvoiceFromCallbackUrl(callback);
|
||||||
|
|
||||||
|
return { invoice, pubkey };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getPayRequestsForEvent(
|
||||||
|
event: NostrEvent,
|
||||||
|
amount: number,
|
||||||
|
comment?: string,
|
||||||
|
additionalRelays?: string[],
|
||||||
|
) {
|
||||||
|
const splits = getZapSplits(event);
|
||||||
|
|
||||||
|
const draftZapRequests: PayRequest[] = [];
|
||||||
|
for (const { pubkey, percent } of splits) {
|
||||||
|
try {
|
||||||
|
// NOTE: round to the nearest sat since there isn't support for msats yet
|
||||||
|
const splitAmount = Math.round((amount / 1000) * percent) * 1000;
|
||||||
|
draftZapRequests.push(await getPayRequestForPubkey(pubkey, event, splitAmount, comment, additionalRelays));
|
||||||
|
} catch (e) {
|
||||||
|
draftZapRequests.push({ error: e, pubkey });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return draftZapRequests;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ZapModalProps = Omit<ModalProps, "children"> & {
|
||||||
|
pubkey: string;
|
||||||
|
event?: NostrEvent;
|
||||||
|
relays?: string[];
|
||||||
|
initialComment?: string;
|
||||||
|
initialAmount?: number;
|
||||||
|
onInvoice: (invoice: string) => void;
|
||||||
|
allowComment?: boolean;
|
||||||
|
showEmbed?: boolean;
|
||||||
|
embedProps?: EmbedProps;
|
||||||
|
additionalRelays?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ZapModal({
|
||||||
|
event,
|
||||||
|
pubkey,
|
||||||
|
relays,
|
||||||
|
onClose,
|
||||||
|
initialComment,
|
||||||
|
initialAmount,
|
||||||
|
onInvoice,
|
||||||
|
allowComment = true,
|
||||||
|
showEmbed = true,
|
||||||
|
embedProps,
|
||||||
|
additionalRelays = [],
|
||||||
|
...props
|
||||||
|
}: ZapModalProps) {
|
||||||
|
const [callbacks, setCallbacks] = useState<PayRequest[]>();
|
||||||
|
|
||||||
|
const renderContent = () => {
|
||||||
|
if (callbacks && callbacks.length > 0) {
|
||||||
|
return <PayStep callbacks={callbacks} onComplete={onClose} />;
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<InputStep
|
||||||
|
pubkey={pubkey}
|
||||||
|
event={event}
|
||||||
|
initialComment={initialComment}
|
||||||
|
initialAmount={initialAmount}
|
||||||
|
showEmbed={showEmbed}
|
||||||
|
embedProps={embedProps}
|
||||||
|
allowComment={allowComment}
|
||||||
|
onSubmit={async (values) => {
|
||||||
|
const amountInMSats = values.amount * 1000;
|
||||||
|
if (event) {
|
||||||
|
setCallbacks(await getPayRequestsForEvent(event, amountInMSats, values.comment, additionalRelays));
|
||||||
|
} else {
|
||||||
|
const callback = await getPayRequestForPubkey(
|
||||||
|
pubkey,
|
||||||
|
event,
|
||||||
|
amountInMSats,
|
||||||
|
values.comment,
|
||||||
|
additionalRelays,
|
||||||
|
);
|
||||||
|
setCallbacks([callback]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal onClose={onClose} size="xl" {...props}>
|
||||||
|
<ModalOverlay />
|
||||||
|
<ModalContent>
|
||||||
|
<ModalCloseButton />
|
||||||
|
<ModalHeader px="4" pb="0" pt="4">
|
||||||
|
Zap Event
|
||||||
|
</ModalHeader>
|
||||||
|
<ModalBody padding="4">{renderContent()}</ModalBody>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
119
src/components/event-zap-modal/input-step.tsx
Normal file
119
src/components/event-zap-modal/input-step.tsx
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
import { Box, Button, Flex, Input, Text } from "@chakra-ui/react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
|
||||||
|
import { NostrEvent } from "../../types/nostr-event";
|
||||||
|
import { readablizeSats } from "../../helpers/bolt11";
|
||||||
|
import { LightningIcon } from "../icons";
|
||||||
|
import useUserLNURLMetadata from "../../hooks/use-user-lnurl-metadata";
|
||||||
|
import { getZapSplits } from "../../helpers/nostr/zaps";
|
||||||
|
import { EmbedEvent, EmbedProps } from "../embed-event";
|
||||||
|
import useAppSettings from "../../hooks/use-app-settings";
|
||||||
|
import CustomZapAmountOptions from "./zap-options";
|
||||||
|
import { UserAvatar } from "../user-avatar";
|
||||||
|
import { UserLink } from "../user-link";
|
||||||
|
|
||||||
|
function UserCard({ pubkey, percent }: { pubkey: string; percent?: number }) {
|
||||||
|
const { address } = useUserLNURLMetadata(pubkey);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex gap="2" alignItems="center" overflow="hidden">
|
||||||
|
<UserAvatar pubkey={pubkey} size="md" />
|
||||||
|
<Box overflow="hidden">
|
||||||
|
<UserLink pubkey={pubkey} fontWeight="bold" />
|
||||||
|
<Text isTruncated>{address}</Text>
|
||||||
|
</Box>
|
||||||
|
{percent && (
|
||||||
|
<Text fontWeight="bold" fontSize="lg" ml="auto">
|
||||||
|
{Math.round(percent * 10000) / 100}%
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InputStepProps = {
|
||||||
|
pubkey: string;
|
||||||
|
event?: NostrEvent;
|
||||||
|
initialComment?: string;
|
||||||
|
initialAmount?: number;
|
||||||
|
allowComment?: boolean;
|
||||||
|
showEmbed?: boolean;
|
||||||
|
embedProps?: EmbedProps;
|
||||||
|
onSubmit: (values: { amount: number; comment: string }) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function InputStep({
|
||||||
|
event,
|
||||||
|
pubkey,
|
||||||
|
initialComment,
|
||||||
|
initialAmount,
|
||||||
|
allowComment = true,
|
||||||
|
showEmbed = true,
|
||||||
|
embedProps,
|
||||||
|
onSubmit,
|
||||||
|
}: InputStepProps) {
|
||||||
|
const { customZapAmounts } = useAppSettings();
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
watch,
|
||||||
|
setValue,
|
||||||
|
formState: { errors, isSubmitting },
|
||||||
|
} = useForm<{
|
||||||
|
amount: number;
|
||||||
|
comment: string;
|
||||||
|
}>({
|
||||||
|
mode: "onBlur",
|
||||||
|
defaultValues: {
|
||||||
|
amount: initialAmount ?? (parseInt(customZapAmounts.split(",")[0]) || 100),
|
||||||
|
comment: initialComment ?? "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const splits = event ? getZapSplits(event) : [];
|
||||||
|
|
||||||
|
const { metadata: lnurlMetadata } = useUserLNURLMetadata(pubkey);
|
||||||
|
const canZap = lnurlMetadata?.allowsNostr && lnurlMetadata?.nostrPubkey;
|
||||||
|
|
||||||
|
const showComment = allowComment && (splits.length > 0 || canZap || lnurlMetadata?.commentAllowed);
|
||||||
|
const actionName = canZap ? "Zap" : "Tip";
|
||||||
|
|
||||||
|
const onSubmitZap = handleSubmit(onSubmit);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={onSubmitZap}>
|
||||||
|
<Flex gap="4" direction="column">
|
||||||
|
{splits.map((p) => (
|
||||||
|
<UserCard key={p.pubkey} pubkey={p.pubkey} percent={p.percent} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{showEmbed && event && <EmbedEvent event={event} {...embedProps} />}
|
||||||
|
|
||||||
|
{showComment && (
|
||||||
|
<Input
|
||||||
|
placeholder="Comment"
|
||||||
|
{...register("comment", { maxLength: lnurlMetadata?.commentAllowed ?? 150 })}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<CustomZapAmountOptions onSelect={(amount) => setValue("amount", amount)} />
|
||||||
|
|
||||||
|
<Flex gap="2">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder="Custom amount"
|
||||||
|
isInvalid={!!errors.amount}
|
||||||
|
step={1}
|
||||||
|
flex={1}
|
||||||
|
{...register("amount", { valueAsNumber: true, min: 1 })}
|
||||||
|
/>
|
||||||
|
<Button leftIcon={<LightningIcon />} type="submit" isLoading={isSubmitting} variant="solid" size="md">
|
||||||
|
{actionName} {readablizeSats(watch("amount"))} sats
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
149
src/components/event-zap-modal/pay-step.tsx
Normal file
149
src/components/event-zap-modal/pay-step.tsx
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
import { useMount } from "react-use";
|
||||||
|
import { Alert, Box, Button, ButtonGroup, Flex, IconButton, Spacer, useDisclosure, useToast } from "@chakra-ui/react";
|
||||||
|
|
||||||
|
import { PayRequest } from ".";
|
||||||
|
import { UserAvatar } from "../user-avatar";
|
||||||
|
import { UserLink } from "../user-link";
|
||||||
|
import { ArrowDownSIcon, ArrowUpSIcon, CheckIcon, ErrorIcon, LightningIcon } from "../icons";
|
||||||
|
import { InvoiceModalContent } from "../invoice-modal";
|
||||||
|
import { PropsWithChildren, useEffect, useState } from "react";
|
||||||
|
import appSettings from "../../services/settings/app-settings";
|
||||||
|
|
||||||
|
function UserCard({ children, pubkey }: PropsWithChildren & { pubkey: string }) {
|
||||||
|
return (
|
||||||
|
<Flex gap="2" alignItems="center" overflow="hidden">
|
||||||
|
<UserAvatar pubkey={pubkey} size="md" />
|
||||||
|
<Box>
|
||||||
|
<UserLink pubkey={pubkey} fontWeight="bold" />
|
||||||
|
</Box>
|
||||||
|
<Spacer />
|
||||||
|
{children}
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function PayRequestCard({ pubkey, invoice, onPaid }: { pubkey: string; invoice: string; onPaid: () => void }) {
|
||||||
|
const toast = useToast();
|
||||||
|
const showMore = useDisclosure();
|
||||||
|
|
||||||
|
const payWithWebLn = async () => {
|
||||||
|
try {
|
||||||
|
if (window.webln && invoice) {
|
||||||
|
if (!window.webln.enabled) await window.webln.enable();
|
||||||
|
await window.webln.sendPayment(invoice);
|
||||||
|
onPaid();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Error) toast({ description: e.message, status: "error" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex direction="column" gap="2">
|
||||||
|
<UserCard pubkey={pubkey}>
|
||||||
|
<ButtonGroup size="sm">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
colorScheme="yellow"
|
||||||
|
size="sm"
|
||||||
|
leftIcon={<LightningIcon />}
|
||||||
|
isDisabled={!window.webln}
|
||||||
|
onClick={payWithWebLn}
|
||||||
|
>
|
||||||
|
Pay
|
||||||
|
</Button>
|
||||||
|
<IconButton
|
||||||
|
icon={showMore.isOpen ? <ArrowUpSIcon /> : <ArrowDownSIcon />}
|
||||||
|
aria-label="More Options"
|
||||||
|
onClick={showMore.onToggle}
|
||||||
|
/>
|
||||||
|
</ButtonGroup>
|
||||||
|
</UserCard>
|
||||||
|
{showMore.isOpen && <InvoiceModalContent invoice={invoice} onPaid={onPaid} />}
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function ErrorCard({ pubkey, error }: { pubkey: string; error: any }) {
|
||||||
|
const showMore = useDisclosure();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex direction="column" gap="2">
|
||||||
|
<UserCard pubkey={pubkey}>
|
||||||
|
<Button size="sm" variant="outline" colorScheme="red" leftIcon={<ErrorIcon />} onClick={showMore.onToggle}>
|
||||||
|
Error
|
||||||
|
</Button>
|
||||||
|
</UserCard>
|
||||||
|
{showMore.isOpen && <Alert status="error">{error.message}</Alert>}
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PayStep({ callbacks, onComplete }: { callbacks: PayRequest[]; onComplete: () => void }) {
|
||||||
|
const [paid, setPaid] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const [payingAll, setPayingAll] = useState(false);
|
||||||
|
const payAllWithWebLN = async () => {
|
||||||
|
if (!window.webln) return;
|
||||||
|
|
||||||
|
setPayingAll(true);
|
||||||
|
if (!window.webln.enabled) await window.webln.enable();
|
||||||
|
|
||||||
|
for (const { invoice, pubkey } of callbacks) {
|
||||||
|
try {
|
||||||
|
if (invoice && !paid.includes(pubkey)) {
|
||||||
|
await window.webln.sendPayment(invoice);
|
||||||
|
setPaid((a) => a.concat(pubkey));
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
setPayingAll(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!callbacks.filter((p) => !!p.invoice).some(({ pubkey }) => !paid.includes(pubkey))) {
|
||||||
|
onComplete();
|
||||||
|
}
|
||||||
|
}, [paid]);
|
||||||
|
|
||||||
|
// if autoPayWithWebLN is enabled, try to pay all immediately
|
||||||
|
useMount(() => {
|
||||||
|
if (appSettings.value.autoPayWithWebLN) {
|
||||||
|
payAllWithWebLN();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex direction="column" gap="4">
|
||||||
|
{callbacks.map(({ pubkey, invoice, error }) => {
|
||||||
|
if (paid.includes(pubkey))
|
||||||
|
return (
|
||||||
|
<UserCard pubkey={pubkey}>
|
||||||
|
<Button size="sm" variant="outline" colorScheme="green" leftIcon={<CheckIcon />}>
|
||||||
|
Paid
|
||||||
|
</Button>
|
||||||
|
</UserCard>
|
||||||
|
);
|
||||||
|
if (error) return <ErrorCard key={pubkey} pubkey={pubkey} error={error} />;
|
||||||
|
if (invoice)
|
||||||
|
return (
|
||||||
|
<PayRequestCard
|
||||||
|
key={pubkey}
|
||||||
|
pubkey={pubkey}
|
||||||
|
invoice={invoice}
|
||||||
|
onPaid={() => setPaid((a) => a.concat(pubkey))}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
})}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="md"
|
||||||
|
leftIcon={<LightningIcon />}
|
||||||
|
colorScheme="yellow"
|
||||||
|
onClick={payAllWithWebLN}
|
||||||
|
isLoading={payingAll}
|
||||||
|
>
|
||||||
|
Pay All
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
27
src/components/event-zap-modal/zap-options.tsx
Normal file
27
src/components/event-zap-modal/zap-options.tsx
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import { Button, Flex } from "@chakra-ui/react";
|
||||||
|
|
||||||
|
import useAppSettings from "../../hooks/use-app-settings";
|
||||||
|
import { LightningIcon } from "../icons";
|
||||||
|
|
||||||
|
export default function CustomZapAmountOptions({ onSelect }: { onSelect: (value: number) => void }) {
|
||||||
|
const { customZapAmounts } = useAppSettings();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex gap="2" alignItems="center" wrap="wrap">
|
||||||
|
{customZapAmounts
|
||||||
|
.split(",")
|
||||||
|
.map((v) => parseInt(v))
|
||||||
|
.map((amount, i) => (
|
||||||
|
<Button
|
||||||
|
key={amount + i}
|
||||||
|
onClick={() => onSelect(amount)}
|
||||||
|
leftIcon={<LightningIcon />}
|
||||||
|
variant="solid"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{amount}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
@ -1,3 +1,4 @@
|
|||||||
|
import { useState } from "react";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Flex,
|
Flex,
|
||||||
@ -15,18 +16,18 @@ import { ExternalLinkIcon, QrCodeIcon } from "./icons";
|
|||||||
import QrCodeSvg from "./qr-code-svg";
|
import QrCodeSvg from "./qr-code-svg";
|
||||||
import { CopyIconButton } from "./copy-icon-button";
|
import { CopyIconButton } from "./copy-icon-button";
|
||||||
|
|
||||||
export default function InvoiceModal({
|
type CommonProps = { invoice: string; onPaid: () => void };
|
||||||
invoice,
|
|
||||||
onClose,
|
export function InvoiceModalContent({ invoice, onPaid }: CommonProps) {
|
||||||
onPaid,
|
|
||||||
...props
|
|
||||||
}: Omit<ModalProps, "children"> & { invoice: string; onPaid: () => void }) {
|
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const showQr = useDisclosure();
|
const showQr = useDisclosure();
|
||||||
|
const [payingWebLn, setPayingWebLn] = useState(false);
|
||||||
|
const [payingApp, setPayingApp] = useState(false);
|
||||||
|
|
||||||
const payWithWebLn = async (invoice: string) => {
|
const payWithWebLn = async (invoice: string) => {
|
||||||
try {
|
try {
|
||||||
if (window.webln && invoice) {
|
if (window.webln && invoice) {
|
||||||
|
setPayingWebLn(true);
|
||||||
if (!window.webln.enabled) await window.webln.enable();
|
if (!window.webln.enabled) await window.webln.enable();
|
||||||
await window.webln.sendPayment(invoice);
|
await window.webln.sendPayment(invoice);
|
||||||
|
|
||||||
@ -35,15 +36,17 @@ export default function InvoiceModal({
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof Error) toast({ description: e.message, status: "error" });
|
if (e instanceof Error) toast({ description: e.message, status: "error" });
|
||||||
}
|
}
|
||||||
|
setPayingWebLn(false);
|
||||||
};
|
};
|
||||||
const payWithApp = async (invoice: string) => {
|
const payWithApp = async (invoice: string) => {
|
||||||
|
setPayingApp(true);
|
||||||
window.open("lightning:" + invoice);
|
window.open("lightning:" + invoice);
|
||||||
|
|
||||||
const listener = () => {
|
const listener = () => {
|
||||||
if (document.visibilityState === "visible") {
|
if (document.visibilityState === "visible") {
|
||||||
if (onPaid) onPaid();
|
if (onPaid) onPaid();
|
||||||
onClose();
|
|
||||||
document.removeEventListener("visibilitychange", listener);
|
document.removeEventListener("visibilitychange", listener);
|
||||||
|
setPayingApp(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@ -51,41 +54,59 @@ export default function InvoiceModal({
|
|||||||
}, 1000 * 2);
|
}, 1000 * 2);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex gap="2" direction="column">
|
||||||
|
{showQr.isOpen && <QrCodeSvg content={invoice} />}
|
||||||
|
<Flex gap="2">
|
||||||
|
<Input value={invoice} readOnly />
|
||||||
|
<IconButton
|
||||||
|
icon={<QrCodeIcon />}
|
||||||
|
aria-label="Show QrCode"
|
||||||
|
onClick={showQr.onToggle}
|
||||||
|
variant="solid"
|
||||||
|
size="md"
|
||||||
|
/>
|
||||||
|
<CopyIconButton text={invoice} aria-label="Copy Invoice" variant="solid" size="md" />
|
||||||
|
</Flex>
|
||||||
|
<Flex gap="2">
|
||||||
|
{window.webln && (
|
||||||
|
<Button onClick={() => payWithWebLn(invoice)} flex={1} variant="solid" size="md" isLoading={payingWebLn}>
|
||||||
|
Pay with WebLN
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
leftIcon={<ExternalLinkIcon />}
|
||||||
|
onClick={() => payWithApp(invoice)}
|
||||||
|
flex={1}
|
||||||
|
variant="solid"
|
||||||
|
size="md"
|
||||||
|
isLoading={payingApp}
|
||||||
|
>
|
||||||
|
Open App
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function InvoiceModal({
|
||||||
|
invoice,
|
||||||
|
onClose,
|
||||||
|
onPaid,
|
||||||
|
...props
|
||||||
|
}: Omit<ModalProps, "children"> & CommonProps) {
|
||||||
return (
|
return (
|
||||||
<Modal onClose={onClose} {...props}>
|
<Modal onClose={onClose} {...props}>
|
||||||
<ModalOverlay />
|
<ModalOverlay />
|
||||||
<ModalContent>
|
<ModalContent>
|
||||||
<ModalBody padding="4">
|
<ModalBody padding="4">
|
||||||
<Flex gap="4" direction="column">
|
<InvoiceModalContent
|
||||||
{showQr.isOpen && <QrCodeSvg content={invoice} />}
|
invoice={invoice}
|
||||||
<Flex gap="2">
|
onPaid={() => {
|
||||||
<Input value={invoice} readOnly />
|
if (onPaid) onPaid();
|
||||||
<IconButton
|
onClose();
|
||||||
icon={<QrCodeIcon />}
|
}}
|
||||||
aria-label="Show QrCode"
|
/>
|
||||||
onClick={showQr.onToggle}
|
|
||||||
variant="solid"
|
|
||||||
size="md"
|
|
||||||
/>
|
|
||||||
<CopyIconButton text={invoice} aria-label="Copy Invoice" variant="solid" size="md" />
|
|
||||||
</Flex>
|
|
||||||
<Flex gap="2">
|
|
||||||
{window.webln && (
|
|
||||||
<Button onClick={() => payWithWebLn(invoice)} flex={1} variant="solid" size="md">
|
|
||||||
Pay with WebLN
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
leftIcon={<ExternalLinkIcon />}
|
|
||||||
onClick={() => payWithApp(invoice)}
|
|
||||||
flex={1}
|
|
||||||
variant="solid"
|
|
||||||
size="md"
|
|
||||||
>
|
|
||||||
Open App
|
|
||||||
</Button>
|
|
||||||
</Flex>
|
|
||||||
</Flex>
|
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
</ModalContent>
|
</ModalContent>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
@ -8,7 +8,7 @@ import clientRelaysService from "../../services/client-relays";
|
|||||||
import eventZapsService from "../../services/event-zaps";
|
import eventZapsService from "../../services/event-zaps";
|
||||||
import { NostrEvent } from "../../types/nostr-event";
|
import { NostrEvent } from "../../types/nostr-event";
|
||||||
import { LightningIcon } from "../icons";
|
import { LightningIcon } from "../icons";
|
||||||
import ZapModal from "../zap-modal";
|
import ZapModal from "../event-zap-modal";
|
||||||
import { useInvoiceModalContext } from "../../providers/invoice-modal";
|
import { useInvoiceModalContext } from "../../providers/invoice-modal";
|
||||||
import useUserLNURLMetadata from "../../hooks/use-user-lnurl-metadata";
|
import useUserLNURLMetadata from "../../hooks/use-user-lnurl-metadata";
|
||||||
import { getEventUID } from "../../helpers/nostr/events";
|
import { getEventUID } from "../../helpers/nostr/events";
|
||||||
|
@ -1,228 +0,0 @@
|
|||||||
import {
|
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
Flex,
|
|
||||||
Input,
|
|
||||||
Modal,
|
|
||||||
ModalBody,
|
|
||||||
ModalCloseButton,
|
|
||||||
ModalContent,
|
|
||||||
ModalOverlay,
|
|
||||||
ModalProps,
|
|
||||||
Text,
|
|
||||||
useToast,
|
|
||||||
} from "@chakra-ui/react";
|
|
||||||
import dayjs from "dayjs";
|
|
||||||
import { Kind } from "nostr-tools";
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
|
|
||||||
import { DraftNostrEvent, NostrEvent, isDTag } from "../types/nostr-event";
|
|
||||||
import { UserAvatar } from "./user-avatar";
|
|
||||||
import { UserLink } from "./user-link";
|
|
||||||
import { parsePaymentRequest, readablizeSats } from "../helpers/bolt11";
|
|
||||||
import { LightningIcon } from "./icons";
|
|
||||||
import clientRelaysService from "../services/client-relays";
|
|
||||||
import { getEventRelays } from "../services/event-relays";
|
|
||||||
import { useSigningContext } from "../providers/signing-provider";
|
|
||||||
import appSettings from "../services/settings/app-settings";
|
|
||||||
import useSubject from "../hooks/use-subject";
|
|
||||||
import useUserLNURLMetadata from "../hooks/use-user-lnurl-metadata";
|
|
||||||
import { requestZapInvoice } from "../helpers/nostr/zaps";
|
|
||||||
import { unique } from "../helpers/array";
|
|
||||||
import { useUserRelays } from "../hooks/use-user-relays";
|
|
||||||
import { RelayMode } from "../classes/relay";
|
|
||||||
import relayScoreboardService from "../services/relay-scoreboard";
|
|
||||||
import { useAdditionalRelayContext } from "../providers/additional-relay-context";
|
|
||||||
import { getEventCoordinate, isReplaceable } from "../helpers/nostr/events";
|
|
||||||
import { EmbedEvent, EmbedProps } from "./embed-event";
|
|
||||||
|
|
||||||
type FormValues = {
|
|
||||||
amount: number;
|
|
||||||
comment: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ZapModalProps = Omit<ModalProps, "children"> & {
|
|
||||||
pubkey: string;
|
|
||||||
event?: NostrEvent;
|
|
||||||
relays?: string[];
|
|
||||||
initialComment?: string;
|
|
||||||
initialAmount?: number;
|
|
||||||
onInvoice: (invoice: string) => void;
|
|
||||||
allowComment?: boolean;
|
|
||||||
showEmbed?: boolean;
|
|
||||||
embedProps?: EmbedProps;
|
|
||||||
additionalRelays?: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function ZapModal({
|
|
||||||
event,
|
|
||||||
pubkey,
|
|
||||||
relays,
|
|
||||||
onClose,
|
|
||||||
initialComment,
|
|
||||||
initialAmount,
|
|
||||||
onInvoice,
|
|
||||||
allowComment = true,
|
|
||||||
showEmbed = true,
|
|
||||||
embedProps,
|
|
||||||
additionalRelays = [],
|
|
||||||
...props
|
|
||||||
}: ZapModalProps) {
|
|
||||||
const toast = useToast();
|
|
||||||
const contextRelays = useAdditionalRelayContext();
|
|
||||||
const { requestSignature } = useSigningContext();
|
|
||||||
const { customZapAmounts } = useSubject(appSettings);
|
|
||||||
const userReadRelays = useUserRelays(pubkey)
|
|
||||||
.filter((r) => r.mode & RelayMode.READ)
|
|
||||||
.map((r) => r.url);
|
|
||||||
|
|
||||||
const {
|
|
||||||
register,
|
|
||||||
handleSubmit,
|
|
||||||
watch,
|
|
||||||
setValue,
|
|
||||||
formState: { errors, isSubmitting },
|
|
||||||
} = useForm<FormValues>({
|
|
||||||
mode: "onBlur",
|
|
||||||
defaultValues: {
|
|
||||||
amount: initialAmount ?? (parseInt(customZapAmounts.split(",")[0]) || 100),
|
|
||||||
comment: initialComment ?? "",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { metadata: lnurlMetadata, address: tipAddress } = useUserLNURLMetadata(pubkey);
|
|
||||||
|
|
||||||
const canZap = lnurlMetadata?.allowsNostr && lnurlMetadata?.nostrPubkey;
|
|
||||||
const actionName = canZap ? "Zap" : "Tip";
|
|
||||||
|
|
||||||
const onSubmitZap = handleSubmit(async (values) => {
|
|
||||||
try {
|
|
||||||
if (!tipAddress) throw new Error("No lightning address");
|
|
||||||
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 eventRelays = event ? getEventRelays(event.id).value : [];
|
|
||||||
const eventRelaysRanked = relayScoreboardService.getRankedRelays(eventRelays).slice(0, 4);
|
|
||||||
const writeRelays = clientRelaysService.getWriteUrls();
|
|
||||||
const writeRelaysRanked = relayScoreboardService.getRankedRelays(writeRelays).slice(0, 4);
|
|
||||||
const userReadRelaysRanked = relayScoreboardService.getRankedRelays(userReadRelays).slice(0, 4);
|
|
||||||
const contextRelaysRanked = relayScoreboardService.getRankedRelays(contextRelays).slice(0, 4);
|
|
||||||
|
|
||||||
const zapRequest: DraftNostrEvent = {
|
|
||||||
kind: Kind.ZapRequest,
|
|
||||||
created_at: dayjs().unix(),
|
|
||||||
content: values.comment,
|
|
||||||
tags: [
|
|
||||||
["p", pubkey],
|
|
||||||
[
|
|
||||||
"relays",
|
|
||||||
...unique([
|
|
||||||
...contextRelaysRanked,
|
|
||||||
...writeRelaysRanked,
|
|
||||||
...userReadRelaysRanked,
|
|
||||||
...eventRelaysRanked,
|
|
||||||
...additionalRelays,
|
|
||||||
]),
|
|
||||||
],
|
|
||||||
["amount", String(amountInMilisat)],
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
if (event) {
|
|
||||||
if (isReplaceable(event.kind) && event.tags.some(isDTag)) {
|
|
||||||
zapRequest.tags.push(["a", getEventCoordinate(event)]);
|
|
||||||
} else zapRequest.tags.push(["e", event.id]);
|
|
||||||
}
|
|
||||||
|
|
||||||
const signed = await requestSignature(zapRequest);
|
|
||||||
if (signed) {
|
|
||||||
const payRequest = await requestZapInvoice(signed, lnurlMetadata.callback);
|
|
||||||
await onInvoice(payRequest);
|
|
||||||
}
|
|
||||||
} 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");
|
|
||||||
|
|
||||||
await onInvoice(payRequest);
|
|
||||||
} else throw new Error("Failed to get invoice");
|
|
||||||
}
|
|
||||||
} else throw new Error("Failed to get LNURL metadata");
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof Error) toast({ status: "error", description: e.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal onClose={onClose} size="xl" {...props}>
|
|
||||||
<ModalOverlay />
|
|
||||||
<ModalContent>
|
|
||||||
<ModalCloseButton />
|
|
||||||
<ModalBody padding="4">
|
|
||||||
<form onSubmit={onSubmitZap}>
|
|
||||||
<Flex gap="4" direction="column">
|
|
||||||
<Flex gap="2" alignItems="center" overflow="hidden">
|
|
||||||
<UserAvatar pubkey={pubkey} size="md" />
|
|
||||||
<Box>
|
|
||||||
<UserLink pubkey={pubkey} fontWeight="bold" />
|
|
||||||
<Text isTruncated>{tipAddress}</Text>
|
|
||||||
</Box>
|
|
||||||
</Flex>
|
|
||||||
|
|
||||||
{showEmbed && event && <EmbedEvent event={event} {...embedProps} />}
|
|
||||||
|
|
||||||
{allowComment && (canZap || lnurlMetadata?.commentAllowed) && (
|
|
||||||
<Input
|
|
||||||
placeholder="Comment"
|
|
||||||
{...register("comment", { maxLength: lnurlMetadata?.commentAllowed ?? 150 })}
|
|
||||||
autoComplete="off"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Flex gap="2" alignItems="center" wrap="wrap">
|
|
||||||
{customZapAmounts
|
|
||||||
.split(",")
|
|
||||||
.map((v) => parseInt(v))
|
|
||||||
.map((amount, i) => (
|
|
||||||
<Button
|
|
||||||
key={amount + i}
|
|
||||||
onClick={() => {
|
|
||||||
setValue("amount", amount);
|
|
||||||
}}
|
|
||||||
leftIcon={<LightningIcon color="yellow.400" />}
|
|
||||||
variant="solid"
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
{amount}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</Flex>
|
|
||||||
|
|
||||||
<Flex gap="2">
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
placeholder="Custom amount"
|
|
||||||
isInvalid={!!errors.amount}
|
|
||||||
step={1}
|
|
||||||
flex={1}
|
|
||||||
{...register("amount", { valueAsNumber: true, min: 1 })}
|
|
||||||
/>
|
|
||||||
<Button leftIcon={<LightningIcon />} type="submit" isLoading={isSubmitting} variant="solid" size="md">
|
|
||||||
{actionName} {readablizeSats(watch("amount"))} sats
|
|
||||||
</Button>
|
|
||||||
</Flex>
|
|
||||||
</Flex>
|
|
||||||
</form>
|
|
||||||
</ModalBody>
|
|
||||||
</ModalContent>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,4 +1,5 @@
|
|||||||
import { decodeText } from "./bech32";
|
import { decodeText } from "./bech32";
|
||||||
|
import { parsePaymentRequest } from "./bolt11";
|
||||||
|
|
||||||
export function isLNURL(lnurl: string) {
|
export function isLNURL(lnurl: string) {
|
||||||
try {
|
try {
|
||||||
@ -29,3 +30,17 @@ export function getLudEndpoint(addressOrLNURL: string) {
|
|||||||
return parseLNURL(addressOrLNURL);
|
return parseLNURL(addressOrLNURL);
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getInvoiceFromCallbackUrl(callback: URL) {
|
||||||
|
const amount = callback.searchParams.get("amount");
|
||||||
|
if (!amount) throw new Error("Missing amount");
|
||||||
|
|
||||||
|
const { pr: payRequest } = await fetch(callback).then((res) => res.json());
|
||||||
|
|
||||||
|
if (payRequest as string) {
|
||||||
|
const parsed = parsePaymentRequest(payRequest);
|
||||||
|
if (parsed.amount !== parseInt(amount)) throw new Error("Incorrect amount");
|
||||||
|
|
||||||
|
return payRequest as string;
|
||||||
|
} else throw new Error("Failed to get invoice");
|
||||||
|
}
|
||||||
|
@ -3,7 +3,7 @@ import { isETag, isPTag, NostrEvent } from "../../types/nostr-event";
|
|||||||
import { ParsedInvoice, parsePaymentRequest } from "../bolt11";
|
import { ParsedInvoice, parsePaymentRequest } from "../bolt11";
|
||||||
|
|
||||||
import { Kind0ParsedContent } from "../user-metadata";
|
import { Kind0ParsedContent } from "../user-metadata";
|
||||||
import { nip57, utils } from "nostr-tools";
|
import { utils } from "nostr-tools";
|
||||||
|
|
||||||
// based on https://github.com/nbd-wtf/nostr-tools/blob/master/nip57.ts
|
// based on https://github.com/nbd-wtf/nostr-tools/blob/master/nip57.ts
|
||||||
export async function getZapEndpoint(metadata: Kind0ParsedContent): Promise<null | string> {
|
export async function getZapEndpoint(metadata: Kind0ParsedContent): Promise<null | string> {
|
||||||
@ -74,20 +74,16 @@ export function parseZapEvent(event: NostrEvent): ParsedZap {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function requestZapInvoice(zapRequest: NostrEvent, lnurl: string) {
|
export type EventSplit = { pubkey: string; percent: number; relay?: string }[];
|
||||||
const amount = zapRequest.tags.find((t) => t[0] === "amount")?.[1];
|
export function getZapSplits(event: NostrEvent): EventSplit {
|
||||||
if (!amount) throw new Error("missing amount");
|
const tags = event.tags.filter((t) => t[0] === "zap" && t[1] && t[3]) as [string, string, string, string][];
|
||||||
|
|
||||||
const callbackUrl = new URL(lnurl);
|
if (tags.length > 0) {
|
||||||
callbackUrl.searchParams.append("amount", amount);
|
const targets = tags
|
||||||
callbackUrl.searchParams.append("nostr", JSON.stringify(zapRequest));
|
.map((t) => ({ pubkey: t[1], relay: t[2], percent: parseFloat(t[3]) }))
|
||||||
|
.filter((p) => Number.isFinite(p.percent));
|
||||||
|
|
||||||
const { pr: payRequest } = await fetch(callbackUrl).then((res) => res.json());
|
const total = targets.reduce((v, p) => v + p.percent, 0);
|
||||||
|
return targets.map((p) => ({ ...p, percent: p.percent / total }));
|
||||||
if (payRequest as string) {
|
} else return [{ pubkey: event.pubkey, relay: "", percent: 1 }];
|
||||||
const parsed = parsePaymentRequest(payRequest);
|
|
||||||
if (parsed.amount !== parseInt(amount)) throw new Error("incorrect amount");
|
|
||||||
|
|
||||||
return payRequest as string;
|
|
||||||
} else throw new Error("Failed to get invoice");
|
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { Button, ButtonProps, useDisclosure } from "@chakra-ui/react";
|
import { Button, ButtonProps, useDisclosure } from "@chakra-ui/react";
|
||||||
import { NostrEvent } from "../../../types/nostr-event";
|
import { NostrEvent } from "../../../types/nostr-event";
|
||||||
import ZapModal from "../../../components/zap-modal";
|
import ZapModal from "../../../components/event-zap-modal";
|
||||||
import eventZapsService from "../../../services/event-zaps";
|
import eventZapsService from "../../../services/event-zaps";
|
||||||
import { getEventUID } from "../../../helpers/nostr/events";
|
import { getEventUID } from "../../../helpers/nostr/events";
|
||||||
import { useInvoiceModalContext } from "../../../providers/invoice-modal";
|
import { useInvoiceModalContext } from "../../../providers/invoice-modal";
|
||||||
|
@ -3,7 +3,7 @@ import { ParsedStream } from "../../../helpers/nostr/stream";
|
|||||||
import { LightningIcon } from "../../../components/icons";
|
import { LightningIcon } from "../../../components/icons";
|
||||||
import { useInvoiceModalContext } from "../../../providers/invoice-modal";
|
import { useInvoiceModalContext } from "../../../providers/invoice-modal";
|
||||||
import useUserLNURLMetadata from "../../../hooks/use-user-lnurl-metadata";
|
import useUserLNURLMetadata from "../../../hooks/use-user-lnurl-metadata";
|
||||||
import ZapModal from "../../../components/zap-modal";
|
import ZapModal from "../../../components/event-zap-modal";
|
||||||
import { useRelaySelectionRelays } from "../../../providers/relay-selection-provider";
|
import { useRelaySelectionRelays } from "../../../providers/relay-selection-provider";
|
||||||
import useStreamGoal from "../../../hooks/use-stream-goal";
|
import useStreamGoal from "../../../hooks/use-stream-goal";
|
||||||
|
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { IconButton, IconButtonProps, useDisclosure } from "@chakra-ui/react";
|
import { IconButton, IconButtonProps, useDisclosure } from "@chakra-ui/react";
|
||||||
import { useUserMetadata } from "../../../hooks/use-user-metadata";
|
import { useUserMetadata } from "../../../hooks/use-user-metadata";
|
||||||
import { LightningIcon } from "../../../components/icons";
|
import { LightningIcon } from "../../../components/icons";
|
||||||
import ZapModal from "../../../components/zap-modal";
|
import ZapModal from "../../../components/event-zap-modal";
|
||||||
import { useInvoiceModalContext } from "../../../providers/invoice-modal";
|
import { useInvoiceModalContext } from "../../../providers/invoice-modal";
|
||||||
|
|
||||||
export default function UserZapButton({ pubkey, ...props }: { pubkey: string } & Omit<IconButtonProps, "aria-label">) {
|
export default function UserZapButton({ pubkey, ...props }: { pubkey: string } & Omit<IconButtonProps, "aria-label">) {
|
||||||
|
Reference in New Issue
Block a user