diff --git a/src/components/PaytoDialog/LightningInvoiceSection.tsx b/src/components/PaytoDialog/LightningInvoiceSection.tsx index ed862cfd..91bb695f 100644 --- a/src/components/PaytoDialog/LightningInvoiceSection.tsx +++ b/src/components/PaytoDialog/LightningInvoiceSection.tsx @@ -16,7 +16,9 @@ import { cn } from '@/lib/utils' import { useZap } from '@/providers/ZapProvider' import lightning from '@/services/lightning.service' import { Check, Copy, Wallet, Zap } from 'lucide-react' -import { useEffect, useMemo, useState } from 'react' +import { closeModal } from '@getalby/bitcoin-connect-react' +import { releaseBodyScrollLocks } from '@/lib/react-remove-scroll-body-cleanup' +import { useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' @@ -30,10 +32,19 @@ function invoiceQrPayload(pr: string): string { export default function LightningInvoiceSection({ lightningAddress, - paytoUri + paytoUri, + onBolt11InvoiceChange, + onRequestClose, + onPaymentSuccess }: { lightningAddress: string paytoUri: string + /** Fired when a BOLT11 invoice is created or cleared (for Phoenix / external wallet links). */ + onBolt11InvoiceChange?: (invoice: string | null) => void + /** Close the payto dialog before opening an external wallet / Bitcoin Connect UI. */ + onRequestClose?: () => void + /** After a successful in-app or external wallet payment (kind-24 tip notice). */ + onPaymentSuccess?: () => void }) { const { t } = useTranslation() const { defaultZapSats, isWalletConnected } = useZap() @@ -47,6 +58,16 @@ export default function LightningInvoiceSection({ const [invoiceDescription, setInvoiceDescription] = useState(null) const [creating, setCreating] = useState(false) const [paying, setPaying] = useState(false) + const mountedRef = useRef(true) + + useEffect(() => { + mountedRef.current = true + return () => { + mountedRef.current = false + closeModal() + releaseBodyScrollLocks() + } + }, []) useEffect(() => { setSats(clampZapSats(defaultZapSats)) @@ -57,7 +78,7 @@ export default function LightningInvoiceSection({ setLnurlMetadataState('loading') let cancelled = false void lightning.getLnurlPayInvoiceOptions(lightningAddress).then((opts) => { - if (!cancelled) { + if (!cancelled && mountedRef.current) { if (opts) { setCommentMax(opts.commentAllowed) setLnurlMetadataState('ready') @@ -77,6 +98,10 @@ export default function LightningInvoiceSection({ setInvoiceDescription(null) }, [sats, description]) + useEffect(() => { + onBolt11InvoiceChange?.(invoice) + }, [invoice, onBolt11InvoiceChange]) + const invoiceSats = useMemo(() => { if (!invoice) return null try { @@ -98,12 +123,15 @@ export default function LightningInvoiceSection({ const pr = await lightning.createLnurlInvoice(lightningAddress, sats, { description: trimmedDesc || undefined }) + if (!mountedRef.current) return setInvoice(pr) setInvoiceDescription(trimmedDesc || null) } catch (error) { - toast.error(`${t('Failed to create invoice')}: ${(error as Error).message}`) + if (mountedRef.current) { + toast.error(`${t('Failed to create invoice')}: ${(error as Error).message}`) + } } finally { - setCreating(false) + if (mountedRef.current) setCreating(false) } } @@ -111,16 +139,20 @@ export default function LightningInvoiceSection({ if (!invoice) return try { setPaying(true) - const result = await lightning.payInvoice(invoice) + const result = await lightning.payInvoice(invoice, onRequestClose) + if (!mountedRef.current) return if (result) { toast.success(t('Payment sent')) setInvoice(null) setInvoiceDescription(null) + onPaymentSuccess?.() } } catch (error) { - toast.error(`${t('Lightning payment failed')}: ${(error as Error).message}`) + if (mountedRef.current) { + toast.error(`${t('Lightning payment failed')}: ${(error as Error).message}`) + } } finally { - setPaying(false) + if (mountedRef.current) setPaying(false) } } diff --git a/src/components/PaytoDialog/index.tsx b/src/components/PaytoDialog/index.tsx index 2790b8a8..016fcbda 100644 --- a/src/components/PaytoDialog/index.tsx +++ b/src/components/PaytoDialog/index.tsx @@ -9,11 +9,14 @@ import { import { Button } from '@/components/ui/button' import { Copy, ExternalLink, Wallet, Zap } from 'lucide-react' import { useTranslation } from 'react-i18next' -import { useRef, useState } from 'react' +import { closeModal } from '@getalby/bitcoin-connect-react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { toast } from 'sonner' +import { releaseBodyScrollLocks } from '@/lib/react-remove-scroll-body-cleanup' import { filterPaytoPaymentOpenHandlersForDevice, getPaytoPaymentOpenHandlers, + getPhoenixPaymentOpenHandler, getPaytoTypeInfo } from '@/lib/payto' import { cn } from '@/lib/utils' @@ -46,9 +49,29 @@ export default function PaytoDialog({ const info = getPaytoTypeInfo(type) const label = info?.label ?? type const isLightning = type.toLowerCase() === 'lightning' - const openHandlers = filterPaytoPaymentOpenHandlersForDevice( - getPaytoPaymentOpenHandlers(type, authority) - ) + const [bolt11Invoice, setBolt11Invoice] = useState(null) + + useEffect(() => { + if (!open) { + setBolt11Invoice(null) + closeModal() + releaseBodyScrollLocks() + } + }, [open]) + + const closeForWalletFlow = useCallback(() => { + skipTipNoticeOnCloseRef.current = true + onOpenChange(false) + }, [onOpenChange]) + + const openHandlers = useMemo(() => { + const handlers = getPaytoPaymentOpenHandlers(type, authority) + if (isLightning && bolt11Invoice) { + const phoenix = getPhoenixPaymentOpenHandler('lightning', bolt11Invoice) + if (phoenix) handlers.push(phoenix) + } + return filterPaytoPaymentOpenHandlersForDevice(handlers) + }, [type, authority, isLightning, bolt11Invoice]) const handleCopy = (text: string, copyLabel?: string) => { navigator.clipboard.writeText(text) @@ -56,12 +79,16 @@ export default function PaytoDialog({ handleDialogOpenChange(false) } - const maybeOfferTipNoticeOnClose = () => { + const maybeOfferTipNotice = useCallback(() => { if (!offerTipNoticeOnClose) return if (!recipientPubkey) return - if (skipTipNoticeOnCloseRef.current) return if (selfPubkey && recipientPubkey === selfPubkey) return setTipNoticeOpen(true) + }, [offerTipNoticeOnClose, recipientPubkey, selfPubkey]) + + const maybeOfferTipNoticeOnClose = () => { + if (skipTipNoticeOnCloseRef.current) return + maybeOfferTipNotice() } const handleDialogOpenChange = (next: boolean) => { @@ -97,9 +124,15 @@ export default function PaytoDialog({
- {isLightning ? ( - - ) : ( + {isLightning && open ? ( + + ) : isLightning ? null : ( <>

diff --git a/src/data/payto-types.json b/src/data/payto-types.json index f7222a78..870a6d5e 100644 --- a/src/data/payto-types.json +++ b/src/data/payto-types.json @@ -12,7 +12,7 @@ "phoenix": { "label": "Phoenix", "mobileOnly": true, - "uriTemplate": "phoenix:pay?uri={coinScheme}:{authority}" + "uriTemplate": "phoenix:{coinScheme}:{authority}" } }, "kind0CryptocurrencyAddresses": { diff --git a/src/lib/payto-wallet-open.test.ts b/src/lib/payto-wallet-open.test.ts index 991e5733..2c96519d 100644 --- a/src/lib/payto-wallet-open.test.ts +++ b/src/lib/payto-wallet-open.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { getPaytoProfileUrl } from '@/lib/payto-registry' import { + buildPhoenixWalletHref, filterPaytoPaymentOpenHandlersForDevice, filterWalletOpenActionsForDevice, getPaytoPaymentOpenHandlers, @@ -89,13 +90,29 @@ describe('getPaytoPaymentOpenHandlers', () => { const offer = 'lno1pg257enxv4ezqcneype82um50ynhxgrwdajx283qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36ryg488qwlrnzyjczs' const actions = getPaytoWalletOpenActions('bolt12', offer) expect(actions).toHaveLength(1) - expect(actions[0].href).toBe(`phoenix:pay?uri=bolt12:${offer}`) + expect(actions[0].href).toBe(`phoenix:bolt12:${offer}`) expect(actions[0].mobileOnly).toBe(true) }) - it('includes Phoenix on mobile only', () => { + it('builds Phoenix lightning deep link without pay?uri query', () => { + expect(buildPhoenixWalletHref('lightning', 'user@example.com')).toBe( + 'phoenix:lightning:user@example.com' + ) + expect(buildPhoenixWalletHref('lightning', 'lnbc1p0example')).toBe('phoenix:lightning:lnbc1p0example') + expect(buildPhoenixWalletHref('lightning', 'lightning:lnbc1p0example')).toBe( + 'phoenix:lightning:lnbc1p0example' + ) + }) + + it('omits Phoenix for lightning address until BOLT11 is supplied separately', () => { const handlers = getPaytoPaymentOpenHandlers('lightning', 'user@example.com') + expect(handlers.some((h) => h.openTargetName === 'Phoenix')).toBe(false) + }) + + it('includes Phoenix on mobile only for bip353', () => { + const handlers = getPaytoPaymentOpenHandlers('bip353', 'user@example.com') const phoenix = handlers.find((h) => h.openTargetName === 'Phoenix') + expect(phoenix?.href).toBe('phoenix:lightning:user@example.com') expect(phoenix?.mobileOnly).toBe(true) const prev = navigator.userAgent diff --git a/src/lib/payto-wallet-open.ts b/src/lib/payto-wallet-open.ts index d5b6c897..d70c3fac 100644 --- a/src/lib/payto-wallet-open.ts +++ b/src/lib/payto-wallet-open.ts @@ -97,6 +97,19 @@ function resolveWalletOpenRow( return `${scheme}:${pathPart}${auth}` } +/** + * Phoenix strips a `phoenix:` prefix, then parses the remainder (e.g. `lightning:lnbc…`). + * Do not use `phoenix:pay?uri=…` — the app does not treat that as a payment request. + */ +export function buildPhoenixWalletHref(coinScheme: string, authority: string): string | null { + const auth = trimAuthority(authority) + if (!auth) return null + const scheme = coinScheme.toLowerCase().trim() + if (!scheme) return null + const payload = auth.replace(/^lightning:/i, '') + return `phoenix:${scheme}:${payload}` +} + function resolveWalletAppUri( appId: string, paytoType: string, @@ -108,6 +121,9 @@ function resolveWalletAppUri( const auth = trimAuthority(authority) if (!auth) return null const coinScheme = (row?.scheme ?? paytoType).toLowerCase() + if (appId === 'phoenix') { + return buildPhoenixWalletHref(coinScheme, auth) + } const href = substituteAuthority( app.uriTemplate.replace(/\{coinScheme\}/g, coinScheme), auth @@ -115,6 +131,24 @@ function resolveWalletAppUri( return href } +/** Mobile Phoenix deep link for a concrete payment target (BOLT11, offer, lightning address, …). */ +export function getPhoenixPaymentOpenHandler( + coinScheme: string, + authority: string +): PaytoPaymentOpenHandler | null { + const href = buildPhoenixWalletHref(coinScheme, authority) + if (!href) return null + return { + id: `phoenix-${coinScheme}`, + openTargetName: walletCatalog.walletApps?.phoenix?.label ?? 'Phoenix', + href, + isHttp: false, + mobileOnly: walletCatalog.walletApps?.phoenix?.mobileOnly !== false + } +} + +const PAYTO_TYPES_PHOENIX_REQUIRES_BOLT11 = new Set(['lightning']) + /** * Primary browser/OS URL for this payto target (wallet URI or https). * Returns null when the type should use copy-only or zap (caller checks zappable lightning). @@ -222,6 +256,9 @@ export function getPaytoPaymentOpenHandlers(type: string, authority: string): Pa } for (const app of getPaytoWalletOpenActions(type, auth)) { + if (app.label === 'Phoenix' && PAYTO_TYPES_PHOENIX_REQUIRES_BOLT11.has(canonical)) { + continue + } add(app.id, app.label, app.href, app.mobileOnly) } diff --git a/src/lib/payto.ts b/src/lib/payto.ts index b4d015bc..b50d384b 100644 --- a/src/lib/payto.ts +++ b/src/lib/payto.ts @@ -28,6 +28,8 @@ export { } from '@/lib/payto-registry' export { + buildPhoenixWalletHref, + getPhoenixPaymentOpenHandler, getPaytoPrimaryOpenUrl, getPaytoPaymentOpenHandlers, filterPaytoPaymentOpenHandlersForDevice, diff --git a/src/lib/react-remove-scroll-body-cleanup.ts b/src/lib/react-remove-scroll-body-cleanup.ts index 928cf8a9..123854d5 100644 --- a/src/lib/react-remove-scroll-body-cleanup.ts +++ b/src/lib/react-remove-scroll-body-cleanup.ts @@ -5,7 +5,8 @@ * that class is still present, the UI can paint on top but ignore all clicks (notably after closing * our Zap dialog from a secondary pane / sheet). */ -function stripReactRemoveScrollBodyLocks(): void { +/** Remove stuck Radix `react-remove-scroll` body classes (restores clicks after nested modals). */ +export function releaseBodyScrollLocks(): void { if (typeof document === 'undefined') return const body = document.body const toRemove: string[] = [] @@ -31,7 +32,7 @@ export function runAfterReleasingRadixScrollLock( closeOuterModel?.() const ms = closeOuterModel != null ? MS_AFTER_RADIX_DIALOG_FOR_EXTERNAL_MODAL : 0 window.setTimeout(() => { - stripReactRemoveScrollBodyLocks() + releaseBodyScrollLocks() fn() }, ms) } diff --git a/src/services/lightning.service.ts b/src/services/lightning.service.ts index b06b56d2..b0f2b0a1 100644 --- a/src/services/lightning.service.ts +++ b/src/services/lightning.service.ts @@ -7,7 +7,7 @@ import { } from '@/constants' import { getZapInfoFromEvent } from '@/lib/event-metadata' import { TProfile } from '@/types' -import { init, launchPaymentModal } from '@getalby/bitcoin-connect-react' +import { closeModal, init, launchPaymentModal } from '@getalby/bitcoin-connect-react' import { isNwcWalletServiceInfoError, sendWebLNPaymentWithRetry @@ -141,6 +141,7 @@ class LightningService { return new Promise((resolve) => { runAfterReleasingRadixScrollLock(closeOuterModel, () => { + closeModal() let checkPaymentInterval: ReturnType | undefined let subCloser: SubCloser | undefined const { setPaid } = launchPaymentModal({ @@ -214,6 +215,7 @@ class LightningService { return new Promise((resolve) => { runAfterReleasingRadixScrollLock(closeOuterModel, () => { + closeModal() launchPaymentModal({ invoice: invoice, onPaid: (response) => {