import "./SendSats.css"; import { useEffect, useMemo, useState } from "react"; import { useIntl, FormattedMessage } from "react-intl"; import { formatShort } from "Number"; import { bech32ToText } from "Util"; import { HexKey } from "@snort/nostr"; import Check from "Icons/Check"; import Zap from "Icons/Zap"; import Close from "Icons/Close"; import useEventPublisher from "Feed/EventPublisher"; import ProfileImage from "Element/ProfileImage"; import Modal from "Element/Modal"; import QrCode from "Element/QrCode"; import Copy from "Element/Copy"; import useWebln from "Hooks/useWebln"; import messages from "./messages"; interface LNURLService { nostrPubkey?: HexKey; minSendable?: number; maxSendable?: number; metadata: string; callback: string; commentAllowed?: number; } interface LNURLInvoice { pr: string; successAction?: LNURLSuccessAction; } interface LNURLSuccessAction { description?: string; url?: string; } export interface LNURLTipProps { onClose?: () => void; svc?: string; show?: boolean; invoice?: string; // shortcut to invoice qr tab title?: string; notice?: string; target?: string; note?: HexKey; author?: HexKey; } function chunks(arr: any[], length: number) { const result = []; let idx = 0; let n = arr.length / length; while (n > 0) { result.push(arr.slice(idx, idx + length)); idx += length; n -= 1; } return result; } export default function LNURLTip(props: LNURLTipProps) { const onClose = props.onClose || (() => undefined); const service = props.svc; const show = props.show || false; const { note, author, target } = props; const amounts = [500, 1_000, 5_000, 10_000, 20_000, 50_000, 100_000, 1_000_000]; const emojis: Record = { 1_000: "👍", 5_000: "💜", 10_000: "😍", 20_000: "🤩", 50_000: "🔥", 100_000: "🚀", 1_000_000: "🤯", }; const [payService, setPayService] = useState(); const [amount, setAmount] = useState(500); const [customAmount, setCustomAmount] = useState(); const [invoice, setInvoice] = useState(); const [comment, setComment] = useState(); const [error, setError] = useState(); const [success, setSuccess] = useState(); const webln = useWebln(show); const { formatMessage } = useIntl(); const publisher = useEventPublisher(); const canComment = (payService?.commentAllowed ?? 0) > 0 || payService?.nostrPubkey; useEffect(() => { if (show && !props.invoice) { loadService() .then(a => setPayService(a ?? undefined)) .catch(() => setError(formatMessage(messages.LNURLFail))); } else { setPayService(undefined); setError(undefined); setInvoice(props.invoice ? { pr: props.invoice } : undefined); setAmount(500); setComment(undefined); setSuccess(undefined); } }, [show, service]); const serviceAmounts = useMemo(() => { if (payService) { const min = (payService.minSendable ?? 0) / 1000; const max = (payService.maxSendable ?? 0) / 1000; return amounts.filter(a => a >= min && a <= max); } return []; }, [payService]); const amountRows = useMemo(() => chunks(serviceAmounts, 3), [serviceAmounts]); const selectAmount = (a: number) => { setError(undefined); setInvoice(undefined); setAmount(a); }; async function fetchJson(url: string) { const rsp = await fetch(url); if (rsp.ok) { const data: T = await rsp.json(); console.log(data); setError(undefined); return data; } return null; } async function loadService(): Promise { if (service) { const isServiceUrl = service.toLowerCase().startsWith("lnurl"); if (isServiceUrl) { const serviceUrl = bech32ToText(service); return await fetchJson(serviceUrl); } else { const ns = service.split("@"); return await fetchJson(`https://${ns[1]}/.well-known/lnurlp/${ns[0]}`); } } return null; } async function loadInvoice() { if (!amount || !payService) return null; let url = ""; const amountParam = `amount=${Math.floor(amount * 1000)}`; const commentParam = comment && payService?.commentAllowed ? `&comment=${encodeURIComponent(comment)}` : ""; if (payService.nostrPubkey && author) { const ev = await publisher.zap(author, note, comment); const nostrParam = ev && `&nostr=${encodeURIComponent(JSON.stringify(ev.ToObject()))}`; url = `${payService.callback}?${amountParam}${commentParam}${nostrParam}`; } else { url = `${payService.callback}?${amountParam}${commentParam}`; } try { const rsp = await fetch(url); if (rsp.ok) { const data = await rsp.json(); console.log(data); if (data.status === "ERROR") { setError(data.reason); } else { setInvoice(data); setError(""); payWebLNIfEnabled(data); } } else { setError(formatMessage(messages.InvoiceFail)); } } catch (e) { setError(formatMessage(messages.InvoiceFail)); } } function custom() { const min = (payService?.minSendable ?? 1000) / 1000; const max = (payService?.maxSendable ?? 21_000_000_000) / 1000; return (
setCustomAmount(parseInt(e.target.value))} />
); } async function payWebLNIfEnabled(invoice: LNURLInvoice) { try { if (webln?.enabled) { const res = await webln.sendPayment(invoice?.pr ?? ""); console.log(res); setSuccess(invoice?.successAction ?? {}); } } catch (e: unknown) { console.warn(e); if (e instanceof Error) { setError(e.toString()); } } } function renderAmounts(amount: number, amounts: number[]) { return (
{amounts.map(a => ( selectAmount(a)}> {emojis[a] && <>{emojis[a]} } {a === 1000 ? "1K" : formatShort(a)} ))}
); } function invoiceForm() { if (invoice) return null; return ( <>

{amountRows.map(amounts => renderAmounts(amount, amounts))} {payService && custom()}
{canComment && ( setComment(e.target.value)} /> )}
{(amount ?? 0) > 0 && ( )} ); } function payInvoice() { if (success) return null; const pr = invoice?.pr; return ( <>
{props.notice && {props.notice}}
{pr && ( <>
)}
); } function successAction() { if (!success) return null; return (

{success?.description ?? }

{success.url && (

{success.url}

)}
); } const defaultTitle = payService?.nostrPubkey ? formatMessage(messages.SendZap) : formatMessage(messages.SendSats); const title = target ? formatMessage(messages.ToTarget, { action: defaultTitle, target, }) : defaultTitle; if (!show) return null; return (
e.stopPropagation()}>
{author && }

{props.title || title}

{invoiceForm()} {error &&

{error}

} {payInvoice()} {successAction()}
); }