Merge pull request #370 from v0l/long-press

Fast Zaps 
This commit is contained in:
Kieran 2023-02-27 21:34:30 +00:00 committed by GitHub
commit 5f6f8f5c44
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 419 additions and 141 deletions

View File

@ -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",

View File

@ -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 (
<>
<div className={`reaction-pill ${didZap ? "reacted" : ""}`} onClick={() => setTip(true)}>
<div className="reaction-pill-icon">
<Zap />
</div>
<div className={`reaction-pill ${didZap ? "reacted" : ""}`} {...longPress()} onClick={e => fastZap(e)}>
<div className="reaction-pill-icon">{zapping ? <Spinner /> : webln?.enabled ? <ZapFast /> : <Zap />}</div>
{zapTotal > 0 && <div className="reaction-pill-number">{formatShort(zapTotal)}</div>}
</div>
</>
@ -309,7 +348,7 @@ export default function NoteFooter(props: NoteFooterProps) {
zaps={zaps}
/>
<SendSats
svc={author?.lud16 || author?.lud06}
lnurl={author?.lud16 || author?.lud06}
onClose={() => setTip(false)}
show={tip}
author={author?.pubkey}

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";
@ -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<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 +65,77 @@ 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 (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<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,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 (
<div className="custom-amount flex">
<input
@ -269,21 +236,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 +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) => (
<div className={`tab${zapType === t ? " active" : ""}`} onClick={() => setZapType(t)}>
{n}
</div>
@ -319,18 +286,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 +310,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 +348,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()}>

View File

@ -2,25 +2,30 @@ import "./ZapButton.css";
import { faBolt } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useState } from "react";
import { useLongPress } from "use-long-press";
import { useUserProfile } from "Feed/ProfileFeed";
import { HexKey } from "@snort/nostr";
import SendSats from "Element/SendSats";
const ZapButton = ({ pubkey, svc }: { pubkey: HexKey; svc?: string }) => {
const ZapButton = ({ pubkey, lnurl }: { pubkey: HexKey; lnurl?: string }) => {
const profile = useUserProfile(pubkey);
const [zap, setZap] = useState(false);
const service = svc ?? (profile?.lud16 || profile?.lud06);
const service = lnurl ?? (profile?.lud16 || profile?.lud06);
const longPress = useLongPress(() => {
console.debug("long press");
});
if (!service) return null;
return (
<>
<div className="zap-button" onClick={() => setZap(true)}>
<div className="zap-button" {...longPress()}>
<FontAwesomeIcon icon={faBolt} />
</div>
<SendSats
target={profile?.display_name || profile?.name}
svc={service}
lnurl={service}
show={zap}
onClose={() => setZap(false)}
author={pubkey}

View File

@ -60,6 +60,7 @@ export default defineMessages({
Milliseconds: { defaultMessage: "{n} ms" },
ShowLatest: { defaultMessage: "Show latest {n} notes" },
LNURLFail: { defaultMessage: "Failed to load LNURL service" },
InvalidLNURL: { defaultMessage: "Invalid LNURL" },
InvoiceFail: { defaultMessage: "Failed to load invoice" },
Custom: { defaultMessage: "Custom" },
Confirm: { defaultMessage: "Confirm" },

View File

@ -191,7 +191,7 @@ export default function useEventPublisher() {
return await signEvent(ev);
}
},
zap: async (author: HexKey, note?: HexKey, msg?: string) => {
zap: async (amount: number, author: HexKey, note?: HexKey, msg?: string) => {
if (pubKey) {
const ev = NEvent.ForPubKey(pubKey);
ev.Kind = EventKind.ZapRequest;
@ -201,6 +201,7 @@ export default function useEventPublisher() {
ev.Tags.push(new Tag(["p", author], ev.Tags.length));
const relayTag = ["relays", ...Object.keys(relays)];
ev.Tags.push(new Tag(relayTag, ev.Tags.length));
ev.Tags.push(new Tag(["amount", amount.toString()], ev.Tags.length));
processContent(ev, msg || "");
return await signEvent(ev);
}

View File

@ -1,11 +1,19 @@
import { useEffect } from "react";
interface WebLNPaymentResponse {
paymentHash: string;
preimage: string;
route: {
total_amt: number;
total_fees: number;
};
}
declare global {
interface Window {
webln?: {
enabled: boolean;
enable: () => Promise<void>;
sendPayment: (pr: string) => Promise<unknown>;
sendPayment: (pr: string) => Promise<WebLNPaymentResponse>;
};
}
}

View File

@ -0,0 +1,33 @@
.spinner_V8m1 {
transform-origin: center;
animation: spinner_zKoa 2s linear infinite;
}
.spinner_V8m1 circle {
stroke-linecap: round;
animation: spinner_YpZS 1.5s ease-in-out infinite;
}
@keyframes spinner_zKoa {
100% {
transform: rotate(360deg);
}
}
@keyframes spinner_YpZS {
0% {
stroke-dasharray: 0 150;
stroke-dashoffset: 0;
}
47.5% {
stroke-dasharray: 42 150;
stroke-dashoffset: -16;
}
95%,
100% {
stroke-dasharray: 42 150;
stroke-dashoffset: -59;
}
}

View File

@ -0,0 +1,12 @@
import IconProps from "./IconProps";
import "./Spinner.css";
const Spinner = (props: IconProps) => (
<svg width="20" height="20" stroke="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg" {...props}>
<g className="spinner_V8m1">
<circle cx="10" cy="10" r="7.5" fill="none" stroke-width="3"></circle>
</g>
</svg>
);
export default Spinner;

View File

@ -0,0 +1,17 @@
import IconProps from "./IconProps";
const ZapFast = (props: IconProps) => {
return (
<svg width="23" height="20" viewBox="0 0 23 20" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path
d="M8 15.5H2.5M5.5 10H1M8 4.5H3M16 1L9.40357 10.235C9.1116 10.6438 8.96562 10.8481 8.97194 11.0185C8.97744 11.1669 9.04858 11.3051 9.1661 11.3958C9.30108 11.5 9.55224 11.5 10.0546 11.5H15L14 19L20.5964 9.76499C20.8884 9.35624 21.0344 9.15187 21.0281 8.98147C21.0226 8.83312 20.9514 8.69489 20.8339 8.60418C20.6989 8.5 20.4478 8.5 19.9454 8.5H15L16 1Z"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
};
export default ZapFast;

168
packages/app/src/LNURL.ts Normal file
View File

@ -0,0 +1,168 @@
import { Event, HexKey } from "@snort/nostr";
import { EmailRegex } from "Const";
import { bech32ToText, unwrap } from "Util";
const PayServiceTag = "payRequest";
export enum LNURLErrorCode {
ServiceUnavailable = 1,
InvalidLNURL = 2,
}
export class LNURLError extends Error {
code: LNURLErrorCode;
constructor(code: LNURLErrorCode, msg: string) {
super(msg);
this.code = code;
}
}
export class LNURL {
#url: URL;
#service?: LNURLService;
/**
* Setup LNURL service
* @param lnurl bech32 lnurl / lightning address / https url
*/
constructor(lnurl: string) {
lnurl = lnurl.toLowerCase().trim();
if (lnurl.startsWith("lnurl")) {
const decoded = bech32ToText(lnurl);
if (!decoded.startsWith("http")) {
throw new LNURLError(LNURLErrorCode.InvalidLNURL, "Not a url");
}
this.#url = new URL(decoded);
} else if (lnurl.match(EmailRegex)) {
const [handle, domain] = lnurl.split("@");
this.#url = new URL(`https://${domain}/.well-known/lnurlp/${handle}`);
} else if (lnurl.startsWith("https:")) {
this.#url = new URL(lnurl);
} else if (lnurl.startsWith("lnurlp:")) {
const tmp = new URL(lnurl);
tmp.protocol = "https:";
this.#url = tmp;
} else {
throw new LNURLError(LNURLErrorCode.InvalidLNURL, "Could not determine service url");
}
}
async load() {
const rsp = await fetch(this.#url);
if (rsp.ok) {
this.#service = await rsp.json();
this.#validateService();
}
}
/**
* Fetch an invoice from the LNURL service
* @param amount Amount in sats
* @param comment
* @param zap
* @returns
*/
async getInvoice(amount: number, comment?: string, zap?: Event) {
const callback = new URL(unwrap(this.#service?.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 && this.#service?.commentAllowed) {
query.set("comment", comment);
}
if (this.#service?.nostrPubkey && zap) {
query.set("nostr", JSON.stringify(zap.ToObject()));
}
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: LNURLInvoice = await rsp.json();
console.debug("[LNURL]: ", data);
if (data.status === "ERROR") {
throw new Error(data.reason);
} else {
return data;
}
} else {
throw new LNURLError(LNURLErrorCode.ServiceUnavailable, `Failed to fetch invoice (${rsp.statusText})`);
}
} catch (e) {
throw new LNURLError(LNURLErrorCode.ServiceUnavailable, "Failed to load callback");
}
}
/**
* Are zaps (NIP-57) supported
*/
get canZap() {
return this.#service?.nostrPubkey ? true : false;
}
/**
* Get the max allowed comment length
*/
get maxCommentLength() {
return this.#service?.commentAllowed ?? 0;
}
/**
* Min sendable in milli-sats
*/
get min() {
return this.#service?.minSendable ?? 1_000; // 1 sat
}
/**
* Max sendable in milli-sats
*/
get max() {
return this.#service?.maxSendable ?? 100e9; // 1 BTC in milli-sats
}
#validateService() {
if (this.#service?.tag !== PayServiceTag) {
throw new LNURLError(LNURLErrorCode.InvalidLNURL, "Only LNURLp is supported");
}
if (!this.#service?.callback) {
throw new LNURLError(LNURLErrorCode.InvalidLNURL, "No callback url");
}
}
}
export interface LNURLService {
tag: string;
nostrPubkey?: HexKey;
minSendable?: number;
maxSendable?: number;
metadata: string;
callback: string;
commentAllowed?: number;
}
export interface LNURLStatus {
status: "SUCCESS" | "ERROR";
reason?: string;
}
export interface LNURLInvoice extends LNURLStatus {
pr?: string;
successAction?: LNURLSuccessAction;
}
export interface LNURLSuccessAction {
description?: string;
url?: string;
}

View File

@ -100,7 +100,7 @@ const DonatePage = () => {
<div className="mr10">
<FormattedMessage defaultMessage="Lightning Donation: " />
</div>
<ZapButton pubkey={bech32ToHex(SnortPubKey)} svc={"donate@snort.social"} />
<ZapButton pubkey={bech32ToHex(SnortPubKey)} lnurl={"donate@snort.social"} />
</div>
{today && (
<small>

View File

@ -160,7 +160,7 @@ export default function ProfilePage() {
)}
<SendSats
svc={lnurl}
lnurl={lnurl}
show={showLnQr}
onClose={() => setShowLnQr(false)}
author={id}

View File

@ -14,6 +14,10 @@
"/JE/X+": {
"defaultMessage": "Account Support"
},
"/PCavi": {
"defaultMessage": "Public",
"description": "Public Zap"
},
"/RD0e2": {
"defaultMessage": "Nostr uses digital signature technology to provide tamper proof notes which can safely be replicated to many relays to provide redundant storage of your content."
},
@ -26,6 +30,9 @@
"0BUTMv": {
"defaultMessage": "Search..."
},
"0jOEtS": {
"defaultMessage": "Invalid LNURL"
},
"0mch2Y": {
"defaultMessage": "name has disallowed characters"
},
@ -162,6 +169,10 @@
"Adk34V": {
"defaultMessage": "Setup your Profile"
},
"AnLrRC": {
"defaultMessage": "Non-Zap",
"description": "Non-Zap, Regular LN payment"
},
"AyGauy": {
"defaultMessage": "Login"
},
@ -755,6 +766,10 @@
"defaultMessage": "Your key",
"description": "Label for key input"
},
"wWLwvh": {
"defaultMessage": "Anon",
"description": "Anonymous Zap"
},
"wih7iJ": {
"defaultMessage": "name is blocked"
},

View File

@ -4,10 +4,12 @@
"+vIQlC": "Please make sure to save the following password in order to manage your handle in the future",
"/4tOwT": "Skip",
"/JE/X+": "Account Support",
"/PCavi": "Public",
"/RD0e2": "Nostr uses digital signature technology to provide tamper proof notes which can safely be replicated to many relays to provide redundant storage of your content.",
"/d6vEc": "Make your profile easier to find and share",
"/n5KSF": "{n} ms",
"0BUTMv": "Search...",
"0jOEtS": "Invalid LNURL",
"0mch2Y": "name has disallowed characters",
"0yO7wF": "{n} secs",
"1A7TZk": "What is Snort and how does it work?",
@ -52,6 +54,7 @@
"ADmfQT": "Parent",
"ASRK0S": "This author has been muted",
"Adk34V": "Setup your Profile",
"AnLrRC": "Non-Zap",
"AyGauy": "Login",
"B4C47Y": "name too short",
"B6+XJy": "zapped",
@ -246,6 +249,7 @@
"vZ4quW": "NIP-05 is a DNS based verification spec which helps to validate you as a real user.",
"wEQDC6": "Edit",
"wLtRCF": "Your key",
"wWLwvh": "Anon",
"wih7iJ": "name is blocked",
"wqyN/i": "Find out more info about {service} at {link}",
"wtLjP6": "Copy ID",

View File

@ -9859,6 +9859,11 @@ use-latest@^1.2.1:
dependencies:
use-isomorphic-layout-effect "^1.1.1"
use-long-press@^2.0.3:
version "2.0.3"
resolved "https://registry.yarnpkg.com/use-long-press/-/use-long-press-2.0.3.tgz#9e26da35f05819fe8056787c78a101e91e3e464b"
integrity sha512-n3cfv90Y1ldNt+hhXzxnxuLZmgLOOC/+qfLGoeEBgOxmnokPPt39MPF3KmvKriq5VMoJ7uQdVjHejCdHBt9anw==
use-sync-external-store@^1.0.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz#7dbefd6ef3fe4e767a0cf5d7287aacfb5846928a"