mirror of
https://github.com/lumina-rocks/lumina.git
synced 2026-06-06 18:51:16 +02:00
Feature: NIP-57 (Lightning Zaps) (#70)
* update ZapButton component to handle zap requests and receipts; add react-qr-code dependency for QR code generation. * Enhance ZapButton component to track payment completion; add visual feedback for successful payments and improve event handling for zap receipts. * Enhance ZapButton component to provide visual feedback for payment completion; update QR code display and messaging based on payment status. * feat: add QR code link for direct payment and improve invoice display logic * fix: encode signed zap request before appending to params * fix: rm encodeURIComponent --------- Co-authored-by: highperfocused <highperfocused@pm.me>
This commit is contained in:
@@ -1,90 +1,332 @@
|
||||
import { useNostr, dateToUnix, useNostrEvents } from "nostr-react";
|
||||
|
||||
import {
|
||||
type Event as NostrEvent,
|
||||
getEventHash,
|
||||
getPublicKey,
|
||||
finalizeEvent,
|
||||
nip19,
|
||||
} from "nostr-tools";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "@/components/ui/drawer"
|
||||
import { ReloadIcon } from "@radix-ui/react-icons";
|
||||
import { ReloadIcon, CheckCircledIcon } from "@radix-ui/react-icons";
|
||||
import ZapButtonList from "./ZapButtonList";
|
||||
import { Input } from "./ui/input";
|
||||
import { useNostr, useNostrEvents, useProfile } from "nostr-react";
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import QRCode from "react-qr-code";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function ZapButton({ event }: { event: any }) {
|
||||
|
||||
const { connectedRelays } = useNostr();
|
||||
|
||||
const { events, isLoading } = useNostrEvents({
|
||||
filter: {
|
||||
// since: dateToUnix(now.current), // all new events from now
|
||||
// since: 0,
|
||||
// limit: 100,
|
||||
'#e': [event.id],
|
||||
kinds: [9735],
|
||||
},
|
||||
});
|
||||
|
||||
// filter out all events that also have another e tag with another id
|
||||
// this will filter out likes that are made on comments and not on the note itself
|
||||
// const filteredEvents = events.filter((event) => { return event.tags.filter((tag) => { return tag[0] === '#e' && tag[1] !== event.id }).length === 0 });
|
||||
const [lnurlPayInfo, setLnurlPayInfo] = useState<any>(null);
|
||||
const [invoice, setInvoice] = useState<string>("");
|
||||
const [customAmount, setCustomAmount] = useState<string>("1000");
|
||||
const [isProcessing, setIsProcessing] = useState<boolean>(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string>("");
|
||||
const [paymentComplete, setPaymentComplete] = useState<boolean>(false);
|
||||
const { publish } = useNostr();
|
||||
|
||||
// Store the initial count of zap receipts when an invoice is generated
|
||||
const invoiceEventsCountRef = useRef<number>(0);
|
||||
|
||||
// Effect to check for new zap receipts when showing an invoice
|
||||
useEffect(() => {
|
||||
if (invoice) {
|
||||
// Store the current count of zap receipts when invoice is generated
|
||||
invoiceEventsCountRef.current = events.length;
|
||||
setPaymentComplete(false);
|
||||
}
|
||||
}, [invoice]);
|
||||
|
||||
// Effect to detect new zap receipts after invoice is shown
|
||||
useEffect(() => {
|
||||
if (invoice && events.length > invoiceEventsCountRef.current) {
|
||||
// Filter events to find new zap receipts related to current invoice
|
||||
const newEvents = events.slice(invoiceEventsCountRef.current);
|
||||
|
||||
// Check if any new events contain the current invoice
|
||||
const relevantEvents = newEvents.filter(zapEvent => {
|
||||
// Look for bolt11 tag containing the invoice
|
||||
return zapEvent.tags.some(tag =>
|
||||
tag[0] === 'bolt11' && invoice.includes(tag[1].substring(0, 50))
|
||||
);
|
||||
});
|
||||
|
||||
if (relevantEvents.length > 0) {
|
||||
setPaymentComplete(true);
|
||||
}
|
||||
}
|
||||
}, [events, invoice]);
|
||||
|
||||
const { data: userData } = useProfile({
|
||||
pubkey: event.pubkey,
|
||||
});
|
||||
|
||||
let sats = 0;
|
||||
var lightningPayReq = require('bolt11');
|
||||
events.forEach((event) => {
|
||||
// console.log(event);
|
||||
event.tags.forEach((tag) => {
|
||||
if (tag[0] === 'bolt11') {
|
||||
let decoded = lightningPayReq.decode(tag[1]);
|
||||
// console.log(decoded.satoshis);
|
||||
sats = sats + decoded.satoshis;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const fetchLnurlInfo = async () => {
|
||||
setErrorMessage("");
|
||||
if (!userData?.lud06 && !userData?.lud16) {
|
||||
setErrorMessage("This user doesn't have a Lightning address configured");
|
||||
return;
|
||||
}
|
||||
|
||||
// const { publish } = useNostr();
|
||||
try {
|
||||
setIsProcessing(true);
|
||||
|
||||
let lnurl;
|
||||
|
||||
if (userData.lud06) {
|
||||
lnurl = userData.lud06;
|
||||
} else if (userData.lud16) {
|
||||
const [name, domain] = userData.lud16.split('@');
|
||||
if (!name || !domain) {
|
||||
throw new Error("Invalid lightning address format");
|
||||
}
|
||||
const url = `https://${domain}/.well-known/lnurlp/${name}`;
|
||||
lnurl = url;
|
||||
} else {
|
||||
throw new Error("No lightning address found in profile");
|
||||
}
|
||||
|
||||
// const onPost = async () => {
|
||||
// const privKey = prompt("Paste your private key:");
|
||||
if (lnurl.toLowerCase().startsWith('lnurl')) {
|
||||
try {
|
||||
lnurl = bech32ToUrl(lnurl);
|
||||
} catch (e) {
|
||||
console.error("Error decoding LNURL:", e);
|
||||
throw new Error("Invalid LNURL format");
|
||||
}
|
||||
}
|
||||
|
||||
// if (!privKey) {
|
||||
// alert("no private key provided");
|
||||
// return;
|
||||
// }
|
||||
const response = await fetch(lnurl);
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Error fetching LNURL info: ${data.reason || 'Unknown error'}`);
|
||||
}
|
||||
|
||||
// const message = prompt("Enter the message you want to send:");
|
||||
if (!data.allowsNostr) {
|
||||
setErrorMessage("This Lightning address doesn't support Nostr zaps");
|
||||
setIsProcessing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// if (!message) {
|
||||
// alert("no message provided");
|
||||
// return;
|
||||
// }
|
||||
setLnurlPayInfo(data);
|
||||
} catch (error) {
|
||||
console.error("Error fetching LNURL info:", error);
|
||||
setErrorMessage(error instanceof Error ? error.message : "Failed to fetch Lightning payment information");
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// const event: NostrEvent = {
|
||||
// content: message,
|
||||
// kind: 1,
|
||||
// tags: [],
|
||||
// created_at: dateToUnix(),
|
||||
// pubkey: getPublicKey(privKey),
|
||||
// id: "",
|
||||
// sig: ""
|
||||
// };
|
||||
const createZapRequest = async (amount: number) => {
|
||||
if (!lnurlPayInfo || !lnurlPayInfo.callback || !lnurlPayInfo.allowsNostr || !lnurlPayInfo.nostrPubkey) {
|
||||
setErrorMessage("Invalid Lightning payment information");
|
||||
return;
|
||||
}
|
||||
|
||||
// event.id = getEventHash(event);
|
||||
// event.sig = getSignature(event, privKey);
|
||||
if (amount < lnurlPayInfo.minSendable || amount > lnurlPayInfo.maxSendable) {
|
||||
setErrorMessage(`Amount must be between ${lnurlPayInfo.minSendable / 1000} and ${lnurlPayInfo.maxSendable / 1000} sats`);
|
||||
return;
|
||||
}
|
||||
|
||||
// publish(event);
|
||||
// };
|
||||
try {
|
||||
setIsProcessing(true);
|
||||
setErrorMessage("");
|
||||
|
||||
let senderPubkey = '';
|
||||
if (typeof window !== 'undefined') {
|
||||
senderPubkey = window.localStorage.getItem('pubkey') ?? '';
|
||||
}
|
||||
|
||||
if (!senderPubkey) {
|
||||
setErrorMessage("You need to be logged in to send zaps");
|
||||
setIsProcessing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let zapRequestEvent = {
|
||||
kind: 9734,
|
||||
content: "",
|
||||
tags: [
|
||||
["relays", ...connectedRelays.map((relay) => relay.url)],
|
||||
["amount", amount.toString()],
|
||||
["p", event.pubkey],
|
||||
["e", event.id],
|
||||
],
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
pubkey: senderPubkey,
|
||||
};
|
||||
|
||||
let params = new URLSearchParams();
|
||||
params.append('amount', amount.toString());
|
||||
|
||||
if (userData?.lud06) {
|
||||
zapRequestEvent.tags.push(["lnurl", userData.lud06]);
|
||||
}
|
||||
|
||||
const signEvent = async () => {
|
||||
if (window.nostr) {
|
||||
return await window.nostr.signEvent(zapRequestEvent);
|
||||
} else {
|
||||
const nsecHex = window.localStorage.getItem("nsec");
|
||||
if (nsecHex) {
|
||||
try {
|
||||
const decoded = nip19.decode(nsecHex);
|
||||
if (decoded.type === 'nsec') {
|
||||
const privateKey = decoded.data as Uint8Array;
|
||||
return finalizeEvent(zapRequestEvent, privateKey);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error decoding private key:", error);
|
||||
throw new Error("Invalid private key format");
|
||||
}
|
||||
}
|
||||
throw new Error("No private key available to sign the zap request");
|
||||
}
|
||||
};
|
||||
|
||||
const signedZapRequest = await signEvent();
|
||||
params.append('nostr', JSON.stringify(signedZapRequest));
|
||||
|
||||
if (userData?.lud06) {
|
||||
params.append('lnurl', userData.lud06);
|
||||
}
|
||||
|
||||
let callbackUrl = `${lnurlPayInfo.callback}?${params.toString()}`;
|
||||
|
||||
const invoiceResponse = await fetch(callbackUrl);
|
||||
const invoiceData = await invoiceResponse.json();
|
||||
|
||||
if (!invoiceResponse.ok || !invoiceData.pr) {
|
||||
throw new Error(`Failed to get invoice: ${invoiceData.reason || 'Unknown error'}`);
|
||||
}
|
||||
|
||||
setInvoice(invoiceData.pr);
|
||||
} catch (error) {
|
||||
console.error("Error creating zap request:", error);
|
||||
setErrorMessage(error instanceof Error ? error.message : "Failed to create zap request");
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleZap = async (amountSats: number) => {
|
||||
if (!lnurlPayInfo) {
|
||||
await fetchLnurlInfo();
|
||||
}
|
||||
createZapRequest(amountSats * 1000);
|
||||
};
|
||||
|
||||
const handleCustomZap = async () => {
|
||||
const amount = parseInt(customAmount, 10);
|
||||
if (isNaN(amount) || amount <= 0) {
|
||||
setErrorMessage("Please enter a valid amount");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!lnurlPayInfo) {
|
||||
await fetchLnurlInfo();
|
||||
}
|
||||
createZapRequest(amount * 1000);
|
||||
};
|
||||
|
||||
const bech32ToUrl = (lnurl: string): string => {
|
||||
try {
|
||||
const decoded = nip19.decode(lnurl);
|
||||
return decoded.data as string;
|
||||
} catch (e) {
|
||||
return lnurl;
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenDrawer = () => {
|
||||
fetchLnurlInfo();
|
||||
};
|
||||
|
||||
const handleCloseDrawer = () => {
|
||||
setInvoice("");
|
||||
setErrorMessage("");
|
||||
setIsProcessing(false);
|
||||
};
|
||||
|
||||
const checkPaymentStatus = async () => {
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
// Force a re-fetch of zap receipt events
|
||||
const eventFilter = {
|
||||
'#e': [event.id],
|
||||
kinds: [9735],
|
||||
};
|
||||
|
||||
// Manually check relays for new zap events
|
||||
const zapPromises = connectedRelays.map(async (relay) => {
|
||||
return new Promise(async (resolve) => {
|
||||
const timeout = setTimeout(() => resolve([]), 3000); // 3 second timeout
|
||||
try {
|
||||
const sub = relay.sub([eventFilter]);
|
||||
const events: any[] = [];
|
||||
|
||||
sub.on('event', (event) => {
|
||||
// Check if this event contains the current invoice
|
||||
const hasBolt11 = event.tags.some(tag =>
|
||||
tag[0] === 'bolt11' && invoice.includes(tag[1].substring(0, 50))
|
||||
);
|
||||
if (hasBolt11) {
|
||||
events.push(event);
|
||||
}
|
||||
});
|
||||
|
||||
sub.on('eose', () => {
|
||||
clearTimeout(timeout);
|
||||
resolve(events);
|
||||
sub.unsub();
|
||||
});
|
||||
} catch (error) {
|
||||
clearTimeout(timeout);
|
||||
resolve([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const zapEventsArrays = await Promise.all(zapPromises);
|
||||
const newZapEvents = zapEventsArrays.flat();
|
||||
|
||||
if (newZapEvents.length > 0) {
|
||||
setPaymentComplete(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error checking payment status:", error);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer>
|
||||
<Drawer onOpenChange={(open) => open ? handleOpenDrawer() : handleCloseDrawer()}>
|
||||
<DrawerTrigger asChild>
|
||||
{isLoading ? (
|
||||
<Button variant="outline"><ReloadIcon className="mr-2 h-4 w-4 animate-spin" /> ⚡️</Button>
|
||||
@@ -93,30 +335,108 @@ export default function ZapButton({ event }: { event: any }) {
|
||||
)}
|
||||
</DrawerTrigger>
|
||||
<DrawerContent>
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>Zaps</DrawerTitle>
|
||||
{/* <DrawerDescription>Sorry, but this feature is not implemented yet.</DrawerDescription> */}
|
||||
</DrawerHeader>
|
||||
<div className="px-4 grid grid-cols-3">
|
||||
<Button variant={"outline"} className="mx-1" disabled>1 sat</Button>
|
||||
<Button variant={"outline"} className="mx-1" disabled>21 sats</Button>
|
||||
<div className="flex">
|
||||
<Input className="mx-1" placeholder="1000 sats" />
|
||||
<Button variant={"outline"} className="mx-1" disabled>send</Button>
|
||||
{errorMessage && (
|
||||
<div className="px-4 py-2 mb-4 text-red-500 bg-red-50 rounded">
|
||||
{errorMessage}
|
||||
</div>
|
||||
</div>
|
||||
<hr className="my-4" />
|
||||
<ZapButtonList events={events} />
|
||||
<DrawerFooter>
|
||||
<DrawerClose asChild>
|
||||
<div>
|
||||
<Button variant={"outline"}>Close</Button>
|
||||
)}
|
||||
|
||||
{invoice ? (
|
||||
<div className="flex flex-col items-center px-4 py-4">
|
||||
{paymentComplete ? (
|
||||
<div className="flex flex-col items-center justify-center mb-4 p-2 rounded-lg bg-green-50 dark:bg-green-900/20 w-[200px] h-[200px]">
|
||||
<CheckCircledIcon className="h-24 w-24 text-green-500" />
|
||||
<p className="text-lg font-semibold text-green-500 mt-4">
|
||||
Payment Complete!
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mb-4 p-2 bg-white rounded-lg">
|
||||
<Link href={`lightning:${invoice}`} target="_blank" rel="noopener noreferrer">
|
||||
<QRCode value={invoice} size={200} />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-sm text-center mb-4">
|
||||
{paymentComplete
|
||||
? "Your payment has been received and confirmed!"
|
||||
: "Scan this QR code with a Lightning wallet to pay the invoice"}
|
||||
</p>
|
||||
|
||||
<div className="w-full overflow-auto p-2 bg-gray-100 dark:bg-gray-800 rounded text-xs mb-4">
|
||||
<code className="break-all">{invoice}</code>
|
||||
</div>
|
||||
</DrawerClose>
|
||||
</DrawerFooter>
|
||||
|
||||
{paymentComplete ? (
|
||||
<div className="flex items-center text-green-500 mb-4">
|
||||
<CheckCircledIcon className="mr-2 h-4 w-4" />
|
||||
Zap sent successfully!
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mb-4"
|
||||
onClick={() => checkPaymentStatus()}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
{isProcessing ? (
|
||||
<>
|
||||
<ReloadIcon className="mr-2 h-4 w-4 animate-spin" /> Checking...
|
||||
</>
|
||||
) : (
|
||||
"Check if paid"
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button variant="outline" onClick={() => setInvoice("")}>
|
||||
{paymentComplete ? "Send Another Zap" : "Back to Zap Options"}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="px-4 pt-4 grid grid-cols-3 gap-2">
|
||||
<Button
|
||||
variant={"outline"}
|
||||
className="mx-1"
|
||||
onClick={() => handleZap(1)}
|
||||
disabled={isProcessing || !lnurlPayInfo}
|
||||
>
|
||||
1 sat
|
||||
</Button>
|
||||
<Button
|
||||
variant={"outline"}
|
||||
className="mx-1"
|
||||
onClick={() => handleZap(21)}
|
||||
disabled={isProcessing || !lnurlPayInfo}
|
||||
>
|
||||
21 sats
|
||||
</Button>
|
||||
<div className="flex">
|
||||
<Input
|
||||
className="mx-1"
|
||||
placeholder="1000 sats"
|
||||
value={customAmount}
|
||||
onChange={(e) => setCustomAmount(e.target.value)}
|
||||
disabled={isProcessing}
|
||||
/>
|
||||
<Button
|
||||
variant={"outline"}
|
||||
className="mx-1"
|
||||
onClick={handleCustomZap}
|
||||
disabled={isProcessing || !lnurlPayInfo}
|
||||
>
|
||||
{isProcessing ? <ReloadIcon className="h-4 w-4 animate-spin" /> : "send"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr className="my-4" />
|
||||
<ZapButtonList events={events} />
|
||||
</>
|
||||
)}
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
// <Button variant="default" onClick={onPost}>{events.length} Reactions</Button>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user