feat: fast zaps

This commit is contained in:
2023-02-25 21:18:36 +00:00
parent f934dcd092
commit 8c286c04f3
14 changed files with 355 additions and 138 deletions

View File

@ -1,11 +1,10 @@
import "./SendSats.css";
import { useEffect, useMemo, useState } from "react";
import React, { useEffect, useMemo, useState } from "react";
import { useIntl, FormattedMessage } from "react-intl";
import { useSelector } from "react-redux";
import { formatShort } from "Number";
import { bech32ToText } from "Util";
import { HexKey, Tag } from "@snort/nostr";
import { Event, HexKey, Tag } from "@snort/nostr";
import { RootState } from "State/Store";
import Check from "Icons/Check";
import Zap from "Icons/Zap";
@ -18,25 +17,7 @@ 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;
}
import { LNURL, LNURLInvoice, LNURLSuccessAction } from "LNURL";
enum ZapType {
PublicZap = 1,
@ -45,9 +26,9 @@ enum ZapType {
NonZap = 4,
}
export interface LNURLTipProps {
export interface SendSatsProps {
onClose?: () => void;
svc?: string;
lnurl?: string;
show?: boolean;
invoice?: string; // shortcut to invoice qr tab
title?: string;
@ -69,10 +50,8 @@ function chunks<T>(arr: T[], length: number) {
return result;
}
export default function LNURLTip(props: LNURLTipProps) {
export default function SendSats(props: SendSatsProps) {
const onClose = props.onClose || (() => undefined);
const service = props.svc;
const show = props.show || false;
const { note, author, target } = props;
const defaultZapAmount = useSelector((s: RootState) => s.login.preferences.defaultZapAmount);
const amounts = [defaultZapAmount, 1_000, 5_000, 10_000, 20_000, 50_000, 100_000, 1_000_000];
@ -85,97 +64,68 @@ export default function LNURLTip(props: LNURLTipProps) {
100_000: "🚀",
1_000_000: "🤯",
};
const [payService, setPayService] = useState<LNURLService>();
const [handler, setHandler] = useState<LNURL>();
const [invoice, setInvoice] = useState(props.invoice);
const [amount, setAmount] = useState<number>(defaultZapAmount);
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 [error, setError] = useState<string>();
const [zapType, setZapType] = useState(ZapType.PublicZap);
const [paying, setPaying] = useState<boolean>(false);
const webln = useWebln(show);
const webln = useWebln(props.show);
const { formatMessage } = useIntl();
const publisher = useEventPublisher();
const canComment = (payService?.commentAllowed ?? 0) > 0 || payService?.nostrPubkey;
const canComment = handler ? (handler.canZap && zapType !== ZapType.NonZap) || handler.maxCommentLength > 0 : false;
useEffect(() => {
if (show && !props.invoice) {
loadService()
.then(a => setPayService(a ?? undefined))
.catch(() => setError(formatMessage(messages.LNURLFail)));
} else {
setPayService(undefined);
if (props.show) {
setError(undefined);
setInvoice(props.invoice ? { pr: props.invoice } : undefined);
setAmount(defaultZapAmount);
setComment(undefined);
setSuccess(undefined);
setZapType(ZapType.PublicZap);
setInvoice(undefined);
setSuccess(undefined);
}
}, [show, service]);
}, [props.show]);
useEffect(() => {
if (props.lnurl && props.show) {
try {
const h = new LNURL(props.lnurl);
setHandler(h);
h.load().catch(e => setError((e as Error).message));
} catch (e) {
if (e instanceof Error) {
setError(e.message);
}
}
}
}, [props.lnurl, props.show]);
const serviceAmounts = useMemo(() => {
if (payService) {
const min = (payService.minSendable ?? 0) / 1000;
const max = (payService.maxSendable ?? 0) / 1000;
if (handler) {
const min = handler.min / 1000;
const max = handler.max / 1000;
return amounts.filter(a => a >= min && a <= max);
}
return [];
}, [payService]);
}, [handler]);
const amountRows = useMemo(() => chunks(serviceAmounts, 3), [serviceAmounts]);
const selectAmount = (a: number) => {
setError(undefined);
setInvoice(undefined);
setAmount(a);
};
async function fetchJson<T>(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<LNURLService | null> {
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;
if (!amount || !handler) return null;
const callback = new URL(payService.callback);
const query = new Map<string, string>();
if (callback.search.length > 0) {
callback.search
.slice(1)
.split("&")
.forEach(a => {
const pSplit = a.split("=");
query.set(pSplit[0], pSplit[1]);
});
}
query.set("amount", Math.floor(amount * 1000).toString());
if (comment && payService?.commentAllowed) {
query.set("comment", comment);
}
if (payService.nostrPubkey && author && zapType !== ZapType.NonZap) {
const ev = await publisher.zap(author, note, comment);
let zap: Event | undefined;
if (author && zapType !== ZapType.NonZap) {
const ev = await publisher.zap(amount * 1000, author, note, comment);
if (ev) {
// replace sig for anon-zap
if (zapType === ZapType.AnonZap) {
@ -186,26 +136,15 @@ export default function LNURLTip(props: LNURLTipProps) {
ev.Tags.push(new Tag(["anon"], ev.Tags.length));
await ev.Sign(randomKey.privateKey);
}
query.set("nostr", JSON.stringify(ev.ToObject()));
zap = ev;
}
}
const baseUrl = `${callback.protocol}//${callback.host}${callback.pathname}`;
const queryJoined = [...query.entries()].map(v => `${v[0]}=${encodeURIComponent(v[1])}`).join("&");
try {
const rsp = await fetch(`${baseUrl}?${queryJoined}`);
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));
const rsp = await handler.getInvoice(amount, comment, zap);
if (rsp.pr) {
setInvoice(rsp.pr);
await payWebLNIfEnabled(rsp);
}
} catch (e) {
setError(formatMessage(messages.InvoiceFail));
@ -213,8 +152,10 @@ export default function LNURLTip(props: LNURLTipProps) {
}
function custom() {
const min = (payService?.minSendable ?? 1000) / 1000;
const max = (payService?.maxSendable ?? 21_000_000_000) / 1000;
if (!handler) return null;
const min = handler.min / 1000;
const max = handler.max / 1000;
return (
<div className="custom-amount flex">
<input
@ -269,21 +210,21 @@ export default function LNURLTip(props: LNURLTipProps) {
}
function invoiceForm() {
if (invoice) return null;
if (!handler || invoice) return null;
return (
<>
<h3>
<FormattedMessage {...messages.ZapAmount} />
</h3>
{amountRows.map(amounts => renderAmounts(amount, amounts))}
{payService && custom()}
{custom()}
<div className="flex">
{canComment && (
<input
type="text"
placeholder={formatMessage(messages.Comment)}
className="f-grow"
maxLength={payService?.commentAllowed || 120}
maxLength={handler.canZap && zapType !== ZapType.NonZap ? 250 : handler.maxCommentLength}
onChange={e => setComment(e.target.value)}
/>
)}
@ -306,9 +247,9 @@ export default function LNURLTip(props: LNURLTipProps) {
}
function zapTypeSelector() {
if (!payService || !payService.nostrPubkey) return;
if (!handler || !handler.canZap) return;
const makeTab = (t: ZapType, n: string) => (
const makeTab = (t: ZapType, n: React.ReactNode) => (
<div className={`tab${zapType === t ? " active" : ""}`} onClick={() => setZapType(t)}>
{n}
</div>
@ -319,18 +260,20 @@ export default function LNURLTip(props: LNURLTipProps) {
<FormattedMessage defaultMessage="Zap Type" />
</h3>
<div className="tabs mt10">
{makeTab(ZapType.PublicZap, "Public")}
{makeTab(ZapType.PublicZap, <FormattedMessage defaultMessage="Public" description="Public Zap" />)}
{/*makeTab(ZapType.PrivateZap, "Private")*/}
{makeTab(ZapType.AnonZap, "Anon")}
{makeTab(ZapType.NonZap, "Non-Zap")}
{makeTab(ZapType.AnonZap, <FormattedMessage defaultMessage="Anon" description="Anonymous Zap" />)}
{makeTab(
ZapType.NonZap,
<FormattedMessage defaultMessage="Non-Zap" description="Non-Zap, Regular LN payment" />
)}
</div>
</>
);
}
function payInvoice() {
if (success) return null;
const pr = invoice?.pr;
if (success || !invoice) return null;
return (
<>
<div className="invoice">
@ -341,15 +284,15 @@ export default function LNURLTip(props: LNURLTipProps) {
...
</h4>
) : (
<QrCode data={pr} link={`lightning:${pr}`} />
<QrCode data={invoice} link={`lightning:${invoice}`} />
)}
<div className="actions">
{pr && (
{invoice && (
<>
<div className="copy-action">
<Copy text={pr} maxSize={26} />
<Copy text={invoice} maxSize={26} />
</div>
<button className="wallet-action" type="button" onClick={() => window.open(`lightning:${pr}`)}>
<button className="wallet-action" type="button" onClick={() => window.open(`lightning:${invoice}`)}>
<FormattedMessage {...messages.OpenWallet} />
</button>
</>
@ -379,14 +322,14 @@ export default function LNURLTip(props: LNURLTipProps) {
);
}
const defaultTitle = payService?.nostrPubkey ? formatMessage(messages.SendZap) : formatMessage(messages.SendSats);
const defaultTitle = handler?.canZap ? formatMessage(messages.SendZap) : formatMessage(messages.SendSats);
const title = target
? formatMessage(messages.ToTarget, {
action: defaultTitle,
target,
})
: defaultTitle;
if (!show) return null;
if (!(props.show ?? false)) return null;
return (
<Modal className="lnurl-modal" onClose={onClose}>
<div className="lnurl-tip" onClick={e => e.stopPropagation()}>