Frontend payment UX cleanup (#1194)

* Replace useInvoiceable with usePayment hook

* Show WebLnError in QR code fallback

* Fix missing removal of old zap undo code

* Fix payment timeout message

* Fix unused arg in super()

* Also bail if invoice expired

* Fix revert on reply error

* Use JIT_INVOICE_TIMEOUT_MS constant

* Remove unnecessary PaymentContext

* Fix me as a dependency in FeeButtonContext

* Fix anon sats added before act success

* Optimistic updates for zaps

* Fix modal not closed after custom zap

* Optimistic update for custom zaps

* Optimistic update for bounty payments

* Consistent error handling for zaps and bounty payments

* Optimistic update for poll votes

* Use var balance in payment.request

* Rename invoiceable to prepaid

* Log cancelled invoices

* Client notifications

We now show notifications that are stored on the client to inform the user about following errors in the prepaid payment flow:

- if a payment fails
- if an invoice expires before it is paid
- if a payment was interrupted (for example via page refresh)
- if the action fails after payment

* Remove unnecessary passing of act

* Use AbortController for zap undos

* Fix anon zap update not updating bolt color

* Fix zap counted towards anon sats even if logged in

* Fix duplicate onComplete call

* Fix downzap type error

* Fix "missing field 'path' while writing result" error

* Pass full item in downzap props

The previous commit fixed cache updates for downzaps but then the cache update for custom zaps failed because 'path' wasn't included in the server response.

This commit is the proper fix.

* Parse lnc rpc error messages

* Add hash to InvoiceExpiredError
This commit is contained in:
ekzyis 2024-05-28 12:18:54 -05:00 committed by GitHub
parent b81c5bcc78
commit 94cce9155d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
36 changed files with 980 additions and 910 deletions

View File

@ -899,7 +899,7 @@ export default {
WHERE act IN ('TIP', 'FEE')
AND "itemId" = ${Number(id)}::INTEGER
AND "userId" = ${me.id}::INTEGER)::INTEGER)`,
{ models }
{ models, lnd, hash, hmac }
)
} else {
await serialize(

View File

@ -107,7 +107,8 @@ export function BountyForm ({
...SubSelectInitial({ sub: item?.subName || sub?.name })
}}
schema={schema}
invoiceable={{ requireSession: true }}
requireSession
prepaid
onSubmit={
handleSubmit ||
onSubmit

View File

@ -0,0 +1,187 @@
import { useApolloClient } from '@apollo/client'
import { useMe } from './me'
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
import { datePivot, timeSince } from '@/lib/time'
import { ANON_USER_ID, JIT_INVOICE_TIMEOUT_MS } from '@/lib/constants'
import { HAS_NOTIFICATIONS } from '@/fragments/notifications'
import Item from './item'
import { RootProvider } from './root'
import Comment from './comment'
const toType = t => ({ ERROR: `${t}_ERROR`, PENDING: `${t}_PENDING` })
export const Types = {
Zap: toType('ZAP'),
Reply: toType('REPLY'),
Bounty: toType('BOUNTY'),
PollVote: toType('POLL_VOTE')
}
const ClientNotificationContext = createContext()
export function ClientNotificationProvider ({ children }) {
const [notifications, setNotifications] = useState([])
const client = useApolloClient()
const me = useMe()
// anons don't have access to /notifications
// but we'll store client notifications anyway for simplicity's sake
const storageKey = `client-notifications:${me?.id || ANON_USER_ID}`
useEffect(() => {
const loaded = loadNotifications(storageKey, client)
setNotifications(loaded)
}, [storageKey])
const notify = useCallback((type, props) => {
const id = crypto.randomUUID()
const sortTime = new Date()
const expiresAt = +datePivot(sortTime, { milliseconds: JIT_INVOICE_TIMEOUT_MS })
const isError = type.endsWith('ERROR')
const n = { __typename: type, id, sortTime: +sortTime, pending: !isError, expiresAt, ...props }
setNotifications(notifications => [n, ...notifications])
saveNotification(storageKey, n)
if (isError) {
client?.writeQuery({
query: HAS_NOTIFICATIONS,
data: {
hasNewNotes: true
}
})
}
return id
}, [storageKey, client])
const unnotify = useCallback((id) => {
setNotifications(notifications => notifications.filter(n => n.id !== id))
removeNotification(storageKey, id)
}, [storageKey])
const value = useMemo(() => ({ notifications, notify, unnotify }), [notifications, notify, unnotify])
return (
<ClientNotificationContext.Provider value={value}>
{children}
</ClientNotificationContext.Provider>
)
}
export function ClientNotifyProvider ({ children, additionalProps }) {
const ctx = useClientNotifications()
const notify = useCallback((type, props) => {
return ctx.notify(type, { ...props, ...additionalProps })
}, [ctx.notify])
const value = useMemo(() => ({ ...ctx, notify }), [ctx, notify])
return (
<ClientNotificationContext.Provider value={value}>
{children}
</ClientNotificationContext.Provider>
)
}
export function useClientNotifications () {
return useContext(ClientNotificationContext)
}
function ClientNotification ({ n, message }) {
if (n.pending) {
const expired = n.expiresAt < +new Date()
if (!expired) return null
n.reason = 'invoice expired'
}
// remove payment hashes due to x-overflow
n.reason = n.reason.replace(/(: )?[a-f0-9]{64}/, '')
return (
<div className='ms-2'>
<small className='fw-bold text-danger'>
{n.reason ? `${message}: ${n.reason}` : message}
<small className='text-muted ms-1 fw-normal' suppressHydrationWarning>{timeSince(new Date(n.sortTime))}</small>
</small>
{!n.item
? null
: n.item.title
? <Item item={n.item} />
: (
<div className='pb-2'>
<RootProvider root={n.item.root}>
<Comment item={n.item} noReply includeParent noComments clickToContext />
</RootProvider>
</div>
)}
</div>
)
}
export function ClientZap ({ n }) {
const message = `failed to zap ${n.sats || n.amount} sats`
return <ClientNotification n={n} message={message} />
}
export function ClientReply ({ n }) {
const message = 'failed to submit reply'
return <ClientNotification n={n} message={message} />
}
export function ClientBounty ({ n }) {
const message = 'failed to pay bounty'
return <ClientNotification n={n} message={message} />
}
export function ClientPollVote ({ n }) {
const message = 'failed to submit poll vote'
return <ClientNotification n={n} message={message} />
}
function loadNotifications (storageKey, client) {
const stored = window.localStorage.getItem(storageKey)
if (!stored) return []
const filtered = JSON.parse(stored).filter(({ sortTime }) => {
// only keep notifications younger than 24 hours
return new Date(sortTime) >= datePivot(new Date(), { hours: -24 })
})
let hasNewNotes = false
const mapped = filtered.map((n) => {
if (!n.pending) return n
// anything that is still pending when we load the page was interrupted
// so we immediately mark it as failed instead of waiting until it expired
const type = n.__typename.replace('PENDING', 'ERROR')
const reason = 'payment was interrupted'
hasNewNotes = true
return { ...n, __typename: type, pending: false, reason }
})
if (hasNewNotes) {
client?.writeQuery({
query: HAS_NOTIFICATIONS,
data: {
hasNewNotes: true
}
})
}
window.localStorage.setItem(storageKey, JSON.stringify(mapped))
return filtered
}
function saveNotification (storageKey, n) {
const stored = window.localStorage.getItem(storageKey)
if (stored) {
window.localStorage.setItem(storageKey, JSON.stringify([...JSON.parse(stored), n]))
} else {
window.localStorage.setItem(storageKey, JSON.stringify([n]))
}
}
function removeNotification (storageKey, id) {
const stored = window.localStorage.getItem(storageKey)
if (stored) {
window.localStorage.setItem(storageKey, JSON.stringify(JSON.parse(stored).filter(n => n.id !== id)))
}
}

View File

@ -145,7 +145,7 @@ export default function Comment ({
{item.outlawed && !me?.privates?.wildWestMode
? <Skull className={styles.dontLike} width={24} height={24} />
: item.meDontLikeSats > item.meSats
? <DownZap width={24} height={24} className={styles.dontLike} id={item.id} meDontLikeSats={item.meDontLikeSats} />
? <DownZap width={24} height={24} className={styles.dontLike} item={item} />
: pin ? <Pin width={22} height={22} className={styles.pin} /> : <UpVote item={item} className={styles.upvote} />}
<div className={`${itemStyles.hunk} ${styles.hunk}`}>
<div className='d-flex align-items-center'>

View File

@ -96,7 +96,7 @@ export function DiscussionForm ({
...SubSelectInitial({ sub: item?.subName || sub?.name })
}}
schema={schema}
invoiceable
prepaid
onSubmit={handleSubmit || onSubmit}
storageKeyPrefix={storageKeyPrefix}
>

View File

@ -1,15 +1,15 @@
import Dropdown from 'react-bootstrap/Dropdown'
import { useShowModal } from './modal'
import { useToast } from './toast'
import ItemAct, { zapUndosThresholdReached } from './item-act'
import ItemAct from './item-act'
import AccordianItem from './accordian-item'
import Flag from '@/svgs/flag-fill.svg'
import { useMemo } from 'react'
import getColor from '@/lib/rainbow'
import { gql, useMutation } from '@apollo/client'
import { useMe } from './me'
export function DownZap ({ id, meDontLikeSats, ...props }) {
export function DownZap ({ item, ...props }) {
const { meDontLikeSats } = item
const style = useMemo(() => (meDontLikeSats
? {
fill: getColor(meDontLikeSats),
@ -17,14 +17,13 @@ export function DownZap ({ id, meDontLikeSats, ...props }) {
}
: undefined), [meDontLikeSats])
return (
<DownZapper id={id} As={({ ...oprops }) => <Flag {...props} {...oprops} style={style} />} />
<DownZapper item={item} As={({ ...oprops }) => <Flag {...props} {...oprops} style={style} />} />
)
}
function DownZapper ({ id, As, children }) {
function DownZapper ({ item, As, children }) {
const toaster = useToast()
const showModal = useShowModal()
const me = useMe()
return (
<As
@ -32,12 +31,7 @@ function DownZapper ({ id, As, children }) {
try {
showModal(onClose =>
<ItemAct
onClose={(amount) => {
onClose()
// undo prompt was toasted before closing modal if zap undos are enabled
// so an additional success toast would be confusing
if (!zapUndosThresholdReached(me, amount)) toaster.success('item downzapped')
}} itemId={id} down
onClose={onClose} item={item} down
>
<AccordianItem
header='what is a downzap?' body={
@ -59,11 +53,11 @@ function DownZapper ({ id, As, children }) {
)
}
export default function DontLikeThisDropdownItem ({ id }) {
export default function DontLikeThisDropdownItem ({ item }) {
return (
<DownZapper
As={Dropdown.Item}
id={id}
item={item}
>
<span className='text-danger'>downzap</span>
</DownZapper>

View File

@ -64,6 +64,7 @@ export function postCommentUseRemoteLineItems ({ parentId } = {}) {
export function FeeButtonProvider ({ baseLineItems = {}, useRemoteLineItems = () => null, children }) {
const [lineItems, setLineItems] = useState({})
const [disabled, setDisabled] = useState(false)
const me = useMe()
const remoteLineItems = useRemoteLineItems()
@ -76,14 +77,18 @@ export function FeeButtonProvider ({ baseLineItems = {}, useRemoteLineItems = ()
const value = useMemo(() => {
const lines = { ...baseLineItems, ...lineItems, ...remoteLineItems }
const total = Object.values(lines).reduce((acc, { modifier }) => modifier(acc), 0)
// freebies: there's only a base cost and we don't have enough sats
const free = total === lines.baseCost?.modifier(0) && lines.baseCost?.allowFreebies && me?.privates?.sats < total
return {
lines,
merge: mergeLineItems,
total: Object.values(lines).reduce((acc, { modifier }) => modifier(acc), 0),
total,
disabled,
setDisabled
setDisabled,
free
}
}, [baseLineItems, lineItems, remoteLineItems, mergeLineItems, disabled, setDisabled])
}, [me?.privates?.sats, baseLineItems, lineItems, remoteLineItems, mergeLineItems, disabled, setDisabled])
return (
<FeeButtonContext.Provider value={value}>
@ -111,9 +116,7 @@ function FreebieDialog () {
export default function FeeButton ({ ChildButton = SubmitButton, variant, text, disabled }) {
const me = useMe()
const { lines, total, disabled: ctxDisabled } = useFeeButton()
// freebies: there's only a base cost and we don't have enough sats
const free = total === lines.baseCost?.modifier(0) && lines.baseCost?.allowFreebies && me?.privates?.sats < total
const { lines, total, disabled: ctxDisabled, free } = useFeeButton()
const feeText = free
? 'free'
: total > 1

View File

@ -18,7 +18,6 @@ import { gql, useLazyQuery } from '@apollo/client'
import { USER_SUGGESTIONS } from '@/fragments/users'
import TextareaAutosize from 'react-textarea-autosize'
import { useToast } from './toast'
import { useInvoiceable } from './invoice'
import { numWithUnits } from '@/lib/format'
import textAreaCaret from 'textarea-caret'
import ReactDatePicker from 'react-datepicker'
@ -32,6 +31,18 @@ import Thumb from '@/svgs/thumb-up-fill.svg'
import Eye from '@/svgs/eye-fill.svg'
import EyeClose from '@/svgs/eye-close-line.svg'
import Info from './info'
import { InvoiceCanceledError, usePayment } from './payment'
import { useMe } from './me'
import { optimisticUpdate } from '@/lib/apollo'
import { useClientNotifications } from './client-notifications'
import { ActCanceledError } from './item-act'
export class SessionRequiredError extends Error {
constructor () {
super('session required')
this.name = 'SessionRequiredError'
}
}
export function SubmitButton ({
children, variant, value, onClick, disabled, nonDisabledText, ...props
@ -793,11 +804,16 @@ const StorageKeyPrefixContext = createContext()
export function Form ({
initial, schema, onSubmit, children, initialError, validateImmediately,
storageKeyPrefix, validateOnChange = true, invoiceable, innerRef, ...props
storageKeyPrefix, validateOnChange = true, prepaid, requireSession, innerRef,
optimisticUpdate: optimisticUpdateArgs, clientNotification, signal, ...props
}) {
const toaster = useToast()
const initialErrorToasted = useRef(false)
const feeButton = useFeeButton()
const payment = usePayment()
const me = useMe()
const { notify, unnotify } = useClientNotifications()
useEffect(() => {
if (initialError && !initialErrorToasted.current) {
toaster.danger('form error: ' + initialError.message || initialError.toString?.())
@ -820,37 +836,57 @@ export function Form ({
})
}, [storageKeyPrefix])
// if `invoiceable` is set,
// support for payment per invoice if they are lurking or don't have enough balance
// is added to submit handlers.
// submit handlers need to accept { satsReceived, hash, hmac } in their first argument
// and use them as variables in their GraphQL mutation
if (invoiceable && onSubmit) {
const options = typeof invoiceable === 'object' ? invoiceable : undefined
onSubmit = useInvoiceable(onSubmit, options)
}
const onSubmitInner = useCallback(async (values, ...args) => {
const onSubmitInner = useCallback(async ({ amount, ...values }, ...args) => {
const variables = { amount, ...values }
let revert, cancel, nid
try {
if (onSubmit) {
// extract cost from formik fields
// (cost may also be set in a formik field named 'amount')
const cost = feeButton?.total || values?.amount
if (cost) {
values.cost = cost
if (requireSession && !me) {
throw new SessionRequiredError()
}
await onSubmit(values, ...args)
if (optimisticUpdateArgs) {
revert = optimisticUpdate(optimisticUpdateArgs(variables))
}
await signal?.pause({ me, amount })
if (me && clientNotification) {
nid = notify(clientNotification.PENDING, variables)
}
let hash, hmac
if (prepaid) {
[{ hash, hmac }, cancel] = await payment.request(amount)
}
await onSubmit({ hash, hmac, ...variables }, ...args)
if (!storageKeyPrefix) return
clearLocalStorage(values)
}
} catch (err) {
const msg = err.message || err.toString?.()
// ignore errors from JIT invoices or payments from attached wallets
// that mean that submit failed because user aborted the payment
if (msg === 'modal closed' || msg === 'invoice canceled') return
toaster.danger('submit error: ' + msg)
revert?.()
if (err instanceof InvoiceCanceledError || err instanceof ActCanceledError) {
return
}
const reason = err.message || err.toString?.()
if (me && clientNotification) {
notify(clientNotification.ERROR, { ...variables, reason })
} else {
toaster.danger('submit error: ' + reason)
}
cancel?.()
} finally {
// if we reach this line, the submit either failed or was successful so we can remove the pending notification.
// if we don't reach this line, the page was probably reloaded and we can use the pending notification
// stored in localStorage to handle this case.
if (nid) unnotify(nid)
}
}, [onSubmit, feeButton?.total, toaster, clearLocalStorage, storageKeyPrefix])
}, [me, onSubmit, feeButton?.total, toaster, clearLocalStorage, storageKeyPrefix, payment, signal])
return (
<Formik

View File

@ -1,23 +1,35 @@
import { useState, useCallback, useEffect } from 'react'
import { useApolloClient, useMutation, useQuery } from '@apollo/client'
import { Button } from 'react-bootstrap'
import { gql } from 'graphql-tag'
import { useState, useEffect } from 'react'
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 Qr from './qr'
import Countdown from './countdown'
import PayerData from './payer-data'
import Bolt11Info from './bolt11-info'
import { useWebLN } from './webln'
import { FAST_POLL_INTERVAL } from '@/lib/constants'
import { useQuery } from '@apollo/client'
import { INVOICE } from '@/fragments/wallet'
import { FAST_POLL_INTERVAL, SSR } from '@/lib/constants'
import { WebLnNotEnabledError } from './payment'
export function Invoice ({ invoice, modal, onPayment, info, successVerb, webLn }) {
export default function Invoice ({ invoice, modal, onPayment, info, successVerb, webLn, webLnError, poll }) {
const [expired, setExpired] = useState(new Date(invoice.expiredAt) <= new Date())
const { data, error } = useQuery(INVOICE, SSR
? {}
: {
pollInterval: FAST_POLL_INTERVAL,
variables: { id: invoice.id },
nextFetchPolicy: 'cache-and-network',
skip: !poll
})
if (data) {
invoice = data.invoice
}
if (error) {
return <div>{error.toString()}</div>
}
// if webLn was not passed, use true by default
if (webLn === undefined) webLn = true
@ -48,6 +60,11 @@ export function Invoice ({ invoice, modal, onPayment, info, successVerb, webLn }
return (
<>
{webLnError && !(webLnError instanceof WebLnNotEnabledError) &&
<div className='text-center text-danger mb-3'>
Payment from attached wallet failed:
<div>{webLnError.toString()}</div>
</div>}
<Qr
webLn={webLn} value={invoice.bolt11}
description={numWithUnits(invoice.satsRequested, { abbreviate: false })}
@ -105,289 +122,3 @@ export function Invoice ({ invoice, modal, onPayment, info, successVerb, webLn }
</>
)
}
const JITInvoice = ({ invoice: { id, hash, hmac, expiresAt }, onPayment, onCancel, onRetry }) => {
const { data, loading, error } = useQuery(INVOICE, {
pollInterval: FAST_POLL_INTERVAL,
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 ItemMeSatsInvoice 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) {
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)
}
}, FAST_POLL_INTERVAL)
})
} 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))
}

View File

@ -5,13 +5,16 @@ import { Form, Input, SubmitButton } from './form'
import { useMe } from './me'
import UpBolt from '@/svgs/bolt.svg'
import { amountSchema } from '@/lib/validate'
import { gql, useApolloClient, useMutation } from '@apollo/client'
import { payOrLoginError, useInvoiceModal } from './invoice'
import { TOAST_DEFAULT_DELAY_MS, useToast, withToastFlow } from './toast'
import { gql, useMutation } from '@apollo/client'
import { useToast } from './toast'
import { useLightning } from './lightning'
import { nextTip } from './upvote'
import { InvoiceCanceledError, usePayment } from './payment'
import { optimisticUpdate } from '@/lib/apollo'
import { Types as ClientNotification, ClientNotifyProvider, useClientNotifications } from './client-notifications'
import { ZAP_UNDO_DELAY_MS } from '@/lib/constants'
const defaultTips = [100, 1000, 10000, 100000]
const defaultTips = [100, 1000, 10_000, 100_000]
const Tips = ({ setOValue }) => {
const tips = [...getCustomTips(), ...defaultTips].sort((a, b) => a - b)
@ -41,222 +44,149 @@ const addCustomTip = (amount) => {
window.localStorage.setItem('custom-tips', JSON.stringify(customTips))
}
export const zapUndosThresholdReached = (me, amount) => {
if (!me) return false
const enabled = me.privates.zapUndos !== null
return enabled ? amount >= me.privates.zapUndos : false
const setItemMeAnonSats = ({ id, amount }) => {
const storageKey = `TIP-item:${id}`
const existingAmount = Number(window.localStorage.getItem(storageKey) || '0')
window.localStorage.setItem(storageKey, existingAmount + amount)
}
export default function ItemAct ({ onClose, itemId, down, children }) {
export const actUpdate = ({ me, onUpdate }) => (cache, args) => {
const { data: { act: { id, sats, path, act } } } = args
cache.modify({
id: `Item:${id}`,
fields: {
sats (existingSats = 0) {
if (act === 'TIP') {
return existingSats + sats
}
return existingSats
},
meSats: (existingSats = 0) => {
if (act === 'TIP') {
return existingSats + sats
}
return existingSats
},
meDontLikeSats: me
? (existingSats = 0) => {
if (act === 'DONT_LIKE_THIS') {
return existingSats + sats
}
return existingSats
}
: undefined
}
})
if (act === 'TIP') {
// update all ancestors
path.split('.').forEach(aId => {
if (Number(aId) === Number(id)) return
cache.modify({
id: `Item:${aId}`,
fields: {
commentSats (existingCommentSats = 0) {
return existingCommentSats + sats
}
}
})
})
}
onUpdate?.(cache, args)
}
export default function ItemAct ({ onClose, item, down, children, abortSignal }) {
const inputRef = useRef(null)
const me = useMe()
const [oValue, setOValue] = useState()
const strike = useLightning()
const toaster = useToast()
const client = useApolloClient()
useEffect(() => {
inputRef.current?.focus()
}, [onClose, itemId])
}, [onClose, item.id])
const [act, actUpdate] = useAct()
const act = useAct()
const onSubmit = useCallback(async ({ amount, hash, hmac }, { update, keepOpen }) => {
if (!me) {
const storageKey = `TIP-item:${itemId}`
const existingAmount = Number(window.localStorage.getItem(storageKey) || '0')
window.localStorage.setItem(storageKey, existingAmount + amount)
}
const onSubmit = useCallback(async ({ amount, hash, hmac }) => {
await act({
variables: {
id: itemId,
id: item.id,
sats: Number(amount),
act: down ? 'DONT_LIKE_THIS' : 'TIP',
hash,
hmac
},
update
}
})
// only strike when zap undos not enabled
// due to optimistic UX on zap undos
if (!zapUndosThresholdReached(me, Number(amount))) await strike()
if (!me) setItemMeAnonSats({ id: item.id, amount })
addCustomTip(Number(amount))
if (!keepOpen) onClose(Number(amount))
}, [me, act, down, itemId, strike])
}, [me, act, down, item.id, strike])
const onSubmitWithUndos = withToastFlow(toaster)(
(values, args) => {
const { flowId } = args
let canceled
const sats = values.amount
const insufficientFunds = me?.privates?.sats < sats
const invoiceAttached = values.hash && values.hmac
if (insufficientFunds && !invoiceAttached) throw new Error('insufficient funds')
// payments from external wallets already have their own flow
// and we don't want to show undo toasts for them
const skipToastFlow = invoiceAttached
// update function for optimistic UX
const update = () => {
const fragment = {
id: `Item:${itemId}`,
fragment: gql`
fragment ItemMeSatsSubmit on Item {
path
sats
meSats
meDontLikeSats
}
`
}
const item = client.cache.readFragment(fragment)
const optimisticResponse = {
act: {
id: itemId, sats, path: item.path, act: down ? 'DONT_LIKE_THIS' : 'TIP'
}
}
actUpdate(client.cache, { data: optimisticResponse })
return () => client.cache.writeFragment({ ...fragment, data: item })
}
let undoUpdate
return {
skipToastFlow,
flowId,
tag: itemId,
type: 'zap',
pendingMessage: `${down ? 'down' : ''}zapped ${sats} sats`,
onPending: async () => {
if (skipToastFlow) {
return onSubmit(values, { flowId, ...args, update: null })
}
await strike()
onClose(sats)
return new Promise((resolve, reject) => {
undoUpdate = update()
setTimeout(() => {
if (canceled) return resolve()
onSubmit(values, { flowId, ...args, update: null, keepOpen: true })
.then(resolve)
.catch((err) => {
undoUpdate()
reject(err)
})
}, TOAST_DEFAULT_DELAY_MS)
})
},
onUndo: () => {
canceled = true
undoUpdate?.()
},
hideSuccess: true,
hideError: true,
timeout: TOAST_DEFAULT_DELAY_MS
}
const optimisticUpdate = useCallback(({ amount }) => {
const variables = {
id: item.id,
sats: Number(amount),
act: down ? 'DONT_LIKE_THIS' : 'TIP'
}
)
const optimisticResponse = { act: { ...variables, path: item.path } }
strike()
onClose()
return { mutation: ACT_MUTATION, variables, optimisticResponse, update: actUpdate({ me }) }
}, [item.id, down, !!me, strike])
return (
<Form
initial={{
amount: me?.privates?.tipDefault || defaultTips[0],
default: false
}}
schema={amountSchema}
invoiceable
onSubmit={(values, ...args) => {
if (zapUndosThresholdReached(me, values.amount)) {
return onSubmitWithUndos(values, ...args)
}
return onSubmit(values, ...args)
}}
>
<Input
label='amount'
name='amount'
type='number'
innerRef={inputRef}
overrideValue={oValue}
required
autoFocus
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
/>
<div>
<Tips setOValue={setOValue} />
</div>
{children}
<div className='d-flex mt-3'>
<SubmitButton variant={down ? 'danger' : 'success'} className='ms-auto mt-1 px-4' value='TIP'>{down && 'down'}zap</SubmitButton>
</div>
</Form>
<ClientNotifyProvider additionalProps={{ itemId: item.id }}>
<Form
initial={{
amount: me?.privates?.tipDefault || defaultTips[0],
default: false
}}
schema={amountSchema}
prepaid
optimisticUpdate={optimisticUpdate}
onSubmit={onSubmit}
clientNotification={ClientNotification.Zap}
signal={abortSignal}
>
<Input
label='amount'
name='amount'
type='number'
innerRef={inputRef}
overrideValue={oValue}
required
autoFocus
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
/>
<div>
<Tips setOValue={setOValue} />
</div>
{children}
<div className='d-flex mt-3'>
<SubmitButton variant={down ? 'danger' : 'success'} className='ms-auto mt-1 px-4' value='TIP'>{down && 'down'}zap</SubmitButton>
</div>
</Form>
</ClientNotifyProvider>
)
}
export function useAct ({ onUpdate } = {}) {
const me = useMe()
const update = useCallback((cache, args) => {
const { data: { act: { id, sats, path, act } } } = args
cache.modify({
id: `Item:${id}`,
fields: {
sats (existingSats = 0) {
if (act === 'TIP') {
return existingSats + sats
}
return existingSats
},
meSats: me
? (existingSats = 0) => {
if (act === 'TIP') {
return existingSats + sats
}
return existingSats
}
: undefined,
meDontLikeSats: me
? (existingSats = 0) => {
if (act === 'DONT_LIKE_THIS') {
return existingSats + sats
}
return existingSats
}
: undefined
}
})
if (act === 'TIP') {
// update all ancestors
path.split('.').forEach(aId => {
if (Number(aId) === Number(id)) return
cache.modify({
id: `Item:${aId}`,
fields: {
commentSats (existingCommentSats = 0) {
return existingCommentSats + sats
}
}
})
})
onUpdate && onUpdate(cache, args)
export const ACT_MUTATION = gql`
mutation act($id: ID!, $sats: Int!, $act: String, $hash: String, $hmac: String) {
act(id: $id, sats: $sats, act: $act, hash: $hash, hmac: $hmac) {
id
sats
path
act
}
}, [!!me, onUpdate])
}`
const [act] = useMutation(
gql`
mutation act($id: ID!, $sats: Int!, $act: String, $hash: String, $hmac: String) {
act(id: $id, sats: $sats, act: $act, hash: $hash, hmac: $hmac) {
id
sats
path
act
}
}`, { update }
)
return [act, update]
export function useAct ({ onUpdate } = {}) {
const [act] = useMutation(ACT_MUTATION)
return act
}
export function useZap () {
@ -307,118 +237,107 @@ export function useZap () {
}
}, [])
const [zap] = useMutation(
gql`
mutation idempotentAct($id: ID!, $sats: Int!) {
act(id: $id, sats: $sats, idempotent: true) {
id
sats
path
}
}`
)
const ZAP_MUTATION = gql`
mutation idempotentAct($id: ID!, $sats: Int!, $hash: String, $hmac: String) {
act(id: $id, sats: $sats, hash: $hash, hmac: $hmac, idempotent: true) {
id
sats
path
}
}`
const [zap] = useMutation(ZAP_MUTATION)
const me = useMe()
const { notify, unnotify } = useClientNotifications()
const toaster = useToast()
const strike = useLightning()
const [act] = useAct()
const client = useApolloClient()
const payment = usePayment()
const invoiceableAct = useInvoiceModal(
async ({ hash, hmac }, { variables, ...apolloArgs }) => {
await act({ variables: { ...variables, hash, hmac }, ...apolloArgs })
strike()
}, [act, strike])
const zapWithUndos = withToastFlow(toaster)(
({ variables, optimisticResponse, update, flowId }) => {
const { id: itemId, amount } = variables
let canceled
// update function for optimistic UX
const _update = () => {
const fragment = {
id: `Item:${itemId}`,
fragment: gql`
fragment ItemMeSatsUndos on Item {
sats
meSats
}
`
}
const item = client.cache.readFragment(fragment)
update(client.cache, { data: optimisticResponse })
// undo function
return () => client.cache.writeFragment({ ...fragment, data: item })
}
let undoUpdate
return {
flowId,
tag: itemId,
type: 'zap',
pendingMessage: `zapped ${amount} sats`,
onPending: () =>
new Promise((resolve, reject) => {
undoUpdate = _update()
setTimeout(
() => {
if (canceled) return resolve()
zap({ variables, optimisticResponse, update: null }).then(resolve).catch((err) => {
undoUpdate()
reject(err)
})
},
TOAST_DEFAULT_DELAY_MS
)
}),
onUndo: () => {
// we can't simply clear the timeout on cancel since
// the onPending promise would never settle in that case
canceled = true
undoUpdate?.()
},
hideSuccess: true,
hideError: true,
timeout: TOAST_DEFAULT_DELAY_MS
}
}
)
return useCallback(async ({ item, me }) => {
return useCallback(async ({ item, mem, abortSignal }) => {
const meSats = (item?.meSats || 0)
// add current sats to next tip since idempotent zaps use desired total zap not difference
const sats = meSats + nextTip(meSats, { ...me?.privates })
const amount = sats - meSats
const satsDelta = sats - meSats
const variables = { id: item.id, sats, act: 'TIP', amount }
const insufficientFunds = me?.privates.sats < amount
const variables = { id: item.id, sats, act: 'TIP' }
const notifyProps = { itemId: item.id, sats: satsDelta }
const optimisticResponse = { act: { path: item.path, ...variables } }
const flowId = (+new Date()).toString(16)
const zapArgs = { variables, optimisticResponse: insufficientFunds ? null : optimisticResponse, update, flowId }
let revert, cancel, nid
try {
if (insufficientFunds) throw new Error('insufficient funds')
revert = optimisticUpdate({ mutation: ZAP_MUTATION, variables, optimisticResponse, update })
strike()
if (zapUndosThresholdReached(me, amount)) {
await zapWithUndos(zapArgs)
} else {
await zap(zapArgs)
await abortSignal.pause({ me, amount: satsDelta })
if (me) {
nid = notify(ClientNotification.Zap.PENDING, notifyProps)
}
let hash, hmac;
[{ hash, hmac }, cancel] = await payment.request(satsDelta)
await zap({ variables: { ...variables, hash, hmac } })
} catch (error) {
if (payOrLoginError(error)) {
// call non-idempotent version
const amount = sats - meSats
optimisticResponse.act.amount = amount
try {
await invoiceableAct({ amount }, {
variables: { ...variables, sats: amount },
optimisticResponse,
update,
flowId
})
} catch (error) {}
revert?.()
if (error instanceof InvoiceCanceledError || error instanceof ActCanceledError) {
return
}
console.error(error)
toaster.danger('zap: ' + error?.message || error?.toString?.())
const reason = error?.message || error?.toString?.()
if (me) {
notify(ClientNotification.Zap.ERROR, { ...notifyProps, reason })
} else {
toaster.danger('zap failed: ' + reason)
}
cancel?.()
} finally {
if (nid) unnotify(nid)
}
}, [me?.id, strike, payment, notify, unnotify])
}
export class ActCanceledError extends Error {
constructor () {
super('act canceled')
this.name = 'ActCanceledError'
}
}
export class ZapUndoController extends AbortController {
constructor () {
super()
this.signal.start = () => { this.started = true }
this.signal.done = () => { this.done = true }
this.signal.pause = async ({ me, amount }) => {
if (zapUndoTrigger({ me, amount })) {
await zapUndo(this.signal)
}
}
}
}
const zapUndoTrigger = ({ me, amount }) => {
if (!me) return false
const enabled = me.privates.zapUndos !== null
return enabled ? amount >= me.privates.zapUndos : false
}
const zapUndo = async (signal) => {
return await new Promise((resolve, reject) => {
signal.start()
const abortHandler = () => {
reject(new ActCanceledError())
signal.done()
signal.removeEventListener('abort', abortHandler)
}
signal.addEventListener('abort', abortHandler)
setTimeout(() => {
resolve()
signal.done()
signal.removeEventListener('abort', abortHandler)
}, ZAP_UNDO_DELAY_MS)
})
}

View File

@ -176,7 +176,7 @@ export default function ItemInfo ({
!item.mine && !item.deletedAt &&
(item.meDontLikeSats > meTotalSats
? <DropdownItemUpVote item={item} />
: <DontLikeThisDropdownItem id={item.id} />)}
: <DontLikeThisDropdownItem item={item} />)}
{me && sub && !item.mine && !item.outlawed && Number(me.id) === Number(sub.userId) && sub.moderated &&
<>
<hr className='dropdown-divider' />

View File

@ -63,7 +63,7 @@ export default function Item ({ item, rank, belowTitle, right, full, children, s
{item.position && (pinnable || !item.subName)
? <Pin width={24} height={24} className={styles.pin} />
: item.meDontLikeSats > item.meSats
? <DownZap width={24} height={24} className={styles.dontLike} id={item.id} meDontLikeSats={item.meDontLikeSats} />
? <DownZap width={24} height={24} className={styles.dontLike} item={item} />
: Number(item.user?.id) === AD_USER_ID
? <AdIcon width={24} height={24} className={styles.ad} />
: <UpVote item={item} className={styles.upvote} />}

View File

@ -105,7 +105,8 @@ export default function JobForm ({ item, sub }) {
}}
schema={jobSchema}
storageKeyPrefix={storageKeyPrefix}
invoiceable={{ requireSession: true }}
requireSession
prepaid
onSubmit={onSubmit}
>
<div className='form-group'>

View File

@ -143,7 +143,7 @@ export function LinkForm ({ item, sub, editThreshold, children }) {
...SubSelectInitial({ sub: item?.subName || sub?.name })
}}
schema={schema}
invoiceable
prepaid
onSubmit={onSubmit}
storageKeyPrefix={storageKeyPrefix}
>

View File

@ -30,12 +30,19 @@ import { nextBillingWithGrace } from '@/lib/territory'
import { commentSubTreeRootId } from '@/lib/item'
import LinkToContext from './link-to-context'
import { Badge } from 'react-bootstrap'
import { Types as ClientTypes, ClientZap, ClientReply, ClientPollVote, ClientBounty, useClientNotifications } from './client-notifications'
import { ITEM_FULL } from '@/fragments/items'
function Notification ({ n, fresh }) {
const type = n.__typename
// we need to resolve item id to item to show item for client notifications
const { data } = useQuery(ITEM_FULL, { variables: { id: n.itemId }, skip: !n.itemId })
const item = data?.item
const itemN = { item, ...n }
return (
<NotificationLayout nid={nid(n)} {...defaultOnClick(n)} fresh={fresh}>
<NotificationLayout nid={nid(n)} {...defaultOnClick(itemN)} fresh={fresh}>
{
(type === 'Earn' && <EarnNotification n={n} />) ||
(type === 'Revenue' && <RevenueNotification n={n} />) ||
@ -53,7 +60,11 @@ function Notification ({ n, fresh }) {
(type === 'FollowActivity' && <FollowActivity n={n} />) ||
(type === 'TerritoryPost' && <TerritoryPost n={n} />) ||
(type === 'TerritoryTransfer' && <TerritoryTransfer n={n} />) ||
(type === 'Reminder' && <Reminder n={n} />)
(type === 'Reminder' && <Reminder n={n} />) ||
([ClientTypes.Zap.ERROR, ClientTypes.Zap.PENDING].includes(type) && <ClientZap n={itemN} />) ||
([ClientTypes.Reply.ERROR, ClientTypes.Reply.PENDING].includes(type) && <ClientReply n={itemN} />) ||
([ClientTypes.Bounty.ERROR, ClientTypes.Bounty.PENDING].includes(type) && <ClientBounty n={itemN} />) ||
([ClientTypes.PollVote.ERROR, ClientTypes.PollVote.PENDING].includes(type) && <ClientPollVote n={itemN} />)
}
</NotificationLayout>
)
@ -102,6 +113,8 @@ const defaultOnClick = n => {
if (type === 'Streak') return {}
if (type === 'TerritoryTransfer') return { href: `/~${n.sub.name}` }
if (!n.item) return {}
// Votification, Mention, JobChanged, Reply all have item
if (!n.item.title) {
const rootId = commentSubTreeRootId(n.item)
@ -534,6 +547,7 @@ export default function Notifications ({ ssrData }) {
const { data, fetchMore } = useQuery(NOTIFICATIONS)
const router = useRouter()
const dat = useData(data, ssrData)
const { notifications: clientNotifications } = useClientNotifications()
const { notifications, lastChecked, cursor } = useMemo(() => {
if (!dat?.notifications) return {}
@ -561,9 +575,12 @@ export default function Notifications ({ ssrData }) {
if (!dat) return <CommentsFlatSkeleton />
const sorted = [...clientNotifications, ...notifications]
.sort((a, b) => new Date(b.sortTime).getTime() - new Date(a.sortTime).getTime())
return (
<>
{notifications.map(n =>
{sorted.map(n =>
<Notification
n={n} key={nid(n)}
fresh={new Date(n.sortTime) > new Date(router?.query?.checkedAt ?? lastChecked)}

View File

@ -6,15 +6,23 @@ import { useMe } from './me'
import { numWithUnits } from '@/lib/format'
import { useShowModal } from './modal'
import { useRoot } from './root'
import { payOrLoginError, useInvoiceModal } from './invoice'
import { useAct } from './item-act'
import { useAct, actUpdate, ACT_MUTATION } from './item-act'
import { InvoiceCanceledError, usePayment } from './payment'
import { optimisticUpdate } from '@/lib/apollo'
import { useLightning } from './lightning'
import { useToast } from './toast'
import { Types as ClientNotification, useClientNotifications } from './client-notifications'
export default function PayBounty ({ children, item }) {
const me = useMe()
const showModal = useShowModal()
const root = useRoot()
const payment = usePayment()
const strike = useLightning()
const toaster = useToast()
const { notify, unnotify } = useClientNotifications()
const onUpdate = useCallback((cache, { data: { act: { id, path } } }) => {
const onUpdate = useCallback(onComplete => (cache, { data: { act: { id, path } } }) => {
// update root bounty status
const root = path.split('.')[0]
cache.modify({
@ -25,30 +33,55 @@ export default function PayBounty ({ children, item }) {
}
}
})
}, [])
strike()
onComplete()
}, [strike])
const [act] = useAct({ onUpdate })
const showInvoiceModal = useInvoiceModal(async ({ hash, hmac }, { variables }) => {
await act({ variables: { ...variables, hash, hmac } })
}, [act])
const act = useAct()
const handlePayBounty = async onComplete => {
const variables = { id: item.id, sats: root.bounty, act: 'TIP', path: item.path }
const sats = root.bounty
const variables = { id: item.id, sats, act: 'TIP', path: item.path }
const notifyProps = { itemId: item.id, sats }
const optimisticResponse = { act: { ...variables, path: item.path } }
let revert, cancel, nid
try {
await act({
revert = optimisticUpdate({
mutation: ACT_MUTATION,
variables,
optimisticResponse,
update: actUpdate({ me, onUpdate: onUpdate(onComplete) })
})
if (me) {
nid = notify(ClientNotification.Bounty.PENDING, notifyProps)
}
let hash, hmac;
[{ hash, hmac }, cancel] = await payment.request(sats)
await act({
variables: { hash, hmac, ...variables },
optimisticResponse: {
act: variables
}
})
onComplete()
} catch (error) {
if (payOrLoginError(error)) {
showInvoiceModal({ amount: root.bounty }, { variables })
revert?.()
if (error instanceof InvoiceCanceledError) {
return
}
throw new Error({ message: error.toString() })
const reason = error?.message || error?.toString?.()
if (me) {
notify(ClientNotification.Bounty.ERROR, { ...notifyProps, reason })
} else {
toaster.danger('pay bounty failed: ' + reason)
}
cancel?.()
} finally {
if (nid) unnotify(nid)
}
}

212
components/payment.js Normal file
View File

@ -0,0 +1,212 @@
import { useCallback } from 'react'
import { useMe } from './me'
import { gql, useApolloClient, useMutation } from '@apollo/client'
import { useWebLN } from './webln'
import { FAST_POLL_INTERVAL, JIT_INVOICE_TIMEOUT_MS } from '@/lib/constants'
import { INVOICE } from '@/fragments/wallet'
import Invoice from '@/components/invoice'
import { useFeeButton } from './fee-button'
import { useShowModal } from './modal'
export class InvoiceCanceledError extends Error {
constructor (hash) {
super(`invoice canceled: ${hash}`)
this.name = 'InvoiceCanceledError'
}
}
export class WebLnNotEnabledError extends Error {
constructor () {
super('no enabled WebLN provider found')
this.name = 'WebLnNotEnabledError'
}
}
export class InvoiceExpiredError extends Error {
constructor (hash) {
super(`invoice expired: ${hash}`)
this.name = 'InvoiceExpiredError'
}
}
const useInvoice = () => {
const client = useApolloClient()
const [createInvoice] = useMutation(gql`
mutation createInvoice($amount: Int!, $expireSecs: Int!) {
createInvoice(amount: $amount, hodlInvoice: true, expireSecs: $expireSecs) {
id
bolt11
hash
hmac
expiresAt
satsRequested
}
}`)
const [cancelInvoice] = useMutation(gql`
mutation cancelInvoice($hash: String!, $hmac: String!) {
cancelInvoice(hash: $hash, hmac: $hmac) {
id
}
}
`)
const create = useCallback(async amount => {
const { data, error } = await createInvoice({ variables: { amount, expireSecs: JIT_INVOICE_TIMEOUT_MS / 1000 } })
if (error) {
throw error
}
const invoice = data.createInvoice
return invoice
}, [createInvoice])
const isPaid = useCallback(async id => {
const { data, error } = await client.query({ query: INVOICE, fetchPolicy: 'no-cache', variables: { id } })
if (error) {
throw error
}
const { hash, isHeld, satsReceived, cancelled } = data.invoice
// if we're polling for invoices, we're using JIT invoices so isHeld must be set
if (isHeld && satsReceived) {
return true
}
if (cancelled) {
throw new InvoiceCanceledError(hash)
}
return false
}, [client])
const waitUntilPaid = useCallback(async id => {
return await new Promise((resolve, reject) => {
const interval = setInterval(async () => {
try {
const paid = await isPaid(id)
if (paid) {
resolve()
clearInterval(interval)
}
} catch (err) {
reject(err)
clearInterval(interval)
}
}, FAST_POLL_INTERVAL)
})
}, [isPaid])
const cancel = useCallback(async ({ hash, hmac }) => {
const inv = await cancelInvoice({ variables: { hash, hmac } })
console.log('invoice canceled:', hash)
return inv
}, [cancelInvoice])
return { create, isPaid, waitUntilPaid, cancel }
}
const useWebLnPayment = () => {
const invoice = useInvoice()
const provider = useWebLN()
const waitForWebLnPayment = useCallback(async ({ id, bolt11 }) => {
if (!provider) {
throw new WebLnNotEnabledError()
}
try {
return await new Promise((resolve, reject) => {
// can't use await here since we might pay JIT invoices and sendPaymentAsync is not supported yet.
// see https://www.webln.guide/building-lightning-apps/webln-reference/webln.sendpaymentasync
provider.sendPayment(bolt11)
// JIT invoice payments will never resolve here
// since they only get resolved after settlement which can't happen here
.then(resolve)
.catch(reject)
invoice.waitUntilPaid(id)
.then(resolve)
.catch(reject)
})
} catch (err) {
console.error('WebLN payment failed:', err)
throw err
}
}, [provider, invoice])
return waitForWebLnPayment
}
const useQrPayment = () => {
const invoice = useInvoice()
const showModal = useShowModal()
const waitForQrPayment = useCallback(async (inv, webLnError) => {
return await new Promise((resolve, reject) => {
let paid
const cancelAndReject = async (onClose) => {
if (paid) return
await invoice.cancel(inv)
reject(new InvoiceCanceledError(inv.hash))
}
showModal(onClose =>
<Invoice
invoice={inv}
modal
successVerb='received'
webLn={false}
webLnError={webLnError}
onPayment={() => { paid = true; onClose(); resolve() }}
poll
/>,
{ keepOpen: true, onClose: cancelAndReject })
})
}, [invoice])
return waitForQrPayment
}
export const usePayment = () => {
const me = useMe()
const feeButton = useFeeButton()
const invoice = useInvoice()
const waitForWebLnPayment = useWebLnPayment()
const waitForQrPayment = useQrPayment()
const waitForPayment = useCallback(async (invoice) => {
let webLnError
try {
return await waitForWebLnPayment(invoice)
} catch (err) {
if (err instanceof InvoiceCanceledError || err instanceof InvoiceExpiredError) {
// bail since qr code payment will also fail
throw err
}
webLnError = err
}
return await waitForQrPayment(invoice, webLnError)
}, [waitForWebLnPayment, waitForQrPayment])
const request = useCallback(async (amount) => {
amount ??= feeButton?.total
const free = feeButton?.free
const balance = me ? me.privates.sats : 0
// if user has enough funds in their custodial wallet or action is free, never prompt for payment
// XXX this will probably not work as intended for deposits < balance
// which means you can't always fund your custodial wallet with attached wallets ...
// but should this even be the case?
const insufficientFunds = balance < amount
if (free || !insufficientFunds) return [{ hash: null, hmac: null }, null]
const inv = await invoice.create(amount)
await waitForPayment(inv)
const cancel = () => invoice.cancel(inv).catch(console.error)
return [inv, cancel]
}, [me, feeButton?.total, invoice, waitForPayment])
const cancel = useCallback(({ hash, hmac }) => {
if (hash && hmac) {
invoice.cancel({ hash, hmac }).catch(console.error)
}
}, [invoice])
return { request, cancel }
}

View File

@ -86,7 +86,7 @@ export function PollForm ({ item, sub, editThreshold, children }) {
...SubSelectInitial({ sub: item?.subName || sub?.name })
}}
schema={schema}
invoiceable
prepaid
onSubmit={onSubmit}
storageKeyPrefix={storageKeyPrefix}
>

View File

@ -8,68 +8,85 @@ import Check from '@/svgs/checkbox-circle-fill.svg'
import { signIn } from 'next-auth/react'
import ActionTooltip from './action-tooltip'
import { POLL_COST } from '@/lib/constants'
import { payOrLoginError, useInvoiceModal } from './invoice'
import { InvoiceCanceledError, usePayment } from './payment'
import { optimisticUpdate } from '@/lib/apollo'
import { useToast } from './toast'
import { Types as ClientNotification, useClientNotifications } from './client-notifications'
export default function Poll ({ item }) {
const me = useMe()
const [pollVote] = useMutation(
gql`
mutation pollVote($id: ID!, $hash: String, $hmac: String) {
pollVote(id: $id, hash: $hash, hmac: $hmac)
}`, {
update (cache, { data: { pollVote } }) {
cache.modify({
id: `Item:${item.id}`,
fields: {
poll (existingPoll) {
const poll = { ...existingPoll }
poll.meVoted = true
poll.count += 1
return poll
}
}
})
cache.modify({
id: `PollOption:${pollVote}`,
fields: {
count (existingCount) {
return existingCount + 1
},
meVoted () {
return true
}
}
})
const POLL_VOTE_MUTATION = gql`
mutation pollVote($id: ID!, $hash: String, $hmac: String) {
pollVote(id: $id, hash: $hash, hmac: $hmac)
}`
const [pollVote] = useMutation(POLL_VOTE_MUTATION)
const toaster = useToast()
const { notify, unnotify } = useClientNotifications()
const update = (cache, { data: { pollVote } }) => {
cache.modify({
id: `Item:${item.id}`,
fields: {
poll (existingPoll) {
const poll = { ...existingPoll }
poll.meVoted = true
poll.count += 1
return poll
}
}
}
)
})
cache.modify({
id: `PollOption:${pollVote}`,
fields: {
count (existingCount) {
return existingCount + 1
},
meVoted () {
return true
}
}
})
}
const PollButton = ({ v }) => {
const showInvoiceModal = useInvoiceModal(async ({ hash, hmac }, { variables }) => {
await pollVote({ variables: { ...variables, hash, hmac } })
}, [pollVote])
const variables = { id: v.id }
const payment = usePayment()
return (
<ActionTooltip placement='left' notForm overlayText='1 sat'>
<Button
variant='outline-info' className={styles.pollButton}
onClick={me
? async () => {
const variables = { id: v.id }
const notifyProps = { itemId: item.id }
const optimisticResponse = { pollVote: v.id }
let revert, cancel, nid
try {
await pollVote({
variables,
optimisticResponse: {
pollVote: v.id
}
})
revert = optimisticUpdate({ mutation: POLL_VOTE_MUTATION, variables, optimisticResponse, update })
if (me) {
nid = notify(ClientNotification.PollVote.PENDING, notifyProps)
}
let hash, hmac;
[{ hash, hmac }, cancel] = await payment.request(item.pollCost || POLL_COST)
await pollVote({ variables: { hash, hmac, ...variables } })
} catch (error) {
if (payOrLoginError(error)) {
showInvoiceModal({ amount: item.pollCost || POLL_COST }, { variables })
revert?.()
if (error instanceof InvoiceCanceledError) {
return
}
throw new Error({ message: error.toString() })
const reason = error?.message || error?.toString?.()
if (me) {
notify(ClientNotification.PollVote.ERROR, { ...notifyProps, reason })
} else {
toaster.danger('poll vote failed: ' + reason)
}
cancel?.()
} finally {
if (nid) unnotify(nid)
}
}
: signIn}

View File

@ -14,7 +14,7 @@ export default function Qr ({ asIs, value, webLn, statusVariant, description, st
async function effect () {
if (webLn && provider) {
try {
await provider.sendPayment({ bolt11: value })
await provider.sendPayment(value)
} catch (e) {
console.log(e?.message)
}

View File

@ -166,7 +166,7 @@ export default forwardRef(function Reply ({ item, onSuccess, replyOpen, children
text: ''
}}
schema={commentSchema}
invoiceable
prepaid
onSubmit={onSubmit}
storageKeyPrefix={`reply-${parentId}`}
>

View File

@ -112,7 +112,7 @@ export default function TerritoryForm ({ sub }) {
nsfw: sub?.nsfw || false
}}
schema={schema}
invoiceable
prepaid
onSubmit={onSubmit}
className='mb-5'
storageKeyPrefix={sub ? undefined : 'territory'}

View File

@ -56,7 +56,7 @@ export default function TerritoryPaymentDue ({ sub }) {
<FeeButtonProvider baseLineItems={{ territory: TERRITORY_BILLING_OPTIONS('one')[sub.billingType.toLowerCase()] }}>
<Form
invoiceable
prepaid
initial={{
name: sub.name
}}

View File

@ -10,21 +10,6 @@ const ToastContext = createContext(() => {})
export const TOAST_DEFAULT_DELAY_MS = 5000
const ensureFlow = (toasts, newToast) => {
const { flowId } = newToast
if (flowId) {
// replace previous toast with same flow id
const idx = toasts.findIndex(toast => toast.flowId === flowId)
if (idx === -1) return [...toasts, newToast]
return [
...toasts.slice(0, idx),
newToast,
...toasts.slice(idx + 1)
]
}
return [...toasts, newToast]
}
const mapHidden = ({ id, tag }) => toast => {
// mark every previous toast with same tag as hidden
if (toast.tag === tag && toast.id !== id) return { ...toast, hidden: true }
@ -36,24 +21,15 @@ export const ToastProvider = ({ children }) => {
const [toasts, setToasts] = useState([])
const toastId = useRef(0)
const removeToast = useCallback(({ id, onCancel, tag }) => {
const removeToast = useCallback(({ id, tag }) => {
setToasts(toasts => toasts.filter(toast => {
if (toast.id === id) {
// remove the toast with the passed id with no exceptions
return false
}
const sameTag = tag && tag === toast.tag
if (!sameTag) {
// don't touch toasts with different tags
return true
}
const toRemoveHasCancel = !!toast.onCancel || !!toast.onUndo
if (toRemoveHasCancel) {
// don't remove this toast so the user can decide to cancel this toast now
return true
}
// remove toasts with same tag if they are not cancelable
return false
// remove toasts with same tag
return !sameTag
}))
}, [setToasts])
@ -63,14 +39,10 @@ export const ToastProvider = ({ children }) => {
createdAt: +new Date(),
id: toastId.current++
}
setToasts(toasts => ensureFlow(toasts, toast).map(mapHidden(toast)))
setToasts(toasts => [...toasts, toast].map(mapHidden(toast)))
return () => removeToast(toast)
}, [setToasts, removeToast])
const endFlow = useCallback((flowId) => {
setToasts(toasts => toasts.filter(toast => toast.flowId !== flowId))
}, [setToasts])
const toaster = useMemo(() => ({
success: (body, options) => {
const toast = {
@ -103,14 +75,13 @@ export const ToastProvider = ({ children }) => {
...options
}
return dispatchToast(toast)
},
endFlow
}), [dispatchToast, removeToast, endFlow])
}
}), [dispatchToast, removeToast])
// Only clear toasts with no cancel function on page navigation
// since navigation should not interfere with being able to cancel an action.
useEffect(() => {
const handleRouteChangeStart = () => setToasts(toasts => toasts.length > 0 ? toasts.filter(({ onCancel, onUndo, persistOnNavigate }) => onCancel || onUndo || persistOnNavigate) : toasts)
const handleRouteChangeStart = () => setToasts(toasts => toasts.length > 0 ? toasts.filter(({ persistOnNavigate }) => persistOnNavigate) : toasts)
router.events.on('routeChangeStart', handleRouteChangeStart)
return () => {
@ -151,16 +122,9 @@ export const ToastProvider = ({ children }) => {
{visibleToasts.map(toast => {
const textStyle = toast.variant === 'warning' ? 'text-dark' : ''
const onClose = () => {
toast.onUndo?.()
toast.onCancel?.()
toast.onClose?.()
removeToast(toast)
}
const buttonElement = toast.onUndo
? <div className={`${styles.toastUndo} ${textStyle}`}>undo</div>
: toast.onCancel
? <div className={`${styles.toastCancel} ${textStyle}`}>cancel</div>
: <div className={`${styles.toastClose} ${textStyle}`}>X</div>
// a toast is unhidden if it was hidden before since it now gets rendered
const unhidden = toast.hidden
// we only need to start the animation at a different timing when it was hidden by another toast before.
@ -181,7 +145,7 @@ export const ToastProvider = ({ children }) => {
className='p-0 ps-2'
aria-label='close'
onClick={onClose}
>{buttonElement}
><div className={`${styles.toastClose} ${textStyle}`}>X</div>
</Button>
</div>
</ToastBody>
@ -196,78 +160,3 @@ export const ToastProvider = ({ children }) => {
}
export const useToast = () => useContext(ToastContext)
export const withToastFlow = (toaster) => flowFn => {
const wrapper = async (...args) => {
const {
flowId,
type: t,
onPending,
pendingMessage,
onSuccess,
onCancel,
onError,
onUndo,
hideError,
hideSuccess,
skipToastFlow,
timeout,
...toastProps
} = flowFn(...args)
let canceled
if (skipToastFlow) return onPending()
toaster.warning(pendingMessage || `${t} pending`, {
progressBar: !!timeout,
delay: timeout || TOAST_DEFAULT_DELAY_MS,
onCancel: onCancel
? async () => {
try {
await onCancel()
canceled = true
toaster.warning(`${t} canceled`, { ...toastProps, flowId })
} catch (err) {
toaster.danger(`failed to cancel ${t}`, { ...toastProps, flowId })
}
}
: undefined,
onUndo: onUndo
? async () => {
try {
await onUndo()
canceled = true
} catch (err) {
toaster.danger(`failed to undo ${t}`, { ...toastProps, flowId })
}
}
: undefined,
flowId,
...toastProps
})
try {
const ret = await onPending()
if (!canceled) {
if (hideSuccess) {
toaster.endFlow(flowId)
} else {
toaster.success(`${t} successful`, { ...toastProps, flowId })
}
await onSuccess?.()
}
return ret
} catch (err) {
// ignore errors if canceled since they might be caused by cancellation
if (canceled) return
const reason = err?.message?.toString().toLowerCase() || 'unknown reason'
if (hideError) {
toaster.endFlow(flowId)
} else {
toaster.danger(`${t} failed: ${reason}`, { ...toastProps, flowId })
}
await onError?.()
throw err
}
}
return wrapper
}

View File

@ -21,20 +21,6 @@
border-color: var(--bs-warning-border-subtle);
}
.toastUndo {
font-style: normal;
cursor: pointer;
display: flex;
align-items: center;
}
.toastCancel {
font-style: italic;
cursor: pointer;
display: flex;
align-items: center;
}
.toastClose {
color: #fff;
font-family: "lightning";

View File

@ -2,7 +2,7 @@ import UpBolt from '@/svgs/bolt.svg'
import styles from './upvote.module.css'
import { gql, useMutation } from '@apollo/client'
import ActionTooltip from './action-tooltip'
import ItemAct, { useAct, useZap } from './item-act'
import ItemAct, { ZapUndoController, useZap } from './item-act'
import { useMe } from './me'
import getColor from '@/lib/rainbow'
import { useCallback, useMemo, useRef, useState } from 'react'
@ -59,7 +59,7 @@ export function DropdownItemUpVote ({ item }) {
<Dropdown.Item
onClick={async () => {
showModal(onClose =>
<ItemAct onClose={onClose} itemId={item.id} />)
<ItemAct onClose={onClose} item={item} />)
}}
>
<span className='text-success'>zap</span>
@ -97,6 +97,9 @@ export default function UpVote ({ item, className }) {
}`
)
const [controller, setController] = useState(null)
const pending = controller?.started && !controller.done
const setVoteShow = useCallback((yes) => {
if (!me) return
@ -125,7 +128,6 @@ export default function UpVote ({ item, className }) {
}
}, [me, tipShow, setWalkthrough])
const [act] = useAct()
const zap = useZap()
const disabled = useMemo(() => item?.mine || item?.meForward || item?.deletedAt,
@ -155,11 +157,19 @@ export default function UpVote ({ item, className }) {
}
setTipShow(false)
if (pending) {
controller.abort()
return
}
const c = new ZapUndoController()
setController(c)
showModal(onClose =>
<ItemAct onClose={onClose} itemId={item.id} />, { onClose: handleModalClosed })
<ItemAct onClose={onClose} item={item} abortSignal={c.signal} />, { onClose: handleModalClosed })
}
const handleShortPress = () => {
const handleShortPress = async () => {
if (me) {
if (!item) return
@ -174,9 +184,16 @@ export default function UpVote ({ item, className }) {
setTipShow(true)
}
zap({ item, me })
if (pending) {
controller.abort()
return
}
const c = new ZapUndoController()
setController(c)
await zap({ item, me, abortSignal: c.signal })
} else {
showModal(onClose => <ItemAct onClose={onClose} itemId={item.id} act={act} />, { onClose: handleModalClosed })
showModal(onClose => <ItemAct onClose={onClose} item={item} />, { onClose: handleModalClosed })
}
}
@ -202,7 +219,8 @@ export default function UpVote ({ item, className }) {
`${styles.upvote}
${className || ''}
${disabled ? styles.noSelfTips : ''}
${meSats ? styles.voted : ''}`
${meSats ? styles.voted : ''}
${pending ? styles.pending : ''}`
}
style={meSats || hover
? {

View File

@ -34,4 +34,18 @@
position: absolute;
left: 4px;
width: 17px;
}
}
.pending {
animation-name: pulse;
animation-iteration-count: infinite;
animation-timing-function: linear;
animation-duration: 0.25s;
animation-direction: alternate;
}
@keyframes pulse {
0% {
fill: #a5a5a5;
}
}

View File

@ -1,8 +1,6 @@
import { createContext, useCallback, useContext, useEffect, useState } from 'react'
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
import { LNbitsProvider, useLNbits } from './lnbits'
import { NWCProvider, useNWC } from './nwc'
import { useToast, withToastFlow } from '@/components/toast'
import { gql, useMutation } from '@apollo/client'
import { LNCProvider, useLNC } from './lnc'
const WebLNContext = createContext({})
@ -86,31 +84,6 @@ function RawWebLNProvider ({ children }) {
// TODO: implement fallbacks via provider priority
const provider = enabledProviders[0]
const toaster = useToast()
const [cancelInvoice] = useMutation(gql`
mutation cancelInvoice($hash: String!, $hmac: String!) {
cancelInvoice(hash: $hash, hmac: $hmac) {
id
}
}
`)
const sendPaymentWithToast = withToastFlow(toaster)(
({ bolt11, hash, hmac, expiresAt, flowId }) => {
const expiresIn = (+new Date(expiresAt)) - (+new Date())
return {
flowId: flowId || hash,
type: 'payment',
onPending: async () => {
await provider.sendPayment(bolt11)
},
// hash and hmac are only passed for JIT invoices
onCancel: () => hash && hmac ? cancelInvoice({ variables: { hash, hmac } }) : undefined,
timeout: expiresIn
}
}
)
const setProvider = useCallback((defaultProvider) => {
// move provider to the start to set it as default
setEnabledProviders(providers => {
@ -129,8 +102,17 @@ function RawWebLNProvider ({ children }) {
await lnc.clearConfig()
}, [])
const value = useMemo(() => ({
provider: isEnabled(provider)
? { name: provider.name, sendPayment: provider.sendPayment }
: null,
enabledProviders,
setProvider,
clearConfig
}), [provider, enabledProviders, setProvider])
return (
<WebLNContext.Provider value={{ provider: isEnabled(provider) ? { name: provider.name, sendPayment: sendPaymentWithToast } : null, enabledProviders, setProvider, clearConfig }}>
<WebLNContext.Provider value={value}>
{children}
</WebLNContext.Provider>
)

View File

@ -9,6 +9,7 @@ import CancelButton from '../cancel-button'
import { Mutex } from 'async-mutex'
import { Wallet } from '@/lib/constants'
import { useMe } from '../me'
import { InvoiceCanceledError, InvoiceExpiredError } from '@/components/payment'
const LNCContext = createContext()
const mutex = new Mutex()
@ -109,7 +110,14 @@ export function LNCProvider ({ children }) {
logger.ok('payment successful:', `payment_hash=${hash}`, `preimage=${preimage}`)
return { preimage }
} catch (err) {
logger.error('payment failed:', `payment_hash=${hash}`, err.message || err.toString?.())
const msg = err.message || err.toString?.()
logger.error('payment failed:', `payment_hash=${hash}`, msg)
if (msg.includes('invoice expired')) {
throw new InvoiceExpiredError(hash)
}
if (msg.includes('canceled')) {
throw new InvoiceCanceledError(hash)
}
throw err
} finally {
try {

View File

@ -6,8 +6,9 @@ import { parseNwcUrl } from '@/lib/url'
import { useWalletLogger } from '../logger'
import { Status, migrateLocalStorage } from '.'
import { bolt11Tags } from '@/lib/bolt11'
import { Wallet } from '@/lib/constants'
import { JIT_INVOICE_TIMEOUT_MS, Wallet } from '@/lib/constants'
import { useMe } from '../me'
import { InvoiceExpiredError } from '../payment'
const NWCContext = createContext()
@ -205,11 +206,11 @@ export function NWCProvider ({ children }) {
(async function () {
// timeout since NWC is async (user needs to confirm payment in wallet)
// timeout is same as invoice expiry
const timeout = 180_000
const timeout = JIT_INVOICE_TIMEOUT_MS
const timer = setTimeout(() => {
const msg = 'timeout waiting for info event'
const msg = 'timeout waiting for payment'
logger.error(msg)
reject(new Error(msg))
reject(new InvoiceExpiredError(hash))
sub?.close()
}, timeout)

View File

@ -255,3 +255,17 @@ function getClient (uri) {
}
})
}
export function optimisticUpdate ({ mutation, variables, optimisticResponse, update }) {
const { cache, queryManager } = getApolloClient()
const mutationId = String(queryManager.mutationIdCounter++)
queryManager.markMutationOptimistic(optimisticResponse, {
mutationId,
document: mutation,
variables,
update
})
return () => cache.removeOptimistic(mutationId)
}

View File

@ -126,6 +126,7 @@ export const ITEM_ALLOW_EDITS = [
]
export const INVOICE_RETENTION_DAYS = 7
export const JIT_INVOICE_TIMEOUT_MS = 180_000
export const FAST_POLL_INTERVAL = Number(process.env.NEXT_PUBLIC_FAST_POLL_INTERVAL)
export const NORMAL_POLL_INTERVAL = Number(process.env.NEXT_PUBLIC_NORMAL_POLL_INTERVAL)
@ -148,3 +149,5 @@ export const getWalletBy = (key, value) => {
}
throw new Error(`wallet not found: ${key}=${value}`)
}
export const ZAP_UNDO_DELAY_MS = 5_000

View File

@ -21,6 +21,7 @@ import { ChainFeeProvider } from '@/components/chain-fee.js'
import { WebLNProvider } from '@/components/webln'
import dynamic from 'next/dynamic'
import { HasNewNotesProvider } from '@/components/use-has-new-notes'
import { ClientNotificationProvider } from '@/components/client-notifications'
const PWAPrompt = dynamic(() => import('react-ios-pwa-prompt'), { ssr: false })
@ -104,28 +105,30 @@ export default function MyApp ({ Component, pageProps: { ...props } }) {
<ApolloProvider client={client}>
<MeProvider me={me}>
<HasNewNotesProvider>
<LoggerProvider>
<ServiceWorkerProvider>
<PriceProvider price={price}>
<LightningProvider>
<ToastProvider>
<WebLNProvider>
<ShowModalProvider>
<BlockHeightProvider blockHeight={blockHeight}>
<ChainFeeProvider chainFee={chainFee}>
<ErrorBoundary>
<Component ssrData={ssrData} {...otherProps} />
{!router?.query?.disablePrompt && <PWAPrompt copyBody='This website has app functionality. Add it to your home screen to use it in fullscreen and receive notifications. In Safari:' promptOnVisit={2} />}
</ErrorBoundary>
</ChainFeeProvider>
</BlockHeightProvider>
</ShowModalProvider>
</WebLNProvider>
</ToastProvider>
</LightningProvider>
</PriceProvider>
</ServiceWorkerProvider>
</LoggerProvider>
<ClientNotificationProvider>
<LoggerProvider>
<ServiceWorkerProvider>
<PriceProvider price={price}>
<LightningProvider>
<ToastProvider>
<WebLNProvider>
<ShowModalProvider>
<BlockHeightProvider blockHeight={blockHeight}>
<ChainFeeProvider chainFee={chainFee}>
<ErrorBoundary>
<Component ssrData={ssrData} {...otherProps} />
{!router?.query?.disablePrompt && <PWAPrompt copyBody='This website has app functionality. Add it to your home screen to use it in fullscreen and receive notifications. In Safari:' promptOnVisit={2} />}
</ErrorBoundary>
</ChainFeeProvider>
</BlockHeightProvider>
</ShowModalProvider>
</WebLNProvider>
</ToastProvider>
</LightningProvider>
</PriceProvider>
</ServiceWorkerProvider>
</LoggerProvider>
</ClientNotificationProvider>
</HasNewNotesProvider>
</MeProvider>
</ApolloProvider>

View File

@ -1,5 +1,5 @@
import { useQuery } from '@apollo/client'
import { Invoice } from '@/components/invoice'
import Invoice from '@/components/invoice'
import { QrSkeleton } from '@/components/qr'
import { CenterLayout } from '@/components/layout'
import { useRouter } from 'next/router'
@ -10,6 +10,7 @@ import { getGetServerSideProps } from '@/api/ssrApollo'
// force SSR to include CSP nonces
export const getServerSideProps = getGetServerSideProps({ query: null })
// TODO: we can probably replace this component with <Invoice poll>
export default function FullInvoice () {
const router = useRouter()
const { data, error } = useQuery(INVOICE, SSR

View File

@ -174,7 +174,7 @@ export function DonateButton () {
amount: 10000
}}
schema={amountSchema}
invoiceable
prepaid
onSubmit={async ({ amount, hash, hmac }) => {
const { error } = await donateToRewards({
variables: {

View File

@ -26,7 +26,7 @@ import { NostrAuth } from '@/components/nostr-auth'
import { useToast } from '@/components/toast'
import { useServiceWorkerLogger } from '@/components/logger'
import { useMe } from '@/components/me'
import { INVOICE_RETENTION_DAYS } from '@/lib/constants'
import { INVOICE_RETENTION_DAYS, ZAP_UNDO_DELAY_MS } from '@/lib/constants'
import { OverlayTrigger, Tooltip } from 'react-bootstrap'
import DeleteIcon from '@/svgs/delete-bin-line.svg'
import { useField } from 'formik'
@ -1007,7 +1007,7 @@ const ZapUndosField = () => {
<Info>
<ul className='fw-bold'>
<li>An undo button is shown after every zap that exceeds or is equal to the threshold</li>
<li>The button is shown for 5 seconds</li>
<li>The button is shown for {ZAP_UNDO_DELAY_MS / 1000} seconds</li>
<li>The button is only shown for zaps from the custodial wallet</li>
<li>Use a budget or manual approval with attached wallets</li>
</ul>