Skip to content

Commit

Permalink
WIP: Wrap WebLN payments with toasts
Browse files Browse the repository at this point in the history
* add toasts for pending, error, success
* while pending, invoice can be canceled
* there are still some race conditions between payiny the invoice / error on payment and invoice cancellation
  • Loading branch information
ekzyis committed Jan 22, 2024
1 parent eeacdf8 commit 2cf0839
Show file tree
Hide file tree
Showing 6 changed files with 103 additions and 45 deletions.
12 changes: 10 additions & 2 deletions components/invoice.js
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,7 @@ export const useInvoiceable = (onSubmit, options = defaultOptions) => {
}

const waitForPayment = async ({ invoice, showModal, provider, pollInvoice, updateCache, undoUpdate }) => {
const INVOICE_CANCELED_ERROR = 'invoice was canceled'
try {
// try WebLN provider first
return await new Promise((resolve, reject) => {
Expand All @@ -275,7 +276,7 @@ const waitForPayment = async ({ invoice, showModal, provider, pollInvoice, updat
// can't use await here since we might be paying HODL invoices
// and sendPaymentAsync is not supported yet.
// see https://www.webln.guide/building-lightning-apps/webln-reference/webln.sendpaymentasync
provider.sendPayment(invoice.bolt11)
provider.sendPayment(invoice)
// WebLN payment will never resolve here for HODL invoices
// since they only get resolved after settlement which can't happen here
.then(resolve)
Expand All @@ -295,16 +296,23 @@ const waitForPayment = async ({ invoice, showModal, provider, pollInvoice, updat
clearInterval(interval)
resolve()
}
if (inv.cancelled) {
clearInterval(interval)
reject(new Error(INVOICE_CANCELED_ERROR))
}
} catch (err) {
clearInterval(interval)
reject(err)
}
}, 1000)
})
} catch (err) {
console.error('WebLN payment failed:', err)
// undo attempt to make zapping UX consistent
undoUpdate?.()
console.error('WebLN payment failed:', err)
if (err.message === INVOICE_CANCELED_ERROR) {
throw err
}
}

// QR code as fallback
Expand Down
54 changes: 29 additions & 25 deletions components/toast.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,15 @@ export const ToastProvider = ({ children }) => {
})
},
warning: (body, options) => {
const id = toastId.current
dispatchToast({
body,
variant: 'warning',
autohide: true,
delay: 5000,
...options
})
return () => removeToast(id)
},
danger: (body, options) => {
const id = toastId.current
Expand All @@ -52,9 +54,7 @@ export const ToastProvider = ({ children }) => {
autohide: false,
...options
})
return {
removeToast: () => removeToast(id)
}
return () => removeToast(id)
}
}), [dispatchToast, removeToast])

