Browse Source

bug-fixes

imwald
Silberengel 4 weeks ago
parent
commit
0099761179
  1. 44
      src/components/PaytoDialog/LightningInvoiceSection.tsx
  2. 51
      src/components/PaytoDialog/index.tsx
  3. 2
      src/data/payto-types.json
  4. 21
      src/lib/payto-wallet-open.test.ts
  5. 37
      src/lib/payto-wallet-open.ts
  6. 2
      src/lib/payto.ts
  7. 5
      src/lib/react-remove-scroll-body-cleanup.ts
  8. 4
      src/services/lightning.service.ts

44
src/components/PaytoDialog/LightningInvoiceSection.tsx

@ -16,7 +16,9 @@ import { cn } from '@/lib/utils'
import { useZap } from '@/providers/ZapProvider' import { useZap } from '@/providers/ZapProvider'
import lightning from '@/services/lightning.service' import lightning from '@/services/lightning.service'
import { Check, Copy, Wallet, Zap } from 'lucide-react' 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 { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
@ -30,10 +32,19 @@ function invoiceQrPayload(pr: string): string {
export default function LightningInvoiceSection({ export default function LightningInvoiceSection({
lightningAddress, lightningAddress,
paytoUri paytoUri,
onBolt11InvoiceChange,
onRequestClose,
onPaymentSuccess
}: { }: {
lightningAddress: string lightningAddress: string
paytoUri: 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 { t } = useTranslation()
const { defaultZapSats, isWalletConnected } = useZap() const { defaultZapSats, isWalletConnected } = useZap()
@ -47,6 +58,16 @@ export default function LightningInvoiceSection({
const [invoiceDescription, setInvoiceDescription] = useState<string | null>(null) const [invoiceDescription, setInvoiceDescription] = useState<string | null>(null)
const [creating, setCreating] = useState(false) const [creating, setCreating] = useState(false)
const [paying, setPaying] = useState(false) const [paying, setPaying] = useState(false)
const mountedRef = useRef(true)
useEffect(() => {
mountedRef.current = true
return () => {
mountedRef.current = false
closeModal()
releaseBodyScrollLocks()
}
}, [])
useEffect(() => { useEffect(() => {
setSats(clampZapSats(defaultZapSats)) setSats(clampZapSats(defaultZapSats))
@ -57,7 +78,7 @@ export default function LightningInvoiceSection({
setLnurlMetadataState('loading') setLnurlMetadataState('loading')
let cancelled = false let cancelled = false
void lightning.getLnurlPayInvoiceOptions(lightningAddress).then((opts) => { void lightning.getLnurlPayInvoiceOptions(lightningAddress).then((opts) => {
if (!cancelled) { if (!cancelled && mountedRef.current) {
if (opts) { if (opts) {
setCommentMax(opts.commentAllowed) setCommentMax(opts.commentAllowed)
setLnurlMetadataState('ready') setLnurlMetadataState('ready')
@ -77,6 +98,10 @@ export default function LightningInvoiceSection({
setInvoiceDescription(null) setInvoiceDescription(null)
}, [sats, description]) }, [sats, description])
useEffect(() => {
onBolt11InvoiceChange?.(invoice)
}, [invoice, onBolt11InvoiceChange])
const invoiceSats = useMemo(() => { const invoiceSats = useMemo(() => {
if (!invoice) return null if (!invoice) return null
try { try {
@ -98,12 +123,15 @@ export default function LightningInvoiceSection({
const pr = await lightning.createLnurlInvoice(lightningAddress, sats, { const pr = await lightning.createLnurlInvoice(lightningAddress, sats, {
description: trimmedDesc || undefined description: trimmedDesc || undefined
}) })
if (!mountedRef.current) return
setInvoice(pr) setInvoice(pr)
setInvoiceDescription(trimmedDesc || null) setInvoiceDescription(trimmedDesc || null)
} catch (error) { } catch (error) {
if (mountedRef.current) {
toast.error(`${t('Failed to create invoice')}: ${(error as Error).message}`) toast.error(`${t('Failed to create invoice')}: ${(error as Error).message}`)
}
} finally { } finally {
setCreating(false) if (mountedRef.current) setCreating(false)
} }
} }
@ -111,16 +139,20 @@ export default function LightningInvoiceSection({
if (!invoice) return if (!invoice) return
try { try {
setPaying(true) setPaying(true)
const result = await lightning.payInvoice(invoice) const result = await lightning.payInvoice(invoice, onRequestClose)
if (!mountedRef.current) return
if (result) { if (result) {
toast.success(t('Payment sent')) toast.success(t('Payment sent'))
setInvoice(null) setInvoice(null)
setInvoiceDescription(null) setInvoiceDescription(null)
onPaymentSuccess?.()
} }
} catch (error) { } catch (error) {
if (mountedRef.current) {
toast.error(`${t('Lightning payment failed')}: ${(error as Error).message}`) toast.error(`${t('Lightning payment failed')}: ${(error as Error).message}`)
}
} finally { } finally {
setPaying(false) if (mountedRef.current) setPaying(false)
} }
} }

51
src/components/PaytoDialog/index.tsx

@ -9,11 +9,14 @@ import {
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Copy, ExternalLink, Wallet, Zap } from 'lucide-react' import { Copy, ExternalLink, Wallet, Zap } from 'lucide-react'
import { useTranslation } from 'react-i18next' 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 { toast } from 'sonner'
import { releaseBodyScrollLocks } from '@/lib/react-remove-scroll-body-cleanup'
import { import {
filterPaytoPaymentOpenHandlersForDevice, filterPaytoPaymentOpenHandlersForDevice,
getPaytoPaymentOpenHandlers, getPaytoPaymentOpenHandlers,
getPhoenixPaymentOpenHandler,
getPaytoTypeInfo getPaytoTypeInfo
} from '@/lib/payto' } from '@/lib/payto'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@ -46,9 +49,29 @@ export default function PaytoDialog({
const info = getPaytoTypeInfo(type) const info = getPaytoTypeInfo(type)
const label = info?.label ?? type const label = info?.label ?? type
const isLightning = type.toLowerCase() === 'lightning' const isLightning = type.toLowerCase() === 'lightning'
const openHandlers = filterPaytoPaymentOpenHandlersForDevice( const [bolt11Invoice, setBolt11Invoice] = useState<string | null>(null)
getPaytoPaymentOpenHandlers(type, authority)
) 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) => { const handleCopy = (text: string, copyLabel?: string) => {
navigator.clipboard.writeText(text) navigator.clipboard.writeText(text)
@ -56,12 +79,16 @@ export default function PaytoDialog({
handleDialogOpenChange(false) handleDialogOpenChange(false)
} }
const maybeOfferTipNoticeOnClose = () => { const maybeOfferTipNotice = useCallback(() => {
if (!offerTipNoticeOnClose) return if (!offerTipNoticeOnClose) return
if (!recipientPubkey) return if (!recipientPubkey) return
if (skipTipNoticeOnCloseRef.current) return
if (selfPubkey && recipientPubkey === selfPubkey) return if (selfPubkey && recipientPubkey === selfPubkey) return
setTipNoticeOpen(true) setTipNoticeOpen(true)
}, [offerTipNoticeOnClose, recipientPubkey, selfPubkey])
const maybeOfferTipNoticeOnClose = () => {
if (skipTipNoticeOnCloseRef.current) return
maybeOfferTipNotice()
} }
const handleDialogOpenChange = (next: boolean) => { const handleDialogOpenChange = (next: boolean) => {
@ -97,9 +124,15 @@ export default function PaytoDialog({
</DialogHeader> </DialogHeader>
<div className="min-w-0 space-y-4 px-4 py-4 sm:px-5"> <div className="min-w-0 space-y-4 px-4 py-4 sm:px-5">
{isLightning ? ( {isLightning && open ? (
<LightningInvoiceSection lightningAddress={authority} paytoUri={paytoUri} /> <LightningInvoiceSection
) : ( lightningAddress={authority}
paytoUri={paytoUri}
onBolt11InvoiceChange={setBolt11Invoice}
onRequestClose={closeForWalletFlow}
onPaymentSuccess={maybeOfferTipNotice}
/>
) : isLightning ? null : (
<> <>
<div className="min-w-0 rounded-lg bg-muted/40 px-3 py-2.5 ring-1 ring-border/50"> <div className="min-w-0 rounded-lg bg-muted/40 px-3 py-2.5 ring-1 ring-border/50">
<p className="mb-1.5 text-xs font-medium uppercase tracking-wider text-muted-foreground"> <p className="mb-1.5 text-xs font-medium uppercase tracking-wider text-muted-foreground">

2
src/data/payto-types.json

@ -12,7 +12,7 @@
"phoenix": { "phoenix": {
"label": "Phoenix", "label": "Phoenix",
"mobileOnly": true, "mobileOnly": true,
"uriTemplate": "phoenix:pay?uri={coinScheme}:{authority}" "uriTemplate": "phoenix:{coinScheme}:{authority}"
} }
}, },
"kind0CryptocurrencyAddresses": { "kind0CryptocurrencyAddresses": {

21
src/lib/payto-wallet-open.test.ts

@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { getPaytoProfileUrl } from '@/lib/payto-registry' import { getPaytoProfileUrl } from '@/lib/payto-registry'
import { import {
buildPhoenixWalletHref,
filterPaytoPaymentOpenHandlersForDevice, filterPaytoPaymentOpenHandlersForDevice,
filterWalletOpenActionsForDevice, filterWalletOpenActionsForDevice,
getPaytoPaymentOpenHandlers, getPaytoPaymentOpenHandlers,
@ -89,13 +90,29 @@ describe('getPaytoPaymentOpenHandlers', () => {
const offer = 'lno1pg257enxv4ezqcneype82um50ynhxgrwdajx283qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36ryg488qwlrnzyjczs' const offer = 'lno1pg257enxv4ezqcneype82um50ynhxgrwdajx283qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36ryg488qwlrnzyjczs'
const actions = getPaytoWalletOpenActions('bolt12', offer) const actions = getPaytoWalletOpenActions('bolt12', offer)
expect(actions).toHaveLength(1) 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) 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') 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') const phoenix = handlers.find((h) => h.openTargetName === 'Phoenix')
expect(phoenix?.href).toBe('phoenix:lightning:user@example.com')
expect(phoenix?.mobileOnly).toBe(true) expect(phoenix?.mobileOnly).toBe(true)
const prev = navigator.userAgent const prev = navigator.userAgent

37
src/lib/payto-wallet-open.ts

@ -97,6 +97,19 @@ function resolveWalletOpenRow(
return `${scheme}:${pathPart}${auth}` 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( function resolveWalletAppUri(
appId: string, appId: string,
paytoType: string, paytoType: string,
@ -108,6 +121,9 @@ function resolveWalletAppUri(
const auth = trimAuthority(authority) const auth = trimAuthority(authority)
if (!auth) return null if (!auth) return null
const coinScheme = (row?.scheme ?? paytoType).toLowerCase() const coinScheme = (row?.scheme ?? paytoType).toLowerCase()
if (appId === 'phoenix') {
return buildPhoenixWalletHref(coinScheme, auth)
}
const href = substituteAuthority( const href = substituteAuthority(
app.uriTemplate.replace(/\{coinScheme\}/g, coinScheme), app.uriTemplate.replace(/\{coinScheme\}/g, coinScheme),
auth auth
@ -115,6 +131,24 @@ function resolveWalletAppUri(
return href 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). * 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). * 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)) { 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) add(app.id, app.label, app.href, app.mobileOnly)
} }

2
src/lib/payto.ts

@ -28,6 +28,8 @@ export {
} from '@/lib/payto-registry' } from '@/lib/payto-registry'
export { export {
buildPhoenixWalletHref,
getPhoenixPaymentOpenHandler,
getPaytoPrimaryOpenUrl, getPaytoPrimaryOpenUrl,
getPaytoPaymentOpenHandlers, getPaytoPaymentOpenHandlers,
filterPaytoPaymentOpenHandlersForDevice, filterPaytoPaymentOpenHandlersForDevice,

5
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 * 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). * 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 if (typeof document === 'undefined') return
const body = document.body const body = document.body
const toRemove: string[] = [] const toRemove: string[] = []
@ -31,7 +32,7 @@ export function runAfterReleasingRadixScrollLock(
closeOuterModel?.() closeOuterModel?.()
const ms = closeOuterModel != null ? MS_AFTER_RADIX_DIALOG_FOR_EXTERNAL_MODAL : 0 const ms = closeOuterModel != null ? MS_AFTER_RADIX_DIALOG_FOR_EXTERNAL_MODAL : 0
window.setTimeout(() => { window.setTimeout(() => {
stripReactRemoveScrollBodyLocks() releaseBodyScrollLocks()
fn() fn()
}, ms) }, ms)
} }

4
src/services/lightning.service.ts

@ -7,7 +7,7 @@ import {
} from '@/constants' } from '@/constants'
import { getZapInfoFromEvent } from '@/lib/event-metadata' import { getZapInfoFromEvent } from '@/lib/event-metadata'
import { TProfile } from '@/types' import { TProfile } from '@/types'
import { init, launchPaymentModal } from '@getalby/bitcoin-connect-react' import { closeModal, init, launchPaymentModal } from '@getalby/bitcoin-connect-react'
import { import {
isNwcWalletServiceInfoError, isNwcWalletServiceInfoError,
sendWebLNPaymentWithRetry sendWebLNPaymentWithRetry
@ -141,6 +141,7 @@ class LightningService {
return new Promise((resolve) => { return new Promise((resolve) => {
runAfterReleasingRadixScrollLock(closeOuterModel, () => { runAfterReleasingRadixScrollLock(closeOuterModel, () => {
closeModal()
let checkPaymentInterval: ReturnType<typeof setInterval> | undefined let checkPaymentInterval: ReturnType<typeof setInterval> | undefined
let subCloser: SubCloser | undefined let subCloser: SubCloser | undefined
const { setPaid } = launchPaymentModal({ const { setPaid } = launchPaymentModal({
@ -214,6 +215,7 @@ class LightningService {
return new Promise((resolve) => { return new Promise((resolve) => {
runAfterReleasingRadixScrollLock(closeOuterModel, () => { runAfterReleasingRadixScrollLock(closeOuterModel, () => {
closeModal()
launchPaymentModal({ launchPaymentModal({
invoice: invoice, invoice: invoice,
onPaid: (response) => { onPaid: (response) => {

Loading…
Cancel
Save