snort/src/Element/SendSats.tsx

359 lines
10 KiB
TypeScript
Raw Normal View History

2023-02-07 13:32:32 +00:00
import "./SendSats.css";
2023-01-07 20:54:12 +00:00
import { useEffect, useMemo, useState } from "react";
2023-02-08 21:10:26 +00:00
import { useIntl, FormattedMessage } from "react-intl";
2023-02-07 13:32:32 +00:00
import { formatShort } from "Number";
2023-01-20 11:11:50 +00:00
import { bech32ToText } from "Util";
2023-02-03 21:38:14 +00:00
import { HexKey } from "Nostr";
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-02-07 13:32:32 +00:00
import useHorizontalScroll from "Hooks/useHorizontalScroll";
2023-01-16 17:48:25 +00:00
2023-02-08 21:10:26 +00:00
import messages from "./messages";
2023-01-16 17:48:25 +00:00
interface LNURLService {
nostrPubkey?: HexKey;
minSendable?: number;
maxSendable?: number;
metadata: string;
callback: string;
commentAllowed?: number;
2023-01-16 17:48:25 +00:00
}
interface LNURLInvoice {
pr: string;
successAction?: LNURLSuccessAction;
2023-01-16 17:48:25 +00:00
}
interface LNURLSuccessAction {
description?: string;
url?: string;
2023-01-16 17:48:25 +00:00
}
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;
2023-01-16 17:48:25 +00:00
}
export default function LNURLTip(props: LNURLTipProps) {
const onClose = props.onClose || (() => {});
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<number, string> = {
1_000: "👍",
5_000: "💜",
10_000: "😍",
20_000: "🤩",
50_000: "🔥",
100_000: "🚀",
1_000_000: "🤯",
};
const [payService, setPayService] = useState<LNURLService>();
const [amount, setAmount] = useState<number>(500);
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);
2023-02-08 21:10:26 +00:00
const { formatMessage } = useIntl();
const publisher = useEventPublisher();
const horizontalScroll = useHorizontalScroll();
2023-02-09 11:32:35 +00:00
const canComment =
(payService?.commentAllowed ?? 0) > 0 || payService?.nostrPubkey;
2023-01-08 20:36:36 +00:00
useEffect(() => {
if (show && !props.invoice) {
loadService()
.then((a) => setPayService(a!))
2023-02-08 21:10:26 +00:00
.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]);
2023-01-08 20:36:36 +00:00
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]);
2023-01-08 20:36:36 +00:00
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]);
2023-01-07 20:54:12 +00:00
const selectAmount = (a: number) => {
setError(undefined);
setInvoice(undefined);
setAmount(a);
};
2023-01-07 23:01:32 +00:00
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;
2023-01-07 20:54:12 +00:00
}
return null;
}
2023-01-07 20:54:12 +00:00
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]}`);
}
2023-01-07 20:54:12 +00:00
}
return null;
}
2023-01-07 20:54:12 +00:00
async function loadInvoice() {
if (!amount || !payService) return null;
let url = "";
const amountParam = `amount=${Math.floor(amount * 1000)}`;
2023-02-09 11:32:35 +00:00
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}`;
2023-01-07 23:01:32 +00:00
}
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);
2023-01-07 20:54:12 +00:00
}
} else {
2023-02-08 21:10:26 +00:00
setError(formatMessage(messages.InvoiceFail));
}
} 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() {
let min = (payService?.minSendable ?? 1000) / 1000;
let max = (payService?.maxSendable ?? 21_000_000_000) / 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}
onChange={(e) => setCustomAmount(parseInt(e.target.value))}
/>
<button
className="secondary"
type="button"
disabled={!Boolean(customAmount)}
onClick={() => selectAmount(customAmount!)}
>
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) {
let res = await webln.sendPayment(invoice!.pr);
console.log(res);
setSuccess(invoice!.successAction || {});
}
} catch (e: any) {
setError(e.toString());
console.warn(e);
2023-01-08 20:36:36 +00:00
}
}
2023-01-08 20:36:36 +00:00
function invoiceForm() {
if (invoice) return null;
return (
<>
2023-02-08 21:10:26 +00:00
<h3>
<FormattedMessage {...messages.ZapAmount} />
</h3>
<div className="amounts" ref={horizontalScroll}>
{serviceAmounts.map((a) => (
<span
className={`sat-amount ${amount === a ? "active" : ""}`}
key={a}
onClick={() => selectAmount(a)}
>
{emojis[a] && <>{emojis[a]}&nbsp;</>}
{formatShort(a)}
</span>
))}
</div>
{payService && custom()}
<div className="flex">
2023-02-09 11:32:35 +00:00
{canComment && (
<input
type="text"
placeholder={formatMessage(messages.Comment)}
className="f-grow"
maxLength={payService?.commentAllowed || 120}
onChange={(e) => setComment(e.target.value)}
/>
)}
</div>
{(amount ?? 0) > 0 && (
<button
type="button"
className="zap-action"
onClick={() => loadInvoice()}
>
<div className="zap-action-container">
2023-02-08 21:10:26 +00:00
<Zap />
{target ? (
<FormattedMessage
{...messages.ZapTarget}
values={{ target, n: formatShort(amount) }}
/>
) : (
<FormattedMessage
{...messages.ZapSats}
values={{ n: formatShort(amount) }}
/>
)}
2023-02-07 13:32:32 +00:00
</div>
</button>
)}
</>
);
}
2023-01-08 20:36:36 +00:00
function payInvoice() {
if (success) return null;
const pr = invoice?.pr;
2023-01-08 20:36:36 +00:00
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>
<button
className="wallet-action"
type="button"
onClick={() => window.open(`lightning:${pr}`)}
>
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-08 21:10:26 +00:00
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 (
<Modal className="lnurl-modal" onClose={onClose}>
<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
}