Expand All @@ -71,28 +71,32 @@ export const ToastProvider = ({ children }) => {
return (
<ToastContext.Provider value={toaster}>
<ToastContainer className={`pb-3 pe-3 ${styles.toastContainer}`} position='bottom-end' containerPosition='fixed'>
{toasts.map(toast => (
<Toast
key={toast.id} bg={toast.variant} show autohide={toast.autohide}
delay={toast.delay} className={`${styles.toast} ${styles[toast.variant]} ${toast.variant === 'warning' ? 'text-dark' : ''}`} onClose={() => removeToast(toast.id)}
>
<ToastBody>
<div className='d-flex align-items-center'>
<div className='flex-grow-1'>{toast.body}</div>
<Button
variant={null}
className='p-0 ps-2'
aria-label='close'
onClick={() => {
toast.onClose?.()
removeToast(toast.id)
}}
><div className={`${styles.toastClose} ${toast.variant === 'warning' ? 'text-dark' : ''}`}>X</div>
</Button>
</div>
</ToastBody>
</Toast>
))}
{toasts.map(toast => {
const textStyle = toast.variant === 'warning' ? 'text-dark' : ''
return (
<Toast
key={toast.id} bg={toast.variant} show autohide={toast.autohide}
delay={toast.delay} className={`${styles.toast} ${styles[toast.variant]} ${textStyle}`} onClose={() => removeToast(toast.id)}
>
<ToastBody>
<div className='d-flex align-items-center'>
<div className='flex-grow-1'>{toast.body}</div>
<Button
variant={null}
className='p-0 ps-2'
aria-label='close'
onClick={() => {
toast.onCancel?.()
toast.onClose?.()
removeToast(toast.id)
}}
>{toast.onCancel ? <div className={`${styles.toastCancel} ${textStyle}`}>cancel</div> : <div className={`${styles.toastClose} ${textStyle}`}>X</div>}
</Button>
</div>
</ToastBody>
</Toast>
)
})}
</ToastContainer>
{children}
</ToastContext.Provider>
Expand Down
7 changes: 7 additions & 0 deletions components/toast.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,13 @@
border-color: var(--bs-warning-border-subtle);
}

.toastCancel {
font-style: italic;
cursor: pointer;
display: flex;
align-items: center;
}

.toastClose {
color: #fff;
font-family: "lightning";
Expand Down
2 changes: 1 addition & 1 deletion components/use-crossposter.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export default function useCrossposter () {

const relayError = (failedRelays) => {
return new Promise(resolve => {
const { removeToast } = toast.danger(
const removeToast = toast.danger(
<>
Crossposting failed for {failedRelays.join(', ')} <br />
<Button
Expand Down
41 changes: 40 additions & 1 deletion components/webln/index.js
Original file line number Diff line number Diff line change
@@ -1,18 +1,57 @@
import { createContext, useContext } from 'react'
import { LNbitsProvider, useLNbits } from './lnbits'
import { NWCProvider, useNWC } from './nwc'
import { useToast } from '../toast'
import { gql, useMutation } from '@apollo/client'

const WebLNContext = createContext({})

function RawWebLNProvider ({ children }) {
const lnbits = useLNbits()
const nwc = useNWC()
const toaster = useToast()
const [cancelInvoice] = useMutation(gql`
mutation cancelInvoice($hash: String!, $hmac: String!) {
cancelInvoice(hash: $hash, hmac: $hmac) {
id
}
}
`)

// TODO: switch between providers based on user preference
const provider = nwc

const sendPaymentWithToast = function ({ bolt11, hash, hmac }) {
let canceled = false
let removeToast = toaster.warning('zap pending', {
autohide: false,
onCancel: async () => {
try {
await cancelInvoice({ variables: { hash, hmac } })
canceled = true
toaster.warning('zap canceled')
removeToast = undefined
} catch (err) {
toaster.danger('failed to cancel zap')
}
}
})
return provider.sendPayment(bolt11)
.then(() => {
if (canceled) return
removeToast?.()
if (!canceled) toaster.success('zap successful')
}).catch((err) => {
if (canceled) return
removeToast?.()
const reason = err?.message?.toString().toLowerCase() || 'unknown reason'
toaster.danger(`zap failed: ${reason}`)
throw err
})
}

return (
<WebLNContext.Provider value={provider}>
<WebLNContext.Provider value={{ ...provider, sendPayment: sendPaymentWithToast }}>
{children}
</WebLNContext.Provider>
)
Expand Down
32 changes: 16 additions & 16 deletions pages/_app.js
Original file line number Diff line number Diff line change
Expand Up @@ -92,14 +92,14 @@ export default function MyApp ({ Component, pageProps: { ...props } }) {
</Head>
<ErrorBoundary>
<PlausibleProvider domain='stacker.news' trackOutboundLinks>
<WebLNProvider>
<ApolloProvider client={client}>
<MeProvider me={me}>
<LoggerProvider>
<ServiceWorkerProvider>
<PriceProvider price={price}>
<LightningProvider>
<ToastProvider>
<ApolloProvider client={client}>
<MeProvider me={me}>
<LoggerProvider>
<ServiceWorkerProvider>
<PriceProvider price={price}>
<LightningProvider>
<ToastProvider>
<WebLNProvider>
<ShowModalProvider>
<BlockHeightProvider blockHeight={blockHeight}>
<ChainFeeProvider chainFee={chainFee}>
Expand All @@ -110,14 +110,14 @@ export default function MyApp ({ Component, pageProps: { ...props } }) {
</ChainFeeProvider>
</BlockHeightProvider>
</ShowModalProvider>
</ToastProvider>
</LightningProvider>
</PriceProvider>
</ServiceWorkerProvider>
</LoggerProvider>
</MeProvider>
</ApolloProvider>
</WebLNProvider>
</WebLNProvider>
</ToastProvider>
</LightningProvider>
</PriceProvider>
</ServiceWorkerProvider>
</LoggerProvider>
</MeProvider>
</ApolloProvider>
</PlausibleProvider>
</ErrorBoundary>
</>
Expand Down

0 comments on commit 2cf0839

Please sign in to comment.