diff --git a/src/components/PaytoDialog/index.tsx b/src/components/PaytoDialog/index.tsx index 02ee6633..0be4cc78 100644 --- a/src/components/PaytoDialog/index.tsx +++ b/src/components/PaytoDialog/index.tsx @@ -88,7 +88,11 @@ export default function PaytoDialog({ setCompletedPaymentDetails(null) closeModal() releaseBodyScrollLocks() + return } + // Nested on ZapDialog: Radix scroll-lock can block clicks on this dialog. + const id = requestAnimationFrame(() => releaseBodyScrollLocks()) + return () => cancelAnimationFrame(id) }, [open]) useEffect(() => { diff --git a/src/components/ZapDialog/Nip57QuickZapButton.tsx b/src/components/ZapDialog/Nip57QuickZapButton.tsx index 1fe15030..f6698ca1 100644 --- a/src/components/ZapDialog/Nip57QuickZapButton.tsx +++ b/src/components/ZapDialog/Nip57QuickZapButton.tsx @@ -3,30 +3,44 @@ import { Skeleton } from '@/components/ui/skeleton' import { superchatLightningAccentClass } from '@/lib/superchat-ui' import { cn } from '@/lib/utils' import { Zap } from 'lucide-react' +import { useTranslation } from 'react-i18next' export default function Nip57QuickZapButton({ label, zapping, onClick, + onCancel, className }: { label: string zapping: boolean onClick: () => void + onCancel: () => void className?: string }) { + const { t } = useTranslation() + + if (zapping) { + return ( +
+ + +
+ ) + } + return ( ) diff --git a/src/components/ZapDialog/index.tsx b/src/components/ZapDialog/index.tsx index 8ae3892e..0023b15c 100644 --- a/src/components/ZapDialog/index.tsx +++ b/src/components/ZapDialog/index.tsx @@ -95,7 +95,7 @@ export default function ZapDialog({ return groupPaymentMethodsForDisplay(merged, senderPaytoTypes) }, [recipientPayment, senderPaytoTypes]) - const { canQuickNip57Zap, quickZapLabel, sendQuickZap, zapping } = useNip57QuickZap({ + const { canQuickNip57Zap, quickZapLabel, sendQuickZap, cancelZap, zapping } = useNip57QuickZap({ enabled: open, recipientPubkey: pubkey, referencedEvent: event, @@ -108,7 +108,12 @@ export default function ZapDialog({ paymentGroups.length > 0 || canQuickNip57Zap ? ( <> {canQuickNip57Zap ? ( - + ) : null} {paymentGroups.length > 0 ? ( { + if (enabled) { + ignoreResultRef.current = false + return + } + ignoreResultRef.current = true + setZapping(false) + }, [enabled]) + const lightningAddressOptionsKey = useMemo( () => buildOrderedZapLightningAddresses({ @@ -68,7 +78,6 @@ export function useNip57QuickZap(opts: { const canQuickNip57Zap = enabled && isLoggedIn && - isWalletConnected && defaultZapSats >= 1 && nip57Addresses !== null && nip57Addresses.length > 0 && @@ -84,10 +93,16 @@ export function useNip57QuickZap(opts: { n: formatSatsGrouped(defaultZapSats) }) + const cancelZap = useCallback(() => { + ignoreResultRef.current = true + setZapping(false) + }, []) + const sendQuickZap = useCallback(() => { if (!canQuickNip57Zap || zapping || !nip57Addresses?.length) return checkLogin(async () => { if (!pubkey) return + ignoreResultRef.current = false try { setZapping(true) const zapResult = await lightning.zap( @@ -102,7 +117,7 @@ export function useNip57QuickZap(opts: { candidates: nip57Addresses } ) - if (!zapResult) return + if (ignoreResultRef.current || !zapResult) return if (includePublicZapReceipt && zapResult.zapReceipt === null) { toast.warning( t( @@ -142,6 +157,7 @@ export function useNip57QuickZap(opts: { canQuickNip57Zap, quickZapLabel, sendQuickZap, + cancelZap, zapping } } diff --git a/src/lib/webln-payment.ts b/src/lib/webln-payment.ts index c92893e7..161b6d4b 100644 --- a/src/lib/webln-payment.ts +++ b/src/lib/webln-payment.ts @@ -73,3 +73,28 @@ export async function sendWebLNPaymentWithRetry( } throw lastError } + +const DEFAULT_WEBLN_PAYMENT_TIMEOUT_MS = 90_000 + +/** Same as {@link sendWebLNPaymentWithRetry} but rejects when the wallet never responds. */ +export async function sendWebLNPaymentWithRetryAndTimeout( + provider: WebLNProvider, + invoice: string, + options?: { maxAttempts?: number; timeoutMs?: number } +): Promise<{ preimage: string }> { + const timeoutMs = options?.timeoutMs ?? DEFAULT_WEBLN_PAYMENT_TIMEOUT_MS + let timeoutId: ReturnType | undefined + try { + return await Promise.race([ + sendWebLNPaymentWithRetry(provider, invoice, options?.maxAttempts ?? 3), + new Promise((_, reject) => { + timeoutId = setTimeout( + () => reject(new Error('Wallet payment timed out')), + timeoutMs + ) + }) + ]) + } finally { + if (timeoutId !== undefined) clearTimeout(timeoutId) + } +} diff --git a/src/services/lightning.service.ts b/src/services/lightning.service.ts index dc355441..8367bbd3 100644 --- a/src/services/lightning.service.ts +++ b/src/services/lightning.service.ts @@ -6,10 +6,7 @@ import { } from '@/constants' import { getProfileFromEvent, getZapInfoFromEvent } from '@/lib/event-metadata' import { closeModal, init, launchPaymentModal } from '@getalby/bitcoin-connect-react' -import { - isNwcWalletServiceInfoError, - sendWebLNPaymentWithRetry -} from '@/lib/webln-payment' +import { sendWebLNPaymentWithRetryAndTimeout } from '@/lib/webln-payment' import { Invoice } from '@getalby/lightning-tools' import { bech32 } from '@scure/base' import { WebLNProvider } from '@webbtc/webln-types' @@ -145,7 +142,7 @@ class LightningService { if (this.provider) { try { - const { preimage } = await sendWebLNPaymentWithRetry(this.provider, pr) + const { preimage } = await sendWebLNPaymentWithRetryAndTimeout(this.provider, pr) closeOuterModel?.() const zapReceipt = relays.length > 0 @@ -160,9 +157,7 @@ class LightningService { onPaymentFlowComplete?.(result) return result } catch (error) { - if (!isNwcWalletServiceInfoError(error)) { - throw error - } + logger.info('WebLN zap payment unavailable, falling back to invoice UI', { error }) } } @@ -302,15 +297,13 @@ class LightningService { ): Promise { if (this.provider) { try { - const { preimage } = await sendWebLNPaymentWithRetry(this.provider, invoice) + const { preimage } = await sendWebLNPaymentWithRetryAndTimeout(this.provider, invoice) closeOuterModel?.() const result = { preimage, invoice } onPaymentFlowComplete?.(result) return result } catch (error) { - if (!isNwcWalletServiceInfoError(error)) { - throw error - } + logger.info('WebLN invoice payment unavailable, falling back to invoice UI', { error }) } }