snort/packages/app/src/Element/SendSats.tsx

351 lines
10 KiB
TypeScript
Raw Normal View History

2023-02-07 13:32:32 +00:00
import "./SendSats.css";
2023-02-25 21:18:36 +00:00
import React, { useEffect, useMemo, useState } from "react";
2023-02-08 21:10:26 +00:00
import { useIntl, FormattedMessage } from "react-intl";
2023-02-24 23:03:01 +00:00
import { useSelector } from "react-redux";
2023-02-07 13:32:32 +00:00
import { formatShort } from "Number";
2023-02-25 21:18:36 +00:00
import { Event, HexKey, Tag } from "@snort/nostr";
2023-02-24 23:03:01 +00:00
import { RootState } from "State/Store";
2023-02-07 13:32:32 +00:00
import Check from "Icons/Check";
import Zap from "Icons/Zap";
import Close from "Icons/Close";
2023-02-03 21:38:14 +00:00
import useEventPublisher from "Feed/EventPublisher";
2023-02-07 13:32:32 +00:00
import ProfileImage from "Element/ProfileImage";
2023-01-20 11:11:50 +00:00
import Modal from "Element/Modal";
import QrCode from "Element/QrCode";
import Copy from "Element/Copy";
import useWebln from "Hooks/useWebln";
2023-01-16 17:48:25 +00:00
2023-02-08 21:10:26 +00:00
import messages from "./messages";
2023-02-25 21:18:36 +00:00
import { LNURL, LNURLInvoice, LNURLSuccessAction } from "LNURL";
2023-01-16 17:48:25 +00:00
2023-02-18 20:36:42 +00:00
enum ZapType {
PublicZap = 1,
AnonZap = 2,
PrivateZap = 3,
NonZap = 4,
}
2023-02-25 21:18:36 +00:00
export interface SendSatsProps {
onClose?: () => void;
2023-02-25 21:18:36 +00:00
lnurl?: string;
show?: boolean;
invoice?: string; // shortcut to invoice qr tab
title?: string;
notice?: string;
target?: string;
note?: HexKey;
author?: HexKey;
2023-01-16 17:48:25 +00:00
}
2023-02-13 23:43:17 +00:00
function chunks<T>(arr: T[], length: number) {
2023-02-13 23:11:51 +00:00
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;
}
2023-02-25 21:18:36 +00:00
export default function SendSats(props: SendSatsProps) {
2023-02-07 19:47:57 +00:00
const onClose = props.onClose || (() => undefined);
const { note, author, target } = props;
2023-02-24 23:03:01 +00:00
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];
const emojis: Record<number, string> = {
1_000: "👍",
5_000: "💜",
10_000: "😍",
20_000: "🤩",
50_000: "🔥",
100_000: "🚀",
1_000_000: "🤯",
};
2023-02-25 21:18:36 +00:00
const [handler, setHandler] = useState<LNURL>();
const [invoice, setInvoice] = useState(props.invoice);
2023-02-24 23:03:01 +00:00
const [amount, setAmount] = useState<number>(defaultZapAmount);
const [customAmount, setCustomAmount] = useState<number>();
const [comment, setComment] = useState<string>();
const [success, setSuccess] = useState<LNURLSuccessAction>();
2023-02-25 21:18:36 +00:00
const [error, setError] = useState<string>();
2023-02-18 20:36:42 +00:00
const [zapType, setZapType] = useState(ZapType.PublicZap);
2023-02-24 23:24:18 +00:00
const [paying, setPaying] = useState<boolean>(false);
2023-02-25 21:18:36 +00:00
const webln = useWebln(props.show);
2023-02-08 21:10:26 +00:00
const { formatMessage } = useIntl();
const publisher = useEventPublisher();
2023-02-25 21:18:36 +00:00
const canComment = handler ? (handler.canZap && zapType !== ZapType.NonZap) || handler.maxCommentLength > 0 : false;
2023-01-08 20:36:36 +00:00
useEffect(() => {
2023-02-25 21:18:36 +00:00
if (props.show) {
setError(undefined);
2023-02-24 23:03:01 +00:00
setAmount(defaultZapAmount);
setComment(undefined);
2023-02-18 20:36:42 +00:00
setZapType(ZapType.PublicZap);
2023-02-25 21:18:36 +00:00
setInvoice(undefined);
setSuccess(undefined);
}
}, [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);
}
}
}
2023-02-25 21:18:36 +00:00
}, [props.lnurl, props.show]);
2023-01-08 20:36:36 +00:00
const serviceAmounts = useMemo(() => {
2023-02-25 21:18:36 +00:00
if (handler) {
const min = handler.min / 1000;
const max = handler.max / 1000;
2023-02-09 12:26:54 +00:00
return amounts.filter(a => a >= min && a <= max);
}
return [];
2023-02-25 21:18:36 +00:00
}, [handler]);
2023-02-13 23:11:51 +00:00
const amountRows = useMemo(() => chunks(serviceAmounts, 3), [serviceAmounts]);
2023-01-08 20:36:36 +00:00
const selectAmount = (a: number) => {
setError(undefined);
setAmount(a);
};
2023-01-07 23:01:32 +00:00
async function loadInvoice() {
2023-02-25 21:18:36 +00:00
if (!amount || !handler) return null;
2023-02-14 19:50:08 +00:00
2023-02-25 21:18:36 +00:00
let zap: Event | undefined;
if (author && zapType !== ZapType.NonZap) {
const ev = await publisher.zap(amount * 1000, author, note, comment);
2023-02-14 19:50:08 +00:00
if (ev) {
2023-02-18 20:36:42 +00:00
// replace sig for anon-zap
if (zapType === ZapType.AnonZap) {
const randomKey = publisher.newKey();
console.debug("Generated new key for zap: ", randomKey);
ev.PubKey = randomKey.publicKey;
ev.Id = "";
2023-02-18 21:27:06 +00:00
ev.Tags.push(new Tag(["anon"], ev.Tags.length));
2023-02-18 20:36:42 +00:00
await ev.Sign(randomKey.privateKey);
}
2023-02-25 21:18:36 +00:00
zap = ev;
2023-02-14 19:50:08 +00:00
}
2023-01-07 23:01:32 +00:00
}
2023-02-14 19:50:08 +00:00
try {
2023-02-25 21:18:36 +00:00
const rsp = await handler.getInvoice(amount, comment, zap);
if (rsp.pr) {
setInvoice(rsp.pr);
await payWebLNIfEnabled(rsp);
}
} catch (e) {
2023-02-08 21:10:26 +00:00
setError(formatMessage(messages.InvoiceFail));
2023-01-08 20:36:36 +00:00
}
}
2023-01-07 20:54:12 +00:00
function custom() {
2023-02-25 21:18:36 +00:00
if (!handler) return null;
const min = handler.min / 1000;
const max = handler.max / 1000;
return (
<div className="custom-amount flex">
<input
type="number"
min={min}
max={max}
className="f-grow mr10"
2023-02-08 21:10:26 +00:00
placeholder={formatMessage(messages.Custom)}
value={customAmount}
2023-02-09 12:26:54 +00:00
onChange={e => setCustomAmount(parseInt(e.target.value))}
/>
<button
className="secondary"
type="button"
2023-02-07 19:47:57 +00:00
disabled={!customAmount}
2023-02-09 12:26:54 +00:00
onClick={() => selectAmount(customAmount ?? 0)}>
2023-02-08 21:10:26 +00:00
<FormattedMessage {...messages.Confirm} />
</button>
</div>
);
}
2023-01-08 20:36:36 +00:00
async function payWebLNIfEnabled(invoice: LNURLInvoice) {
try {
if (webln?.enabled) {
2023-02-24 23:24:18 +00:00
setPaying(true);
2023-02-07 19:47:57 +00:00
const res = await webln.sendPayment(invoice?.pr ?? "");
console.log(res);
2023-02-07 19:47:57 +00:00
setSuccess(invoice?.successAction ?? {});
}
2023-02-07 19:47:57 +00:00
} catch (e: unknown) {
console.warn(e);
2023-02-07 19:47:57 +00:00
if (e instanceof Error) {
setError(e.toString());
}
2023-02-24 23:24:18 +00:00
} finally {
setPaying(false);
2023-01-08 20:36:36 +00:00
}
}
2023-01-08 20:36:36 +00:00
2023-02-13 23:11:51 +00:00
function renderAmounts(amount: number, amounts: number[]) {
return (
<div className="amounts">
{amounts.map(a => (
<span className={`sat-amount ${amount === a ? "active" : ""}`} key={a} onClick={() => selectAmount(a)}>
{emojis[a] && <>{emojis[a]}&nbsp;</>}
{a === 1000 ? "1K" : formatShort(a)}
</span>
))}
</div>
);
}
function invoiceForm() {
2023-02-25 21:18:36 +00:00
if (!handler || invoice) return null;
return (
<>
2023-02-08 21:10:26 +00:00
<h3>
<FormattedMessage {...messages.ZapAmount} />
</h3>
2023-02-13 23:11:51 +00:00
{amountRows.map(amounts => renderAmounts(amount, amounts))}
2023-02-25 21:18:36 +00:00
{custom()}
<div className="flex">
2023-02-09 11:32:35 +00:00
{canComment && (
<input
type="text"
placeholder={formatMessage(messages.Comment)}
className="f-grow"
2023-02-25 21:18:36 +00:00
maxLength={handler.canZap && zapType !== ZapType.NonZap ? 250 : handler.maxCommentLength}
2023-02-09 12:26:54 +00:00
onChange={e => setComment(e.target.value)}
2023-02-09 11:32:35 +00:00
/>
)}
</div>
2023-02-18 20:36:42 +00:00
{zapTypeSelector()}
{(amount ?? 0) > 0 && (
2023-02-09 12:26:54 +00:00
<button type="button" className="zap-action" onClick={() => loadInvoice()}>
<div className="zap-action-container">
2023-02-08 21:10:26 +00:00
<Zap />
{target ? (
2023-02-09 12:26:54 +00:00
<FormattedMessage {...messages.ZapTarget} values={{ target, n: formatShort(amount) }} />
2023-02-08 21:10:26 +00:00
) : (
2023-02-09 12:26:54 +00:00
<FormattedMessage {...messages.ZapSats} values={{ n: formatShort(amount) }} />
2023-02-08 21:10:26 +00:00
)}
2023-02-07 13:32:32 +00:00
</div>
</button>
)}
</>
);
}
2023-01-08 20:36:36 +00:00
2023-02-18 20:36:42 +00:00
function zapTypeSelector() {
2023-02-25 21:18:36 +00:00
if (!handler || !handler.canZap) return;
2023-02-18 20:36:42 +00:00
2023-02-25 21:18:36 +00:00
const makeTab = (t: ZapType, n: React.ReactNode) => (
2023-02-18 20:36:42 +00:00
<div className={`tab${zapType === t ? " active" : ""}`} onClick={() => setZapType(t)}>
{n}
</div>
);
return (
<>
<h3>
<FormattedMessage defaultMessage="Zap Type" />
</h3>
<div className="tabs mt10">
2023-02-25 21:18:36 +00:00
{makeTab(ZapType.PublicZap, <FormattedMessage defaultMessage="Public" description="Public Zap" />)}
2023-02-18 20:36:42 +00:00
{/*makeTab(ZapType.PrivateZap, "Private")*/}
2023-02-25 21:18:36 +00:00
{makeTab(ZapType.AnonZap, <FormattedMessage defaultMessage="Anon" description="Anonymous Zap" />)}
{makeTab(
ZapType.NonZap,
<FormattedMessage defaultMessage="Non-Zap" description="Non-Zap, Regular LN payment" />
)}
2023-02-18 20:36:42 +00:00
</div>
</>
);
}
function payInvoice() {
2023-02-25 21:18:36 +00:00
if (success || !invoice) return null;
2023-01-08 20:36:36 +00:00
return (
<>
<div className="invoice">
{props.notice && <b className="error">{props.notice}</b>}
2023-02-24 23:24:18 +00:00
{paying ? (
<h4>
<FormattedMessage defaultMessage="Paying with WebLN" />
...
</h4>
) : (
2023-02-25 21:18:36 +00:00
<QrCode data={invoice} link={`lightning:${invoice}`} />
2023-02-24 23:24:18 +00:00
)}
<div className="actions">
2023-02-25 21:18:36 +00:00
{invoice && (
<>
<div className="copy-action">
2023-02-25 21:18:36 +00:00
<Copy text={invoice} maxSize={26} />
</div>
2023-02-25 21:18:36 +00:00
<button className="wallet-action" type="button" onClick={() => window.open(`lightning:${invoice}`)}>
2023-02-08 21:10:26 +00:00
<FormattedMessage {...messages.OpenWallet} />
</button>
</>
)}
2023-02-07 13:32:32 +00:00
</div>
</div>
</>
);
}
function successAction() {
if (!success) return null;
return (
<div className="success-action">
<p className="paid">
<Check className="success mr10" />
2023-02-08 21:10:26 +00:00
{success?.description ?? <FormattedMessage {...messages.Paid} />}
</p>
{success.url && (
<p>
<a href={success.url} rel="noreferrer" target="_blank">
{success.url}
</a>
</p>
)}
</div>
);
}
2023-02-25 21:18:36 +00:00
const defaultTitle = handler?.canZap ? formatMessage(messages.SendZap) : formatMessage(messages.SendSats);
2023-02-08 21:10:26 +00:00
const title = target
? formatMessage(messages.ToTarget, {
action: defaultTitle,
target,
})
: defaultTitle;
2023-02-25 21:18:36 +00:00
if (!(props.show ?? false)) return null;
return (
<Modal className="lnurl-modal" onClose={onClose}>
2023-02-09 12:26:54 +00:00
<div className="lnurl-tip" onClick={e => e.stopPropagation()}>
<div className="close" onClick={onClose}>
<Close />
</div>
<div className="lnurl-header">
{author && <ProfileImage pubkey={author} showUsername={false} />}
<h2>{props.title || title}</h2>
</div>
{invoiceForm()}
{error && <p className="error">{error}</p>}
{payInvoice()}
{successAction()}
</div>
</Modal>
);
2023-02-03 21:38:14 +00:00
}