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-11 20:05:46 +00:00
|
|
|
import { HexKey } from "@snort/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";
|
2023-01-26 15:57:36 +00:00
|
|
|
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 {
|
2023-02-07 20:04:50 +00:00
|
|
|
nostrPubkey?: HexKey;
|
|
|
|
minSendable?: number;
|
|
|
|
maxSendable?: number;
|
|
|
|
metadata: string;
|
|
|
|
callback: string;
|
|
|
|
commentAllowed?: number;
|
2023-01-16 17:48:25 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
interface LNURLInvoice {
|
2023-02-07 20:04:50 +00:00
|
|
|
pr: string;
|
|
|
|
successAction?: LNURLSuccessAction;
|
2023-01-16 17:48:25 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
interface LNURLSuccessAction {
|
2023-02-07 20:04:50 +00:00
|
|
|
description?: string;
|
|
|
|
url?: string;
|
2023-01-16 17:48:25 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
export interface LNURLTipProps {
|
2023-02-07 20:04:50 +00:00
|
|
|
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) {
|
2023-02-07 19:47:57 +00:00
|
|
|
const onClose = props.onClose || (() => undefined);
|
2023-02-07 20:04:50 +00:00
|
|
|
const service = props.svc;
|
|
|
|
const show = props.show || false;
|
|
|
|
const { note, author, target } = props;
|
2023-02-09 12:26:54 +00:00
|
|
|
const amounts = [500, 1_000, 5_000, 10_000, 20_000, 50_000, 100_000, 1_000_000];
|
2023-02-07 20:04:50 +00:00
|
|
|
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();
|
2023-02-07 20:04:50 +00:00
|
|
|
const publisher = useEventPublisher();
|
|
|
|
const horizontalScroll = useHorizontalScroll();
|
2023-02-09 12:26:54 +00:00
|
|
|
const canComment = (payService?.commentAllowed ?? 0) > 0 || payService?.nostrPubkey;
|
2023-01-08 20:36:36 +00:00
|
|
|
|
2023-02-07 20:04:50 +00:00
|
|
|
useEffect(() => {
|
|
|
|
if (show && !props.invoice) {
|
|
|
|
loadService()
|
2023-02-09 12:26:54 +00:00
|
|
|
.then(a => setPayService(a ?? undefined))
|
2023-02-08 21:10:26 +00:00
|
|
|
.catch(() => setError(formatMessage(messages.LNURLFail)));
|
2023-02-07 20:04:50 +00:00
|
|
|
} 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
|
|
|
|
2023-02-07 20:04:50 +00:00
|
|
|
const serviceAmounts = useMemo(() => {
|
|
|
|
if (payService) {
|
2023-02-07 19:47:57 +00:00
|
|
|
const min = (payService.minSendable ?? 0) / 1000;
|
|
|
|
const max = (payService.maxSendable ?? 0) / 1000;
|
2023-02-09 12:26:54 +00:00
|
|
|
return amounts.filter(a => a >= min && a <= max);
|
2023-02-07 20:04:50 +00:00
|
|
|
}
|
|
|
|
return [];
|
|
|
|
}, [payService]);
|
2023-01-08 20:36:36 +00:00
|
|
|
|
2023-02-07 20:04:50 +00:00
|
|
|
const selectAmount = (a: number) => {
|
|
|
|
setError(undefined);
|
|
|
|
setInvoice(undefined);
|
|
|
|
setAmount(a);
|
|
|
|
};
|
2023-01-07 23:01:32 +00:00
|
|
|
|
2023-02-07 20:04:50 +00:00
|
|
|
async function fetchJson<T>(url: string) {
|
2023-02-07 19:47:57 +00:00
|
|
|
const rsp = await fetch(url);
|
2023-02-07 20:04:50 +00:00
|
|
|
if (rsp.ok) {
|
2023-02-07 19:47:57 +00:00
|
|
|
const data: T = await rsp.json();
|
2023-02-07 20:04:50 +00:00
|
|
|
console.log(data);
|
|
|
|
setError(undefined);
|
|
|
|
return data;
|
2023-01-07 20:54:12 +00:00
|
|
|
}
|
2023-02-07 20:04:50 +00:00
|
|
|
return null;
|
|
|
|
}
|
2023-01-07 20:54:12 +00:00
|
|
|
|
2023-02-07 20:04:50 +00:00
|
|
|
async function loadService(): Promise<LNURLService | null> {
|
|
|
|
if (service) {
|
2023-02-07 19:47:57 +00:00
|
|
|
const isServiceUrl = service.toLowerCase().startsWith("lnurl");
|
2023-02-07 20:04:50 +00:00
|
|
|
if (isServiceUrl) {
|
2023-02-07 19:47:57 +00:00
|
|
|
const serviceUrl = bech32ToText(service);
|
2023-02-07 20:04:50 +00:00
|
|
|
return await fetchJson(serviceUrl);
|
|
|
|
} else {
|
2023-02-07 19:47:57 +00:00
|
|
|
const ns = service.split("@");
|
2023-02-07 20:04:50 +00:00
|
|
|
return await fetchJson(`https://${ns[1]}/.well-known/lnurlp/${ns[0]}`);
|
|
|
|
}
|
2023-01-07 20:54:12 +00:00
|
|
|
}
|
2023-02-07 20:04:50 +00:00
|
|
|
return null;
|
|
|
|
}
|
2023-01-07 20:54:12 +00:00
|
|
|
|
2023-02-07 20:04:50 +00:00
|
|
|
async function loadInvoice() {
|
|
|
|
if (!amount || !payService) return null;
|
|
|
|
let url = "";
|
|
|
|
const amountParam = `amount=${Math.floor(amount * 1000)}`;
|
2023-02-09 12:26:54 +00:00
|
|
|
const commentParam = comment && payService?.commentAllowed ? `&comment=${encodeURIComponent(comment)}` : "";
|
2023-02-07 20:04:50 +00:00
|
|
|
if (payService.nostrPubkey && author) {
|
|
|
|
const ev = await publisher.zap(author, note, comment);
|
2023-02-09 12:26:54 +00:00
|
|
|
const nostrParam = ev && `&nostr=${encodeURIComponent(JSON.stringify(ev.ToObject()))}`;
|
2023-02-07 20:04:50 +00:00
|
|
|
url = `${payService.callback}?${amountParam}${commentParam}${nostrParam}`;
|
|
|
|
} else {
|
|
|
|
url = `${payService.callback}?${amountParam}${commentParam}`;
|
2023-01-07 23:01:32 +00:00
|
|
|
}
|
2023-02-07 20:04:50 +00:00
|
|
|
try {
|
2023-02-07 19:47:57 +00:00
|
|
|
const rsp = await fetch(url);
|
2023-02-07 20:04:50 +00:00
|
|
|
if (rsp.ok) {
|
2023-02-07 19:47:57 +00:00
|
|
|
const data = await rsp.json();
|
2023-02-07 20:04:50 +00:00
|
|
|
console.log(data);
|
|
|
|
if (data.status === "ERROR") {
|
|
|
|
setError(data.reason);
|
|
|
|
} else {
|
|
|
|
setInvoice(data);
|
|
|
|
setError("");
|
|
|
|
payWebLNIfEnabled(data);
|
2023-01-07 20:54:12 +00:00
|
|
|
}
|
2023-02-07 20:04:50 +00:00
|
|
|
} else {
|
2023-02-08 21:10:26 +00:00
|
|
|
setError(formatMessage(messages.InvoiceFail));
|
2023-02-07 20:04:50 +00:00
|
|
|
}
|
|
|
|
} catch (e) {
|
2023-02-08 21:10:26 +00:00
|
|
|
setError(formatMessage(messages.InvoiceFail));
|
2023-01-08 20:36:36 +00:00
|
|
|
}
|
2023-02-07 20:04:50 +00:00
|
|
|
}
|
2023-01-07 20:54:12 +00:00
|
|
|
|
2023-02-07 20:04:50 +00:00
|
|
|
function custom() {
|
2023-02-07 19:47:57 +00:00
|
|
|
const min = (payService?.minSendable ?? 1000) / 1000;
|
|
|
|
const max = (payService?.maxSendable ?? 21_000_000_000) / 1000;
|
2023-02-07 20:04:50 +00:00
|
|
|
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)}
|
2023-02-07 20:04:50 +00:00
|
|
|
value={customAmount}
|
2023-02-09 12:26:54 +00:00
|
|
|
onChange={e => setCustomAmount(parseInt(e.target.value))}
|
2023-02-07 20:04:50 +00:00
|
|
|
/>
|
|
|
|
<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} />
|
2023-02-07 20:04:50 +00:00
|
|
|
</button>
|
|
|
|
</div>
|
|
|
|
);
|
|
|
|
}
|
2023-01-08 20:36:36 +00:00
|
|
|
|
2023-02-07 20:04:50 +00:00
|
|
|
async function payWebLNIfEnabled(invoice: LNURLInvoice) {
|
|
|
|
try {
|
|
|
|
if (webln?.enabled) {
|
2023-02-07 19:47:57 +00:00
|
|
|
const res = await webln.sendPayment(invoice?.pr ?? "");
|
2023-02-07 20:04:50 +00:00
|
|
|
console.log(res);
|
2023-02-07 19:47:57 +00:00
|
|
|
setSuccess(invoice?.successAction ?? {});
|
2023-02-07 20:04:50 +00:00
|
|
|
}
|
2023-02-07 19:47:57 +00:00
|
|
|
} catch (e: unknown) {
|
2023-02-07 20:04:50 +00:00
|
|
|
console.warn(e);
|
2023-02-07 19:47:57 +00:00
|
|
|
if (e instanceof Error) {
|
|
|
|
setError(e.toString());
|
|
|
|
}
|
2023-01-08 20:36:36 +00:00
|
|
|
}
|
2023-02-07 20:04:50 +00:00
|
|
|
}
|
2023-01-08 20:36:36 +00:00
|
|
|
|
2023-02-07 20:04:50 +00:00
|
|
|
function invoiceForm() {
|
|
|
|
if (invoice) return null;
|
|
|
|
return (
|
|
|
|
<>
|
2023-02-08 21:10:26 +00:00
|
|
|
<h3>
|
|
|
|
<FormattedMessage {...messages.ZapAmount} />
|
|
|
|
</h3>
|
2023-02-07 20:04:50 +00:00
|
|
|
<div className="amounts" ref={horizontalScroll}>
|
2023-02-09 12:26:54 +00:00
|
|
|
{serviceAmounts.map(a => (
|
|
|
|
<span className={`sat-amount ${amount === a ? "active" : ""}`} key={a} onClick={() => selectAmount(a)}>
|
2023-02-07 20:04:50 +00:00
|
|
|
{emojis[a] && <>{emojis[a]} </>}
|
|
|
|
{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}
|
2023-02-09 12:26:54 +00:00
|
|
|
onChange={e => setComment(e.target.value)}
|
2023-02-09 11:32:35 +00:00
|
|
|
/>
|
|
|
|
)}
|
2023-02-07 20:04:50 +00:00
|
|
|
</div>
|
|
|
|
{(amount ?? 0) > 0 && (
|
2023-02-09 12:26:54 +00:00
|
|
|
<button type="button" className="zap-action" onClick={() => loadInvoice()}>
|
2023-02-07 20:04:50 +00:00
|
|
|
<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>
|
2023-02-07 20:04:50 +00:00
|
|
|
</button>
|
|
|
|
)}
|
|
|
|
</>
|
|
|
|
);
|
|
|
|
}
|
2023-01-08 20:36:36 +00:00
|
|
|
|
2023-02-07 20:04:50 +00:00
|
|
|
function payInvoice() {
|
|
|
|
if (success) return null;
|
|
|
|
const pr = invoice?.pr;
|
2023-01-08 20:36:36 +00:00
|
|
|
return (
|
2023-02-07 20:04:50 +00:00
|
|
|
<>
|
|
|
|
<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>
|
2023-02-09 12:26:54 +00:00
|
|
|
<button className="wallet-action" type="button" onClick={() => window.open(`lightning:${pr}`)}>
|
2023-02-08 21:10:26 +00:00
|
|
|
<FormattedMessage {...messages.OpenWallet} />
|
2023-02-07 20:04:50 +00:00
|
|
|
</button>
|
|
|
|
</>
|
|
|
|
)}
|
2023-02-07 13:32:32 +00:00
|
|
|
</div>
|
2023-02-07 20:04:50 +00:00
|
|
|
</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} />}
|
2023-02-07 20:04:50 +00:00
|
|
|
</p>
|
|
|
|
{success.url && (
|
|
|
|
<p>
|
|
|
|
<a href={success.url} rel="noreferrer" target="_blank">
|
|
|
|
{success.url}
|
|
|
|
</a>
|
|
|
|
</p>
|
|
|
|
)}
|
|
|
|
</div>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2023-02-09 12:26:54 +00:00
|
|
|
const defaultTitle = payService?.nostrPubkey ? 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-07 20:04:50 +00:00
|
|
|
if (!show) 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()}>
|
2023-02-07 20:04:50 +00:00
|
|
|
<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
|
|
|
}
|