a067a9fcf1
The progress bar indicates when the invoice will expire. This works by passing in a timeout to the withToastFlow wrapper. If timeout is set, progressBar option will be true for the toast and delay will be set to the timeout. If progressBar is set, the progress bar will use the delay for its duration.
386 lines
12 KiB
JavaScript
386 lines
12 KiB
JavaScript
import { useState, useCallback, useEffect } from 'react'
|
|
import { useApolloClient, useMutation, useQuery } from '@apollo/client'
|
|
import { Button } from 'react-bootstrap'
|
|
import { gql } from 'graphql-tag'
|
|
import { numWithUnits } from '../lib/format'
|
|
import AccordianItem from './accordian-item'
|
|
import Qr, { QrSkeleton } from './qr'
|
|
import { INVOICE } from '../fragments/wallet'
|
|
import InvoiceStatus from './invoice-status'
|
|
import { useMe } from './me'
|
|
import { useShowModal } from './modal'
|
|
import Countdown from './countdown'
|
|
import PayerData from './payer-data'
|
|
import Bolt11Info from './bolt11-info'
|
|
import { useWebLN } from './webln'
|
|
|
|
export function Invoice ({ invoice, modal, onPayment, info, successVerb, webLn }) {
|
|
const [expired, setExpired] = useState(new Date(invoice.expiredAt) <= new Date())
|
|
|
|
// if webLn was not passed, use true by default
|
|
if (webLn === undefined) webLn = true
|
|
|
|
let variant = 'default'
|
|
let status = 'waiting for you'
|
|
|
|
if (invoice.cancelled) {
|
|
variant = 'failed'
|
|
status = 'cancelled'
|
|
webLn = false
|
|
} else if (invoice.confirmedAt || (invoice.isHeld && invoice.satsReceived && !expired)) {
|
|
variant = 'confirmed'
|
|
status = `${numWithUnits(invoice.satsReceived, { abbreviate: false })} ${successVerb || 'deposited'}`
|
|
webLn = false
|
|
} else if (expired) {
|
|
variant = 'failed'
|
|
status = 'expired'
|
|
webLn = false
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (invoice.confirmedAt || (invoice.isHeld && invoice.satsReceived)) {
|
|
onPayment?.(invoice)
|
|
}
|
|
}, [invoice.confirmedAt, invoice.isHeld, invoice.satsReceived])
|
|
|
|
const { nostr, comment, lud18Data, bolt11, confirmedPreimage } = invoice
|
|
|
|
return (
|
|
<>
|
|
<Qr
|
|
webLn={webLn} value={invoice.bolt11}
|
|
description={numWithUnits(invoice.satsRequested, { abbreviate: false })}
|
|
statusVariant={variant} status={status}
|
|
/>
|
|
{!invoice.confirmedAt &&
|
|
<div className='text-muted text-center'>
|
|
<Countdown
|
|
date={invoice.expiresAt} onComplete={() => {
|
|
setExpired(true)
|
|
}}
|
|
/>
|
|
</div>}
|
|
{!modal &&
|
|
<>
|
|
{info && <div className='text-muted fst-italic text-center'>{info}</div>}
|
|
<div className='w-100'>
|
|
{nostr
|
|
? <AccordianItem
|
|
header='Nostr Zap Request'
|
|
body={
|
|
<pre>
|
|
<code>
|
|
{JSON.stringify(nostr, null, 2)}
|
|
</code>
|
|
</pre>
|
|
}
|
|
/>
|
|
: null}
|
|
</div>
|
|
{lud18Data &&
|
|
<div className='w-100'>
|
|
<AccordianItem
|
|
header='sender information'
|
|
body={<PayerData data={lud18Data} className='text-muted ms-3 mb-3' />}
|
|
/>
|
|
</div>}
|
|
{comment &&
|
|
<div className='w-100'>
|
|
<AccordianItem
|
|
header='sender comments'
|
|
body={<span className='text-muted ms-3'>{comment}</span>}
|
|
/>
|
|
</div>}
|
|
<Bolt11Info bolt11={bolt11} preimage={confirmedPreimage} />
|
|
</>}
|
|
|
|
</>
|
|
)
|
|
}
|
|
|
|
const JITInvoice = ({ invoice: { id, hash, hmac, expiresAt }, onPayment, onCancel, onRetry }) => {
|
|
const { data, loading, error } = useQuery(INVOICE, {
|
|
pollInterval: 1000,
|
|
variables: { id }
|
|
})
|
|
const [retryError, setRetryError] = useState(0)
|
|
if (error) {
|
|
if (error.message?.includes('invoice not found')) {
|
|
return
|
|
}
|
|
return <div>error</div>
|
|
}
|
|
if (!data || loading) {
|
|
return <QrSkeleton description status='loading' />
|
|
}
|
|
|
|
const retry = !!onRetry
|
|
let errorStatus = 'Something went wrong trying to perform the action after payment.'
|
|
if (retryError > 0) {
|
|
errorStatus = 'Something still went wrong.\nYou can retry or cancel the invoice to return your funds.'
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<Invoice invoice={data.invoice} modal onPayment={onPayment} successVerb='received' webLn={false} />
|
|
{retry
|
|
? (
|
|
<>
|
|
<div className='my-3'>
|
|
<InvoiceStatus variant='failed' status={errorStatus} />
|
|
</div>
|
|
<div className='d-flex flex-row mt-3 justify-content-center'>
|
|
<Button
|
|
className='mx-1' variant='info' onClick={async () => {
|
|
try {
|
|
await onRetry()
|
|
} catch (err) {
|
|
console.error('retry error:', err)
|
|
setRetryError(retryError => retryError + 1)
|
|
}
|
|
}}
|
|
>Retry
|
|
</Button>
|
|
<Button
|
|
className='mx-1'
|
|
variant='danger'
|
|
onClick={onCancel}
|
|
>Cancel
|
|
</Button>
|
|
</div>
|
|
</>
|
|
)
|
|
: null}
|
|
</>
|
|
)
|
|
}
|
|
|
|
const defaultOptions = {
|
|
requireSession: false,
|
|
forceInvoice: false
|
|
}
|
|
export const useInvoiceable = (onSubmit, options = defaultOptions) => {
|
|
const me = useMe()
|
|
const [createInvoice] = useMutation(gql`
|
|
mutation createInvoice($amount: Int!) {
|
|
createInvoice(amount: $amount, hodlInvoice: true, expireSecs: 180) {
|
|
id
|
|
bolt11
|
|
hash
|
|
hmac
|
|
expiresAt
|
|
}
|
|
}`)
|
|
const [cancelInvoice] = useMutation(gql`
|
|
mutation cancelInvoice($hash: String!, $hmac: String!) {
|
|
cancelInvoice(hash: $hash, hmac: $hmac) {
|
|
id
|
|
}
|
|
}
|
|
`)
|
|
|
|
const showModal = useShowModal()
|
|
const provider = useWebLN()
|
|
const client = useApolloClient()
|
|
const pollInvoice = (id) => client.query({ query: INVOICE, fetchPolicy: 'no-cache', variables: { id } })
|
|
|
|
const onSubmitWrapper = useCallback(async (
|
|
{ cost, ...formValues },
|
|
{ variables, optimisticResponse, update, flowId, ...submitArgs }) => {
|
|
// some actions require a session
|
|
if (!me && options.requireSession) {
|
|
throw new Error('you must be logged in')
|
|
}
|
|
|
|
// id for toast flows
|
|
if (!flowId) flowId = (+new Date()).toString(16)
|
|
|
|
// educated guesses where action might pass in the invoice amount
|
|
// (field 'cost' has highest precedence)
|
|
cost ??= formValues.amount
|
|
|
|
// attempt action for the first time
|
|
if (!cost || (me && !options.forceInvoice)) {
|
|
try {
|
|
const insufficientFunds = me?.privates.sats < cost
|
|
return await onSubmit(formValues,
|
|
{ ...submitArgs, flowId, variables, optimisticsResponse: insufficientFunds ? null : optimisticResponse, update })
|
|
} catch (error) {
|
|
if (!payOrLoginError(error) || !cost) {
|
|
// can't handle error here - bail
|
|
throw error
|
|
}
|
|
}
|
|
}
|
|
|
|
// initial attempt of action failed. we will create an invoice, pay and retry now.
|
|
const { data, error } = await createInvoice({ variables: { amount: cost } })
|
|
if (error) {
|
|
throw error
|
|
}
|
|
const inv = data.createInvoice
|
|
|
|
// If this is a zap, we need to manually be optimistic to have a consistent
|
|
// UX across custodial and WebLN zaps since WebLN zaps don't call GraphQL
|
|
// mutations which implement optimistic responses natively.
|
|
// Therefore, we check if this is a zap and then wrap the WebLN payment logic
|
|
// with manual cache update calls.
|
|
const itemId = optimisticResponse?.act?.id
|
|
const isZap = !!itemId
|
|
let _update
|
|
if (isZap && update) {
|
|
_update = () => {
|
|
const fragment = {
|
|
id: `Item:${itemId}`,
|
|
fragment: gql`
|
|
fragment ItemMeSats on Item {
|
|
sats
|
|
meSats
|
|
}
|
|
`
|
|
}
|
|
const item = client.cache.readFragment(fragment)
|
|
update(client.cache, { data: optimisticResponse })
|
|
// undo function
|
|
return () => client.cache.writeFragment({ ...fragment, data: item })
|
|
}
|
|
}
|
|
|
|
// wait until invoice is paid or modal is closed
|
|
const { modalOnClose, webLn, gqlCacheUpdateUndo } = await waitForPayment({
|
|
invoice: inv,
|
|
showModal,
|
|
provider,
|
|
pollInvoice,
|
|
gqlCacheUpdate: _update,
|
|
flowId
|
|
})
|
|
|
|
const retry = () => onSubmit(
|
|
{ hash: inv.hash, hmac: inv.hmac, expiresAt: inv.expiresAt, ...formValues },
|
|
// unset update function since we already ran an cache update if we paid using WebLN
|
|
// also unset update function if null was explicitly passed in
|
|
{ ...submitArgs, variables, update: webLn ? null : undefined })
|
|
// first retry
|
|
try {
|
|
const ret = await retry()
|
|
modalOnClose?.()
|
|
return ret
|
|
} catch (error) {
|
|
gqlCacheUpdateUndo?.()
|
|
console.error('retry error:', error)
|
|
}
|
|
|
|
// retry until success or cancel
|
|
return await new Promise((resolve, reject) => {
|
|
const cancelAndReject = async () => {
|
|
await cancelInvoice({ variables: { hash: inv.hash, hmac: inv.hmac } })
|
|
reject(new Error('invoice canceled'))
|
|
}
|
|
showModal(onClose => {
|
|
return (
|
|
<JITInvoice
|
|
invoice={inv}
|
|
onCancel={async () => {
|
|
await cancelAndReject()
|
|
onClose()
|
|
}}
|
|
onRetry={async () => {
|
|
resolve(await retry())
|
|
onClose()
|
|
}}
|
|
/>
|
|
)
|
|
}, { keepOpen: true, onClose: cancelAndReject })
|
|
})
|
|
}, [onSubmit, provider, createInvoice, !!me])
|
|
|
|
return onSubmitWrapper
|
|
}
|
|
|
|
const INVOICE_CANCELED_ERROR = 'invoice canceled'
|
|
const waitForPayment = async ({ invoice, showModal, provider, pollInvoice, gqlCacheUpdate, flowId }) => {
|
|
if (provider.enabled) {
|
|
try {
|
|
return await waitForWebLNPayment({ provider, invoice, pollInvoice, gqlCacheUpdate, flowId })
|
|
} catch (err) {
|
|
// check for errors which mean that QR code will also fail
|
|
if (err.message === INVOICE_CANCELED_ERROR) {
|
|
throw err
|
|
}
|
|
}
|
|
}
|
|
|
|
// QR code as fallback
|
|
return await new Promise((resolve, reject) => {
|
|
showModal(onClose => {
|
|
return (
|
|
<JITInvoice
|
|
invoice={invoice}
|
|
onPayment={() => resolve({ modalOnClose: onClose })}
|
|
/>
|
|
)
|
|
}, { keepOpen: true, onClose: reject })
|
|
})
|
|
}
|
|
|
|
const waitForWebLNPayment = async ({ provider, invoice, pollInvoice, gqlCacheUpdate, flowId }) => {
|
|
let undoUpdate
|
|
try {
|
|
// try WebLN provider first
|
|
return await new Promise((resolve, reject) => {
|
|
// be optimistic and pretend zap was already successful for consistent zapping UX
|
|
undoUpdate = gqlCacheUpdate?.()
|
|
// can't use await here since we might be paying JIT invoices
|
|
// and sendPaymentAsync is not supported yet.
|
|
// see https://www.webln.guide/building-lightning-apps/webln-reference/webln.sendpaymentasync
|
|
provider.sendPayment({ ...invoice, flowId })
|
|
// WebLN payment will never resolve here for JIT invoices
|
|
// since they only get resolved after settlement which can't happen here
|
|
.then(() => resolve({ webLn: true, gqlCacheUpdateUndo: undoUpdate }))
|
|
.catch(err => {
|
|
clearInterval(interval)
|
|
reject(err)
|
|
})
|
|
const interval = setInterval(async () => {
|
|
try {
|
|
const { data, error } = await pollInvoice(invoice.id)
|
|
if (error) {
|
|
clearInterval(interval)
|
|
return reject(error)
|
|
}
|
|
const { invoice: inv } = data
|
|
if (inv.isHeld && inv.satsReceived) {
|
|
clearInterval(interval)
|
|
resolve({ webLn: true, gqlCacheUpdateUndo: undoUpdate })
|
|
}
|
|
if (inv.cancelled) {
|
|
clearInterval(interval)
|
|
reject(new Error(INVOICE_CANCELED_ERROR))
|
|
}
|
|
} catch (err) {
|
|
clearInterval(interval)
|
|
reject(err)
|
|
}
|
|
}, 1000)
|
|
})
|
|
} catch (err) {
|
|
undoUpdate?.()
|
|
console.error('WebLN payment failed:', err)
|
|
throw err
|
|
}
|
|
}
|
|
|
|
export const useInvoiceModal = (onPayment, deps) => {
|
|
const onPaymentMemo = useCallback(onPayment, deps)
|
|
return useInvoiceable(onPaymentMemo, { replaceModal: true })
|
|
}
|
|
|
|
export const payOrLoginError = (error) => {
|
|
const matches = ['insufficient funds', 'you must be logged in or pay']
|
|
if (Array.isArray(error)) {
|
|
return error.some(({ message }) => matches.some(m => message.includes(m)))
|
|
}
|
|
return matches.some(m => error.toString().includes(m))
|
|
}
|