256 lines
8.8 KiB
TypeScript
256 lines
8.8 KiB
TypeScript
import "./LNURLTip.css";
|
|
import { useEffect, useMemo, useState } from "react";
|
|
import { bech32ToText } from "Util";
|
|
import { HexKey } from "Nostr";
|
|
import useEventPublisher from "Feed/EventPublisher";
|
|
import Modal from "Element/Modal";
|
|
import QrCode from "Element/QrCode";
|
|
import Copy from "Element/Copy";
|
|
import useWebln from "Hooks/useWebln";
|
|
|
|
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
|
|
note?: HexKey
|
|
author?: HexKey
|
|
}
|
|
|
|
export default function LNURLTip(props: LNURLTipProps) {
|
|
const onClose = props.onClose || (() => { });
|
|
const service = props.svc;
|
|
const show = props.show || false;
|
|
const { note, author } = props
|
|
const amounts = [50, 100, 500, 1_000, 5_000, 10_000, 50_000];
|
|
const [payService, setPayService] = useState<LNURLService>();
|
|
const [amount, setAmount] = useState<number>();
|
|
const [customAmount, setCustomAmount] = useState<number>();
|
|
const [invoice, setInvoice] = useState<LNURLInvoice>();
|
|
const [comment, setComment] = useState<string>();
|
|
const [error, setError] = useState<string>();
|
|
const [success, setSuccess] = useState<LNURLSuccessAction>();
|
|
const webln = useWebln(show);
|
|
const publisher = useEventPublisher();
|
|
|
|
useEffect(() => {
|
|
if (show && !props.invoice) {
|
|
loadService()
|
|
.then(a => setPayService(a!))
|
|
.catch(() => setError("Failed to load LNURL service"));
|
|
} else {
|
|
setPayService(undefined);
|
|
setError(undefined);
|
|
setInvoice(props.invoice ? { pr: props.invoice } : undefined);
|
|
setAmount(undefined);
|
|
setComment(undefined);
|
|
setSuccess(undefined);
|
|
}
|
|
}, [show, service]);
|
|
|
|
const serviceAmounts = useMemo(() => {
|
|
if (payService) {
|
|
let min = (payService.minSendable ?? 0) / 1000;
|
|
let max = (payService.maxSendable ?? 0) / 1000;
|
|
return amounts.filter(a => a >= min && a <= max);
|
|
}
|
|
return [];
|
|
}, [payService]);
|
|
|
|
const metadata = useMemo(() => {
|
|
if (payService) {
|
|
let meta: string[][] = JSON.parse(payService.metadata);
|
|
let desc = meta.find(a => a[0] === "text/plain");
|
|
let image = meta.find(a => a[0] === "image/png;base64");
|
|
return {
|
|
description: desc ? desc[1] : null,
|
|
image: image ? image[1] : null
|
|
};
|
|
}
|
|
return null;
|
|
}, [payService]);
|
|
|
|
const selectAmount = (a: number) => {
|
|
setError(undefined);
|
|
setInvoice(undefined);
|
|
setAmount(a);
|
|
};
|
|
|
|
async function fetchJson<T>(url: string) {
|
|
let rsp = await fetch(url);
|
|
if (rsp.ok) {
|
|
let data: T = await rsp.json();
|
|
console.log(data);
|
|
setError(undefined);
|
|
return data;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function loadService(): Promise<LNURLService | null> {
|
|
if (service) {
|
|
let isServiceUrl = service.toLowerCase().startsWith("lnurl");
|
|
if (isServiceUrl) {
|
|
let serviceUrl = bech32ToText(service);
|
|
return await fetchJson(serviceUrl);
|
|
} else {
|
|
let 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 ? `&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 {
|
|
let rsp = await fetch(url);
|
|
if (rsp.ok) {
|
|
let data = await rsp.json();
|
|
console.log(data);
|
|
if (data.status === "ERROR") {
|
|
setError(data.reason);
|
|
} else {
|
|
setInvoice(data);
|
|
setError("");
|
|
payWebLNIfEnabled(data);
|
|
}
|
|
} else {
|
|
setError("Failed to load invoice");
|
|
}
|
|
} catch (e) {
|
|
setError("Failed to load invoice");
|
|
}
|
|
};
|
|
|
|
function custom() {
|
|
let min = (payService?.minSendable ?? 0) / 1000;
|
|
let max = (payService?.maxSendable ?? 21_000_000_000) / 1000;
|
|
return (
|
|
<div className="flex mb10">
|
|
<input type="number" min={min} max={max} className="f-grow mr10" value={customAmount} onChange={(e) => setCustomAmount(parseInt(e.target.value))} />
|
|
<div className="btn" onClick={() => selectAmount(customAmount!)}>Confirm</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
async function payWebLNIfEnabled(invoice: LNURLInvoice) {
|
|
try {
|
|
if (webln?.enabled) {
|
|
let res = await webln.sendPayment(invoice!.pr);
|
|
console.log(res);
|
|
setSuccess(invoice!.successAction || {});
|
|
}
|
|
} catch (e: any) {
|
|
setError(e.toString());
|
|
console.warn(e);
|
|
}
|
|
}
|
|
|
|
function invoiceForm() {
|
|
if (invoice) return null;
|
|
return (
|
|
<>
|
|
<div className="f-ellipsis mb10">{metadata?.description ?? service}</div>
|
|
<div className="flex">
|
|
{(payService?.commentAllowed ?? 0) > 0 ?
|
|
<input type="text" placeholder="Comment" className="mb10 f-grow" maxLength={payService?.commentAllowed} onChange={(e) => setComment(e.target.value)} /> : null}
|
|
</div>
|
|
<div className="mb10">
|
|
{serviceAmounts.map(a => <span className={`sat-amount ${amount === a ? "active" : ""}`} key={a} onClick={() => selectAmount(a)}>
|
|
{a.toLocaleString()}
|
|
</span>)}
|
|
{payService ?
|
|
<span className={`sat-amount ${amount === -1 ? "active" : ""}`} onClick={() => selectAmount(-1)}>
|
|
Custom
|
|
</span> : null}
|
|
</div>
|
|
{amount === -1 ? custom() : null}
|
|
{(amount ?? 0) > 0 && <button type="button" className="mb10" onClick={() => loadInvoice()}>Get Invoice</button>}
|
|
</>
|
|
)
|
|
}
|
|
|
|
function payInvoice() {
|
|
if (success) return null;
|
|
const pr = invoice?.pr;
|
|
return (
|
|
<>
|
|
<div className="invoice">
|
|
{props.notice && <b className="error">{props.notice}</b>}
|
|
<QrCode data={pr} link={`lightning:${pr}`} />
|
|
<div className="actions">
|
|
{pr && (
|
|
<>
|
|
<div className="copy-action">
|
|
<Copy text={pr} maxSize={26} />
|
|
</div>
|
|
<div className="pay-actions">
|
|
<button type="button" onClick={() => window.open(`lightning:${pr}`)}>
|
|
Open Wallet
|
|
</button>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</>
|
|
)
|
|
}
|
|
|
|
function successAction() {
|
|
if (!success) return null;
|
|
return (
|
|
<>
|
|
<p>{success?.description ?? "Paid!"}</p>
|
|
{success.url ? <a href={success.url} rel="noreferrer" target="_blank">{success.url}</a> : null}
|
|
</>
|
|
)
|
|
}
|
|
|
|
const defaultTitle = payService?.nostrPubkey ? "⚡️ Send Zap!" : "⚡️ Send sats";
|
|
if (!show) return null;
|
|
return (
|
|
<Modal onClose={onClose}>
|
|
<div className="lnurl-tip" onClick={(e) => e.stopPropagation()}>
|
|
<h2>{props.title || defaultTitle}</h2>
|
|
{invoiceForm()}
|
|
{error ? <p className="error">{error}</p> : null}
|
|
{payInvoice()}
|
|
{successAction()}
|
|
</div>
|
|
</Modal>
|
|
)
|
|
}
|