-
Notifications
You must be signed in to change notification settings - Fork 47
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
13 changed files
with
593 additions
and
284 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
--- | ||
"nostrudel": minor | ||
--- | ||
|
||
Add support for paying zap splits |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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> | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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> | ||
); | ||
} |
Oops, something went wrong.