diff --git a/packages/app/package.json b/packages/app/package.json
index 25c7b497..4f9cc05f 100644
--- a/packages/app/package.json
+++ b/packages/app/package.json
@@ -11,6 +11,7 @@
"@noble/secp256k1": "^1.7.0",
"@protobufjs/base64": "^1.1.2",
"@reduxjs/toolkit": "^1.9.1",
+ "@snort/nostr": "^1.0.0",
"@szhsin/react-menu": "^3.3.1",
"@types/jest": "^29.2.5",
"@types/node": "^18.11.18",
@@ -37,6 +38,7 @@
"react-twitter-embed": "^4.0.4",
"typescript": "^4.9.4",
"unist-util-visit": "^4.1.2",
+ "use-long-press": "^2.0.3",
"uuid": "^9.0.0",
"workbox-background-sync": "^6.4.2",
"workbox-broadcast-update": "^6.4.2",
@@ -49,8 +51,7 @@
"workbox-range-requests": "^6.4.2",
"workbox-routing": "^6.4.2",
"workbox-strategies": "^6.4.2",
- "workbox-streams": "^6.4.2",
- "@snort/nostr": "^1.0.0"
+ "workbox-streams": "^6.4.2"
},
"scripts": {
"start": "react-app-rewired start",
diff --git a/packages/app/src/Element/NoteFooter.tsx b/packages/app/src/Element/NoteFooter.tsx
index f35e3781..c2be3ce2 100644
--- a/packages/app/src/Element/NoteFooter.tsx
+++ b/packages/app/src/Element/NoteFooter.tsx
@@ -1,7 +1,8 @@
-import { useState } from "react";
+import React, { useState } from "react";
import { useSelector, useDispatch } from "react-redux";
import { useIntl, FormattedMessage } from "react-intl";
import { Menu, MenuItem } from "@szhsin/react-menu";
+import { useLongPress } from "use-long-press";
import Bookmark from "Icons/Bookmark";
import Pin from "Icons/Pin";
@@ -20,7 +21,7 @@ import Zap from "Icons/Zap";
import Reply from "Icons/Reply";
import { formatShort } from "Number";
import useEventPublisher from "Feed/EventPublisher";
-import { hexToBech32, normalizeReaction } from "Util";
+import { hexToBech32, normalizeReaction, unwrap } from "Util";
import { NoteCreator } from "Element/NoteCreator";
import Reactions from "Element/Reactions";
import SendSats from "Element/SendSats";
@@ -31,6 +32,10 @@ import { RootState } from "State/Store";
import { UserPreferences, setPinned, setBookmarked } from "State/Login";
import useModeration from "Hooks/useModeration";
import { TranslateHost } from "Const";
+import useWebln from "Hooks/useWebln";
+import { LNURL } from "LNURL";
+import Spinner from "Icons/Spinner";
+import ZapFast from "Icons/ZapFast";
import messages from "./messages";
@@ -63,6 +68,8 @@ export default function NoteFooter(props: NoteFooterProps) {
const publisher = useEventPublisher();
const [reply, setReply] = useState(false);
const [tip, setTip] = useState(false);
+ const [zapping, setZapping] = useState(false);
+ const webln = useWebln();
const isMine = ev.RootPubKey === login;
const lang = window.navigator.language;
const langNames = new Intl.DisplayNames([...window.navigator.languages], {
@@ -70,6 +77,15 @@ export default function NoteFooter(props: NoteFooterProps) {
});
const zapTotal = zaps.reduce((acc, z) => acc + z.amount, 0);
const didZap = zaps.some(a => a.zapper === login);
+ const longPress = useLongPress(
+ e => {
+ e.stopPropagation();
+ setTip(true);
+ },
+ {
+ captureEvent: true,
+ }
+ );
function hasReacted(emoji: string) {
return positive?.some(({ pubkey, content }) => normalizeReaction(content) === emoji && pubkey === login);
@@ -102,15 +118,38 @@ export default function NoteFooter(props: NoteFooterProps) {
}
}
+ async function fastZap(e: React.MouseEvent) {
+ if (zapping || e.isPropagationStopped()) return;
+
+ const lnurl = author?.lud16 || author?.lud06;
+ if (webln?.enabled && lnurl) {
+ setZapping(true);
+ try {
+ const handler = new LNURL(lnurl);
+ await handler.load();
+ const zap = handler.canZap ? await publisher.zap(prefs.defaultZapAmount * 1000, ev.PubKey, ev.Id) : undefined;
+ const invoice = await handler.getInvoice(prefs.defaultZapAmount, undefined, zap);
+ await await webln.sendPayment(unwrap(invoice.pr));
+ } catch (e) {
+ console.warn("Fast zap failed", e);
+ if (!(e instanceof Error) || e.message !== "User rejected") {
+ setTip(true);
+ }
+ } finally {
+ setZapping(false);
+ }
+ } else {
+ setTip(true);
+ }
+ }
+
function tipButton() {
const service = author?.lud16 || author?.lud06;
if (service) {
return (
<>
-
setTip(true)}>
-
-
-
+
fastZap(e)}>
+
{zapping ? : webln?.enabled ? : }
{zapTotal > 0 &&
{formatShort(zapTotal)}
}
>
@@ -309,7 +348,7 @@ export default function NoteFooter(props: NoteFooterProps) {
zaps={zaps}
/>
setTip(false)}
show={tip}
author={author?.pubkey}
diff --git a/packages/app/src/Element/SendSats.tsx b/packages/app/src/Element/SendSats.tsx
index bc107474..f4c76ba5 100644
--- a/packages/app/src/Element/SendSats.tsx
+++ b/packages/app/src/Element/SendSats.tsx
@@ -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";
@@ -16,28 +15,11 @@ import Modal from "Element/Modal";
import QrCode from "Element/QrCode";
import Copy from "Element/Copy";
import useWebln from "Hooks/useWebln";
+import { LNURL, LNURLError, LNURLErrorCode, LNURLInvoice, LNURLSuccessAction } from "LNURL";
+import { debounce } from "Util";
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;
-}
-
enum ZapType {
PublicZap = 1,
AnonZap = 2,
@@ -45,9 +27,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 +51,8 @@ function chunks(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 +65,77 @@ export default function LNURLTip(props: LNURLTipProps) {
100_000: "🚀",
1_000_000: "🤯",
};
- const [payService, setPayService] = useState();
+
+ const [handler, setHandler] = useState();
+ const [invoice, setInvoice] = useState(props.invoice);
const [amount, setAmount] = useState(defaultZapAmount);
const [customAmount, setCustomAmount] = useState();
- const [invoice, setInvoice] = useState();
const [comment, setComment] = useState();
- const [error, setError] = useState();
const [success, setSuccess] = useState();
+ const [error, setError] = useState();
const [zapType, setZapType] = useState(ZapType.PublicZap);
const [paying, setPaying] = useState(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 (success && !success.url) {
+ // Fire onClose when success is set with no URL action
+ return debounce(1_000, () => {
+ onClose();
+ });
+ }
+ }, [success]);
+
+ useEffect(() => {
+ if (props.lnurl && props.show) {
+ try {
+ const h = new LNURL(props.lnurl);
+ setHandler(h);
+ h.load().catch(e => handleLNURLError(e, formatMessage(messages.InvoiceFail)));
+ } 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(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 {
- 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();
- 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,35 +146,42 @@ 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));
+ handleLNURLError(e, formatMessage(messages.InvoiceFail));
}
}
+ function handleLNURLError(e: unknown, fallback: string) {
+ if (e instanceof LNURLError) {
+ switch (e.code) {
+ case LNURLErrorCode.ServiceUnavailable: {
+ setError(formatMessage(messages.LNURLFail));
+ return;
+ }
+ case LNURLErrorCode.InvalidLNURL: {
+ setError(formatMessage(messages.InvalidLNURL));
+ return;
+ }
+ }
+ }
+ setError(fallback);
+ }
+
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 (
{amountRows.map(amounts => renderAmounts(amount, amounts))}
- {payService && custom()}
+ {custom()}
{canComment && (
setComment(e.target.value)}
/>
)}
@@ -306,9 +273,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) => (
setZapType(t)}>
{n}
@@ -319,18 +286,20 @@ export default function LNURLTip(props: LNURLTipProps) {
- {makeTab(ZapType.PublicZap, "Public")}
+ {makeTab(ZapType.PublicZap, )}
{/*makeTab(ZapType.PrivateZap, "Private")*/}
- {makeTab(ZapType.AnonZap, "Anon")}
- {makeTab(ZapType.NonZap, "Non-Zap")}
+ {makeTab(ZapType.AnonZap, )}
+ {makeTab(
+ ZapType.NonZap,
+
+ )}
>
);
}
function payInvoice() {
- if (success) return null;
- const pr = invoice?.pr;
+ if (success || !invoice) return null;
return (
<>
@@ -341,15 +310,15 @@ export default function LNURLTip(props: LNURLTipProps) {
...
) : (
-
+
)}
- {pr && (
+ {invoice && (
<>
-
+
-