Files
grimoire/src/components/ZapWindow.tsx
Claude db200a2d93 feat: refine ZapWindow UI and add dynamic window title
UI Refinements per user request:
- Remove QrCode unused import
- Simplify payment flow to single adaptive button
- Button shows "Log in to Zap" if user can't sign
- Button shows "Pay with Wallet" if NWC available, else "Pay"
- Fix activeAccount usage to use accountManager.active
- Remove unused getProfileContent import
- Remove unused eventAuthorName variable

Dynamic Title:
- Add "Zap [username]" dynamic title in DynamicWindowTitle
- Fetches recipient profile and displays name or fallback
- Shows recipient's display name, name, or truncated pubkey

Build fixes:
- Fix TypeScript errors with unused imports
- Fix activeAccount.signer property access
- All tests passing (939 passed)
2026-01-18 20:42:05 +00:00

508 lines
16 KiB
TypeScript

/**
* ZapWindow Component
*
* UI for sending Lightning zaps to Nostr users and events (NIP-57)
*
* Features:
* - Send zaps to profiles or events
* - Preset and custom amounts
* - Remembers most-used amounts
* - NWC wallet payment or QR code fallback
* - Shows feed render of zapped event
*/
import { useState, useMemo, useEffect } from "react";
import { toast } from "sonner";
import {
Zap,
Wallet,
Copy,
ExternalLink,
Loader2,
CheckCircle2,
LogIn,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import QRCode from "qrcode";
import { useProfile } from "@/hooks/useProfile";
import { use$ } from "applesauce-react/hooks";
import eventStore from "@/services/event-store";
import { useWallet } from "@/hooks/useWallet";
import { getDisplayName } from "@/lib/nostr-utils";
import { KindRenderer } from "./nostr/kinds";
import type { EventPointer, AddressPointer } from "@/lib/open-parser";
import { useGrimoire } from "@/core/state";
import accountManager from "@/services/accounts";
export interface ZapWindowProps {
/** Recipient pubkey (who receives the zap) */
recipientPubkey: string;
/** Optional event being zapped (adds context) */
eventPointer?: EventPointer | AddressPointer;
}
// Default preset amounts in sats
const DEFAULT_PRESETS = [21, 100, 500, 1000, 5000, 10000];
// LocalStorage keys
const STORAGE_KEY_CUSTOM_AMOUNTS = "grimoire_zap_custom_amounts";
const STORAGE_KEY_AMOUNT_USAGE = "grimoire_zap_amount_usage";
export function ZapWindow({
recipientPubkey: initialRecipientPubkey,
eventPointer,
}: ZapWindowProps) {
// Load event if we have a pointer and no recipient pubkey (derive from event author)
const event = use$(() => {
if (!eventPointer) return undefined;
if ("id" in eventPointer) {
return eventStore.event(eventPointer.id);
}
// AddressPointer
return eventStore.replaceable(
eventPointer.kind,
eventPointer.pubkey,
eventPointer.identifier,
);
}, [eventPointer]);
// Resolve recipient: use provided pubkey or derive from event author
const recipientPubkey = initialRecipientPubkey || event?.pubkey || "";
const recipientProfile = useProfile(recipientPubkey);
const { addWindow } = useGrimoire();
const activeAccount = accountManager.active;
const canSign = !!activeAccount?.signer;
const { wallet, payInvoice, refreshBalance, getInfo } = useWallet();
// Fetch wallet info
const [walletInfo, setWalletInfo] = useState<any>(null);
useEffect(() => {
if (wallet) {
getInfo()
.then((info) => setWalletInfo(info))
.catch((error) => console.error("Failed to get wallet info:", error));
}
}, [wallet, getInfo]);
const [selectedAmount, setSelectedAmount] = useState<number | null>(null);
const [customAmount, setCustomAmount] = useState("");
const [comment, setComment] = useState("");
const [isProcessing, setIsProcessing] = useState(false);
const [isPaid, setIsPaid] = useState(false);
const [qrCodeUrl, setQrCodeUrl] = useState<string>("");
const [invoice, setInvoice] = useState<string>("");
const [showQrDialog, setShowQrDialog] = useState(false);
// Load custom amounts and usage stats from localStorage
const [customAmounts, setCustomAmounts] = useState<number[]>(() => {
const stored = localStorage.getItem(STORAGE_KEY_CUSTOM_AMOUNTS);
return stored ? JSON.parse(stored) : [];
});
const [amountUsage, setAmountUsage] = useState<Record<string, number>>(() => {
const stored = localStorage.getItem(STORAGE_KEY_AMOUNT_USAGE);
return stored ? JSON.parse(stored) : {};
});
// Combine preset and custom amounts, sort by usage
const availableAmounts = useMemo(() => {
const all = [...DEFAULT_PRESETS, ...customAmounts];
const unique = Array.from(new Set(all));
// Sort by usage count (descending), then by amount
return unique.sort((a, b) => {
const usageA = amountUsage[a] || 0;
const usageB = amountUsage[b] || 0;
if (usageA !== usageB) return usageB - usageA;
return a - b;
});
}, [customAmounts, amountUsage]);
// Get recipient name for display
const recipientName = useMemo(() => {
return recipientProfile
? getDisplayName(recipientPubkey, recipientProfile)
: recipientPubkey.slice(0, 8);
}, [recipientPubkey, recipientProfile]);
// Track amount usage
const trackAmountUsage = (amount: number) => {
const newUsage = {
...amountUsage,
[amount]: (amountUsage[amount] || 0) + 1,
};
setAmountUsage(newUsage);
localStorage.setItem(STORAGE_KEY_AMOUNT_USAGE, JSON.stringify(newUsage));
// If it's a custom amount not in our list, add it
if (!DEFAULT_PRESETS.includes(amount) && !customAmounts.includes(amount)) {
const newCustomAmounts = [...customAmounts, amount];
setCustomAmounts(newCustomAmounts);
localStorage.setItem(
STORAGE_KEY_CUSTOM_AMOUNTS,
JSON.stringify(newCustomAmounts),
);
}
};
// Generate QR code for invoice
const generateQrCode = async (invoiceText: string) => {
try {
const qrDataUrl = await QRCode.toDataURL(invoiceText, {
width: 300,
margin: 2,
color: {
dark: "#000000",
light: "#FFFFFF",
},
});
return qrDataUrl;
} catch (error) {
console.error("QR code generation error:", error);
throw new Error("Failed to generate QR code");
}
};
// Handle zap payment flow
const handleZap = async (useWallet: boolean) => {
const amount = selectedAmount || parseInt(customAmount);
if (!amount || amount <= 0) {
toast.error("Please enter a valid amount");
return;
}
if (!recipientPubkey) {
toast.error("No recipient specified");
return;
}
setIsProcessing(true);
try {
// Track usage
trackAmountUsage(amount);
// Step 1: Get Lightning address from recipient profile
const lud16 = recipientProfile?.lud16;
const lud06 = recipientProfile?.lud06;
if (!lud16 && !lud06) {
throw new Error(
"Recipient has no Lightning address configured in their profile",
);
}
// Step 2: Resolve LNURL to get callback URL and nostrPubkey
toast.info("Resolving Lightning address...");
let lnurlData;
if (lud16) {
const { resolveLightningAddress, validateZapSupport } =
await import("@/lib/lnurl");
lnurlData = await resolveLightningAddress(lud16);
validateZapSupport(lnurlData);
} else if (lud06) {
throw new Error(
"LNURL (lud06) not supported. Recipient should use a Lightning address (lud16) instead.",
);
}
if (!lnurlData) {
throw new Error("Failed to resolve Lightning address");
}
// Validate amount is within acceptable range
const amountMillisats = amount * 1000;
if (amountMillisats < lnurlData.minSendable) {
throw new Error(
`Amount too small. Minimum: ${Math.ceil(lnurlData.minSendable / 1000)} sats`,
);
}
if (amountMillisats > lnurlData.maxSendable) {
throw new Error(
`Amount too large. Maximum: ${Math.floor(lnurlData.maxSendable / 1000)} sats`,
);
}
// Validate comment length if provided
if (comment && lnurlData.commentAllowed) {
if (comment.length > lnurlData.commentAllowed) {
throw new Error(
`Comment too long. Maximum ${lnurlData.commentAllowed} characters.`,
);
}
}
// Step 3: Create and sign zap request event (kind 9734)
toast.info("Creating zap request...");
const { createZapRequest, serializeZapRequest } =
await import("@/lib/create-zap-request");
const zapRequest = await createZapRequest({
recipientPubkey,
amountMillisats,
comment,
eventPointer,
lnurl: lud16 || undefined,
});
const serializedZapRequest = serializeZapRequest(zapRequest);
// Step 4: Fetch invoice from LNURL callback
toast.info("Fetching invoice...");
const { fetchInvoiceFromCallback } = await import("@/lib/lnurl");
const invoiceResponse = await fetchInvoiceFromCallback(
lnurlData.callback,
amountMillisats,
serializedZapRequest,
comment || undefined,
);
const invoiceText = invoiceResponse.pr;
// Step 5: Pay or show QR code
if (useWallet && wallet && walletInfo?.methods.includes("pay_invoice")) {
// Pay with NWC wallet
toast.info("Paying invoice with wallet...");
await payInvoice(invoiceText);
await refreshBalance();
setIsPaid(true);
toast.success(
`⚡ Zapped ${amount} sats to ${recipientProfile?.name || recipientName}!`,
);
// Show success message from LNURL service if available
if (invoiceResponse.successAction?.message) {
toast.info(invoiceResponse.successAction.message);
}
} else {
// Show QR code and invoice
const qrUrl = await generateQrCode(invoiceText);
setQrCodeUrl(qrUrl);
setInvoice(invoiceText);
setShowQrDialog(true);
toast.success("Invoice ready! Scan or copy to pay.");
}
} catch (error) {
console.error("Zap error:", error);
toast.error(
error instanceof Error ? error.message : "Failed to send zap",
);
} finally {
setIsProcessing(false);
}
};
// Copy to clipboard
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
toast.success("Copied to clipboard");
} catch {
toast.error("Failed to copy");
}
};
// Open in wallet
const openInWallet = (invoice: string) => {
window.open(`lightning:${invoice}`, "_blank");
};
// Open account selector for login
const handleLogin = () => {
addWindow("conn", {});
};
return (
<div className="h-full flex flex-col bg-background overflow-hidden">
<div className="flex-1 overflow-y-auto">
<div className="max-w-2xl mx-auto p-6 space-y-6">
{/* Show event preview if zapping an event */}
{event && (
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium text-muted-foreground">
Zapping Event
</CardTitle>
</CardHeader>
<CardContent>
<KindRenderer event={event} />
</CardContent>
</Card>
)}
{/* Amount Selection */}
<div className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground">
Amount (sats)
</h3>
{/* Preset amounts */}
<div className="grid grid-cols-3 gap-2">
{availableAmounts.map((amount) => (
<Button
key={amount}
variant={selectedAmount === amount ? "default" : "outline"}
onClick={() => {
setSelectedAmount(amount);
setCustomAmount("");
}}
className="relative"
>
{amount.toLocaleString()}
{amountUsage[amount] && (
<span className="absolute top-1 right-1 size-1.5 rounded-full bg-yellow-500" />
)}
</Button>
))}
</div>
{/* Custom amount */}
<div className="space-y-2">
<Label>Custom Amount</Label>
<Input
id="custom-amount"
type="number"
placeholder="Enter amount in sats"
value={customAmount}
onChange={(e) => {
setCustomAmount(e.target.value);
setSelectedAmount(null);
}}
min="1"
/>
</div>
{/* Comment */}
<div className="space-y-2">
<Label>Comment (optional)</Label>
<Input
id="comment"
placeholder="Say something nice..."
value={comment}
onChange={(e) => setComment(e.target.value)}
maxLength={200}
/>
</div>
</div>
{/* Payment Button */}
{!canSign ? (
<Button
onClick={handleLogin}
className="w-full"
size="lg"
variant="default"
>
<LogIn className="size-4 mr-2" />
Log in to Zap
</Button>
) : (
<Button
onClick={() =>
handleZap(wallet && walletInfo?.methods.includes("pay_invoice"))
}
disabled={isProcessing || (!selectedAmount && !customAmount)}
className="w-full"
size="lg"
>
{isProcessing ? (
<>
<Loader2 className="size-4 mr-2 animate-spin" />
Processing...
</>
) : isPaid ? (
<>
<CheckCircle2 className="size-4 mr-2" />
Zap Sent!
</>
) : wallet && walletInfo?.methods.includes("pay_invoice") ? (
<>
<Wallet className="size-4 mr-2" />
Pay with Wallet (
{selectedAmount || parseInt(customAmount) || 0} sats)
</>
) : (
<>
<Zap className="size-4 mr-2" />
Pay ({selectedAmount || parseInt(customAmount) || 0} sats)
</>
)}
</Button>
)}
</div>
</div>
{/* QR Code Dialog */}
<Dialog open={showQrDialog} onOpenChange={setShowQrDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Lightning Invoice</DialogTitle>
<DialogDescription>
Scan with your Lightning wallet or copy the invoice
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{qrCodeUrl && (
<div className="flex justify-center p-4 bg-white rounded-lg">
<img
src={qrCodeUrl}
alt="Lightning Invoice QR Code"
className="w-64 h-64"
/>
</div>
)}
<div className="space-y-2">
<Label>Invoice</Label>
<div className="flex gap-2">
<Input value={invoice} readOnly className="font-mono text-xs" />
<Button
variant="outline"
size="icon"
onClick={() => copyToClipboard(invoice)}
>
<Copy className="size-4" />
</Button>
</div>
</div>
<div className="flex gap-2">
<Button
variant="outline"
className="flex-1"
onClick={() => openInWallet(invoice)}
>
<ExternalLink className="size-4 mr-2" />
Open in Wallet
</Button>
<Button
variant="outline"
className="flex-1"
onClick={() => copyToClipboard(invoice)}
>
<Copy className="size-4 mr-2" />
Copy Invoice
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
}