mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-12 08:27:27 +02:00
feat: optimize zap UX with better error handling and UI improvements
LNURL improvements:
- Add 10s timeouts to Lightning address resolution and invoice fetching
- Better error messages with more context (response status, error text)
- Handle AbortError for timeout scenarios
UI improvements:
- Bigger amount buttons (default size instead of sm)
- Custom amount on separate line for better layout
- Disable all zap UI when recipient has no Lightning address
- Show clear warning when Lightning address is missing
- Only show comment editor when Lightning address is available
Toast cleanup:
- Remove chatty info toasts ("Resolving...", "Creating...", "Fetching...")
- Only show errors and success messages
- Cleaner, less noisy UX
This addresses common issues with LNURL requests timing out and makes
the UI more responsive and informative when zaps cannot be sent.
This commit is contained in:
@@ -162,6 +162,11 @@ export function ZapWindow({
|
||||
: recipientPubkey.slice(0, 8);
|
||||
}, [recipientPubkey, recipientProfile]);
|
||||
|
||||
// Check if recipient has a lightning address
|
||||
const hasLightningAddress = !!(
|
||||
recipientProfile?.lud16 || recipientProfile?.lud06
|
||||
);
|
||||
|
||||
// Track amount usage
|
||||
const trackAmountUsage = (amount: number) => {
|
||||
const newUsage = {
|
||||
@@ -229,8 +234,6 @@ export function ZapWindow({
|
||||
}
|
||||
|
||||
// Step 2: Resolve LNURL to get callback URL and nostrPubkey
|
||||
toast.info("Resolving Lightning address...");
|
||||
|
||||
let lnurlData;
|
||||
if (lud16) {
|
||||
lnurlData = await resolveLightningAddress(lud16);
|
||||
@@ -277,8 +280,6 @@ export function ZapWindow({
|
||||
}
|
||||
|
||||
// Step 3: Create and sign zap request event (kind 9734)
|
||||
toast.info("Creating zap request...");
|
||||
|
||||
const zapRequest = await createZapRequest({
|
||||
recipientPubkey,
|
||||
amountMillisats,
|
||||
@@ -291,8 +292,6 @@ export function ZapWindow({
|
||||
const serializedZapRequest = serializeZapRequest(zapRequest);
|
||||
|
||||
// Step 4: Fetch invoice from LNURL callback
|
||||
toast.info("Fetching invoice...");
|
||||
|
||||
const invoiceResponse = await fetchInvoiceFromCallback(
|
||||
lnurlData.callback,
|
||||
amountMillisats,
|
||||
@@ -305,8 +304,6 @@ export function ZapWindow({
|
||||
// Step 5: Pay or show QR code
|
||||
if (useWallet && wallet && walletInfo?.methods.includes("pay_invoice")) {
|
||||
// Pay with NWC wallet with timeout
|
||||
toast.info("Paying invoice with wallet...");
|
||||
|
||||
try {
|
||||
// Race between payment and 30 second timeout
|
||||
const paymentPromise = payInvoice(invoiceText);
|
||||
@@ -319,15 +316,9 @@ export function ZapWindow({
|
||||
|
||||
setIsPaid(true);
|
||||
toast.success(`⚡ Zapped ${amount} sats to ${recipientName}!`);
|
||||
|
||||
// Show success message from LNURL service if available
|
||||
if (invoiceResponse.successAction?.message) {
|
||||
toast.info(invoiceResponse.successAction.message);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === "TIMEOUT") {
|
||||
// Payment timed out - show QR code with retry option
|
||||
toast.warning("Wallet payment timed out. Showing QR code instead.");
|
||||
setPaymentTimedOut(true);
|
||||
const qrUrl = await generateQrCode(invoiceText);
|
||||
setQrCodeUrl(qrUrl);
|
||||
@@ -344,7 +335,6 @@ export function ZapWindow({
|
||||
setQrCodeUrl(qrUrl);
|
||||
setInvoice(invoiceText);
|
||||
setShowQrDialog(true);
|
||||
toast.success("Invoice ready! Scan or copy to pay.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Zap error:", error);
|
||||
@@ -385,8 +375,6 @@ export function ZapWindow({
|
||||
setPaymentTimedOut(false);
|
||||
|
||||
try {
|
||||
toast.info("Retrying payment with wallet...");
|
||||
|
||||
// Try again with timeout
|
||||
const paymentPromise = payInvoice(invoice);
|
||||
const timeoutPromise = new Promise((_, reject) =>
|
||||
@@ -401,7 +389,7 @@ export function ZapWindow({
|
||||
toast.success("⚡ Payment successful!");
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === "TIMEOUT") {
|
||||
toast.error("Payment timed out again. Please try manually.");
|
||||
toast.error("Payment timed out. Please try manually.");
|
||||
setPaymentTimedOut(true);
|
||||
setShowQrDialog(true);
|
||||
} else {
|
||||
@@ -526,11 +514,11 @@ export function ZapWindow({
|
||||
{/* Amount Selection */}
|
||||
<div className="space-y-2">
|
||||
{/* Preset amounts - single row */}
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{availableAmounts.map((amount) => (
|
||||
<Button
|
||||
key={amount}
|
||||
size="sm"
|
||||
size="default"
|
||||
variant={
|
||||
selectedAmount === amount ? "default" : "outline"
|
||||
}
|
||||
@@ -539,6 +527,7 @@ export function ZapWindow({
|
||||
setCustomAmount("");
|
||||
}}
|
||||
className="relative"
|
||||
disabled={!hasLightningAddress}
|
||||
>
|
||||
{formatAmount(amount)}
|
||||
{amountUsage[amount] && (
|
||||
@@ -546,30 +535,41 @@ export function ZapWindow({
|
||||
)}
|
||||
</Button>
|
||||
))}
|
||||
{/* Custom amount inline */}
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="Custom"
|
||||
value={customAmount}
|
||||
onChange={(e) => {
|
||||
setCustomAmount(e.target.value);
|
||||
setSelectedAmount(null);
|
||||
}}
|
||||
min="1"
|
||||
className="flex-1 h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Comment with emoji support - single row */}
|
||||
<MentionEditor
|
||||
ref={editorRef}
|
||||
placeholder="Say something nice..."
|
||||
searchProfiles={searchProfiles}
|
||||
searchEmojis={searchEmojis}
|
||||
className="rounded-md border border-input bg-background px-3 py-2"
|
||||
{/* Custom amount - separate line */}
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="Custom amount (sats)"
|
||||
value={customAmount}
|
||||
onChange={(e) => {
|
||||
setCustomAmount(e.target.value);
|
||||
setSelectedAmount(null);
|
||||
}}
|
||||
min="1"
|
||||
disabled={!hasLightningAddress}
|
||||
className="w-full"
|
||||
/>
|
||||
|
||||
{/* Comment with emoji support */}
|
||||
{hasLightningAddress && (
|
||||
<MentionEditor
|
||||
ref={editorRef}
|
||||
placeholder="Say something nice..."
|
||||
searchProfiles={searchProfiles}
|
||||
searchEmojis={searchEmojis}
|
||||
className="rounded-md border border-input bg-background px-3 py-2"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* No Lightning Address Warning */}
|
||||
{!hasLightningAddress && (
|
||||
<div className="text-sm text-muted-foreground text-center py-2 border border-dashed rounded-md">
|
||||
This user has not configured a Lightning address
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Payment Button */}
|
||||
{!canSign ? (
|
||||
<Button
|
||||
@@ -577,6 +577,7 @@ export function ZapWindow({
|
||||
className="w-full"
|
||||
size="lg"
|
||||
variant="default"
|
||||
disabled={!hasLightningAddress}
|
||||
>
|
||||
<LogIn className="size-4 mr-2" />
|
||||
Log in to Zap
|
||||
@@ -591,6 +592,7 @@ export function ZapWindow({
|
||||
)
|
||||
}
|
||||
disabled={
|
||||
!hasLightningAddress ||
|
||||
isProcessing ||
|
||||
(!isPaid && !selectedAmount && !customAmount)
|
||||
}
|
||||
|
||||
@@ -40,7 +40,13 @@ export async function resolveLightningAddress(
|
||||
const url = `https://${domain}/.well-known/lnurlp/${username}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
// Add timeout to prevent hanging
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10s timeout
|
||||
|
||||
const response = await fetch(url, { signal: controller.signal });
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch LNURL data: ${response.status} ${response.statusText}`,
|
||||
@@ -63,6 +69,11 @@ export async function resolveLightningAddress(
|
||||
return data;
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (error.name === "AbortError") {
|
||||
throw new Error(
|
||||
"Lightning address request timed out. Please try again.",
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
throw new Error(`Failed to resolve Lightning address: ${error}`);
|
||||
@@ -102,10 +113,17 @@ export async function fetchInvoiceFromCallback(
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url.toString());
|
||||
// Add timeout to prevent hanging
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10s timeout
|
||||
|
||||
const response = await fetch(url.toString(), { signal: controller.signal });
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => "");
|
||||
throw new Error(
|
||||
`Failed to fetch invoice: ${response.status} ${response.statusText}`,
|
||||
`Failed to fetch invoice (${response.status}): ${errorText || response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -118,6 +136,9 @@ export async function fetchInvoiceFromCallback(
|
||||
return data;
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (error.name === "AbortError") {
|
||||
throw new Error("Invoice request timed out. Please try again.");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
throw new Error(`Failed to fetch invoice from callback: ${error}`);
|
||||
|
||||
Reference in New Issue
Block a user