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') WHERE act IN ('TIP', 'FEE')
AND "itemId" = ${Number(id)}::INTEGER AND "itemId" = ${Number(id)}::INTEGER
AND "userId" = ${me.id}::INTEGER)::INTEGER)`, AND "userId" = ${me.id}::INTEGER)::INTEGER)`,
{ models } { models, lnd, hash, hmac }
) )
} else { } else {
await serialize( await serialize(

View File

@ -107,7 +107,8 @@ export function BountyForm ({
...SubSelectInitial({ sub: item?.subName || sub?.name }) ...SubSelectInitial({ sub: item?.subName || sub?.name })
}} }}
schema={schema} schema={schema}
invoiceable={{ requireSession: true }} requireSession
prepaid
onSubmit={ onSubmit={
handleSubmit || handleSubmit ||
onSubmit 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 {item.outlawed && !me?.privates?.wildWestMode
? <Skull className={styles.dontLike} width={24} height={24} /> ? <Skull className={styles.dontLike} width={24} height={24} />
: item.meDontLikeSats > item.meSats : 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} />} : pin ? <Pin width={22} height={22} className={styles.pin} /> : <UpVote item={item} className={styles.upvote} />}
<div className={`${itemStyles.hunk} ${styles.hunk}`}> <div className={`${itemStyles.hunk} ${styles.hunk}`}>
<div className='d-flex align-items-center'> <div className='d-flex align-items-center'>

View File

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

View File

@ -1,15 +1,15 @@
import Dropdown from 'react-bootstrap/Dropdown' import Dropdown from 'react-bootstrap/Dropdown'
import { useShowModal } from './modal' import { useShowModal } from './modal'
import { useToast } from './toast' import { useToast } from './toast'
import ItemAct, { zapUndosThresholdReached } from './item-act' import ItemAct from './item-act'
import AccordianItem from './accordian-item' import AccordianItem from './accordian-item'
import Flag from '@/svgs/flag-fill.svg' import Flag from '@/svgs/flag-fill.svg'
import { useMemo } from 'react' import { useMemo } from 'react'
import getColor from '@/lib/rainbow' import getColor from '@/lib/rainbow'
import { gql, useMutation } from '@apollo/client' 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 const style = useMemo(() => (meDontLikeSats
? { ? {
fill: getColor(meDontLikeSats), fill: getColor(meDontLikeSats),
@ -17,14 +17,13 @@ export function DownZap ({ id, meDontLikeSats, ...props }) {
} }
: undefined), [meDontLikeSats]) : undefined), [meDontLikeSats])
return ( 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 toaster = useToast()
const showModal = useShowModal() const showModal = useShowModal()
const me = useMe()
return ( return (
<As <As
@ -32,12 +31,7 @@ function DownZapper ({ id, As, children }) {
try { try {
showModal(onClose => showModal(onClose =>
<ItemAct <ItemAct
onClose={(amount) => { onClose={onClose} item={item} down
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
> >
<AccordianItem <AccordianItem
header='what is a downzap?' body={ 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 ( return (
<DownZapper <DownZapper
As={Dropdown.Item} As={Dropdown.Item}
id={id} item={item}
> >
<span className='text-danger'>downzap</span> <span className='text-danger'>downzap</span>
</DownZapper> </DownZapper>

View File

@ -64,6 +64,7 @@ export function postCommentUseRemoteLineItems ({ parentId } = {}) {
export function FeeButtonProvider ({ baseLineItems = {}, useRemoteLineItems = () => null, children }) { export function FeeButtonProvider ({ baseLineItems = {}, useRemoteLineItems = () => null, children }) {
const [lineItems, setLineItems] = useState({}) const [lineItems, setLineItems] = useState({})
const [disabled, setDisabled] = useState(false) const [disabled, setDisabled] = useState(false)
const me = useMe()
const remoteLineItems = useRemoteLineItems() const remoteLineItems = useRemoteLineItems()
@ -76,14 +77,18 @@ export function FeeButtonProvider ({ baseLineItems = {}, useRemoteLineItems = ()
const value = useMemo(() => { const value = useMemo(() => {
const lines = { ...baseLineItems, ...lineItems, ...remoteLineItems } 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 { return {
lines, lines,
merge: mergeLineItems, merge: mergeLineItems,
total: Object.values(lines).reduce((acc, { modifier }) => modifier(acc), 0), total,
disabled, disabled,
setDisabled setDisabled,
free
} }
}, [baseLineItems, lineItems, remoteLineItems, mergeLineItems, disabled, setDisabled]) }, [me?.privates?.sats, baseLineItems, lineItems, remoteLineItems, mergeLineItems, disabled, setDisabled])
return ( return (
<FeeButtonContext.Provider value={value}> <FeeButtonContext.Provider value={value}>
@ -111,9 +116,7 @@ function FreebieDialog () {
export default function FeeButton ({ ChildButton = SubmitButton, variant, text, disabled }) { export default function FeeButton ({ ChildButton = SubmitButton, variant, text, disabled }) {
const me = useMe() const me = useMe()
const { lines, total, disabled: ctxDisabled } = useFeeButton() const { lines, total, disabled: ctxDisabled, free } = 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 feeText = free const feeText = free
? 'free' ? 'free'
: total > 1 : total > 1

View File

@ -18,7 +18,6 @@ import { gql, useLazyQuery } from '@apollo/client'
import { USER_SUGGESTIONS } from '@/fragments/users' import { USER_SUGGESTIONS } from '@/fragments/users'
import TextareaAutosize from 'react-textarea-autosize' import TextareaAutosize from 'react-textarea-autosize'
import { useToast } from './toast' import { useToast } from './toast'
import { useInvoiceable } from './invoice'
import { numWithUnits } from '@/lib/format' import { numWithUnits } from '@/lib/format'
import textAreaCaret from 'textarea-caret' import textAreaCaret from 'textarea-caret'
import ReactDatePicker from 'react-datepicker' 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 Eye from '@/svgs/eye-fill.svg'
import EyeClose from '@/svgs/eye-close-line.svg' import EyeClose from '@/svgs/eye-close-line.svg'
import Info from './info' 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 ({ export function SubmitButton ({
children, variant, value, onClick, disabled, nonDisabledText, ...props children, variant, value, onClick, disabled, nonDisabledText, ...props
@ -793,11 +804,16 @@ const StorageKeyPrefixContext = createContext()
export function Form ({ export function Form ({
initial, schema, onSubmit, children, initialError, validateImmediately, 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 toaster = useToast()
const initialErrorToasted = useRef(false) const initialErrorToasted = useRef(false)
const feeButton = useFeeButton() const feeButton = useFeeButton()
const payment = usePayment()
const me = useMe()
const { notify, unnotify } = useClientNotifications()
useEffect(() => { useEffect(() => {
if (initialError && !initialErrorToasted.current) { if (initialError && !initialErrorToasted.current) {
toaster.danger('form error: ' + initialError.message || initialError.toString?.()) toaster.danger('form error: ' + initialError.message || initialError.toString?.())
@ -820,37 +836,57 @@ export function Form ({
}) })
}, [storageKeyPrefix]) }, [storageKeyPrefix])
// if `invoiceable` is set, const onSubmitInner = useCallback(async ({ amount, ...values }, ...args) => {
// support for payment per invoice if they are lurking or don't have enough balance const variables = { amount, ...values }
// is added to submit handlers. let revert, cancel, nid
// 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) => {
try { try {
if (onSubmit) { if (onSubmit) {
// extract cost from formik fields if (requireSession && !me) {
// (cost may also be set in a formik field named 'amount') throw new SessionRequiredError()
const cost = feeButton?.total || values?.amount
if (cost) {
values.cost = cost
} }
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 if (!storageKeyPrefix) return
clearLocalStorage(values) clearLocalStorage(values)
} }
} catch (err) { } catch (err) {
const msg = err.message || err.toString?.() revert?.()
// ignore errors from JIT invoices or payments from attached wallets
// that mean that submit failed because user aborted the payment if (err instanceof InvoiceCanceledError || err instanceof ActCanceledError) {
if (msg === 'modal closed' || msg === 'invoice canceled') return return
toaster.danger('submit error: ' + msg)
} }
}, [onSubmit, feeButton?.total, toaster, clearLocalStorage, storageKeyPrefix])
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)
}
}, [me, onSubmit, feeButton?.total, toaster, clearLocalStorage, storageKeyPrefix, payment, signal])
return ( return (
<Formik <Formik

View File

@ -1,23 +1,35 @@
import { useState, useCallback, useEffect } from 'react' import { useState, 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 { numWithUnits } from '@/lib/format'
import AccordianItem from './accordian-item' import AccordianItem from './accordian-item'
import Qr, { QrSkeleton } from './qr' import Qr 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 Countdown from './countdown'
import PayerData from './payer-data' import PayerData from './payer-data'
import Bolt11Info from './bolt11-info' import Bolt11Info from './bolt11-info'
import { useWebLN } from './webln' import { useQuery } from '@apollo/client'
import { FAST_POLL_INTERVAL } from '@/lib/constants' 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 [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 was not passed, use true by default
if (webLn === undefined) webLn = true if (webLn === undefined) webLn = true
@ -48,6 +60,11 @@ export function Invoice ({ invoice, modal, onPayment, info, successVerb, webLn }
return ( return (
<> <>
{webLnError && !(webLnError instanceof WebLnNotEnabledError) &&
<div className='text-center text-danger mb-3'>
Payment from attached wallet failed:
<div>{webLnError.toString()}</div>
</div>}
<Qr <Qr
webLn={webLn} value={invoice.bolt11} webLn={webLn} value={invoice.bolt11}
description={numWithUnits(invoice.satsRequested, { abbreviate: false })} 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 { useMe } from './me'
import UpBolt from '@/svgs/bolt.svg' import UpBolt from '@/svgs/bolt.svg'
import { amountSchema } from '@/lib/validate' import { amountSchema } from '@/lib/validate'
import { gql, useApolloClient, useMutation } from '@apollo/client' import { gql, useMutation } from '@apollo/client'
import { payOrLoginError, useInvoiceModal } from './invoice' import { useToast } from './toast'
import { TOAST_DEFAULT_DELAY_MS, useToast, withToastFlow } from './toast'
import { useLightning } from './lightning' import { useLightning } from './lightning'
import { nextTip } from './upvote' 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 = ({ setOValue }) => {
const tips = [...getCustomTips(), ...defaultTips].sort((a, b) => a - b) const tips = [...getCustomTips(), ...defaultTips].sort((a, b) => a - b)
@ -41,159 +44,13 @@ const addCustomTip = (amount) => {
window.localStorage.setItem('custom-tips', JSON.stringify(customTips)) window.localStorage.setItem('custom-tips', JSON.stringify(customTips))
} }
export const zapUndosThresholdReached = (me, amount) => { const setItemMeAnonSats = ({ id, amount }) => {
if (!me) return false const storageKey = `TIP-item:${id}`
const enabled = me.privates.zapUndos !== null
return enabled ? amount >= me.privates.zapUndos : false
}
export default function ItemAct ({ onClose, itemId, down, children }) {
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])
const [act, actUpdate] = 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') const existingAmount = Number(window.localStorage.getItem(storageKey) || '0')
window.localStorage.setItem(storageKey, existingAmount + amount) window.localStorage.setItem(storageKey, existingAmount + amount)
}
await act({
variables: {
id: itemId,
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()
addCustomTip(Number(amount))
if (!keepOpen) onClose(Number(amount))
}, [me, act, down, itemId, 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
}
}
)
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>
)
} }
export function useAct ({ onUpdate } = {}) { export const actUpdate = ({ me, onUpdate }) => (cache, args) => {
const me = useMe()
const update = useCallback((cache, args) => {
const { data: { act: { id, sats, path, act } } } = args const { data: { act: { id, sats, path, act } } } = args
cache.modify({ cache.modify({
@ -206,15 +63,13 @@ export function useAct ({ onUpdate } = {}) {
return existingSats return existingSats
}, },
meSats: me meSats: (existingSats = 0) => {
? (existingSats = 0) => {
if (act === 'TIP') { if (act === 'TIP') {
return existingSats + sats return existingSats + sats
} }
return existingSats return existingSats
} },
: undefined,
meDontLikeSats: me meDontLikeSats: me
? (existingSats = 0) => { ? (existingSats = 0) => {
if (act === 'DONT_LIKE_THIS') { if (act === 'DONT_LIKE_THIS') {
@ -240,13 +95,86 @@ export function useAct ({ onUpdate } = {}) {
} }
}) })
}) })
onUpdate && onUpdate(cache, args)
} }
}, [!!me, onUpdate])
const [act] = useMutation( onUpdate?.(cache, args)
gql` }
export default function ItemAct ({ onClose, item, down, children, abortSignal }) {
const inputRef = useRef(null)
const me = useMe()
const [oValue, setOValue] = useState()
const strike = useLightning()
useEffect(() => {
inputRef.current?.focus()
}, [onClose, item.id])
const act = useAct()
const onSubmit = useCallback(async ({ amount, hash, hmac }) => {
await act({
variables: {
id: item.id,
sats: Number(amount),
act: down ? 'DONT_LIKE_THIS' : 'TIP',
hash,
hmac
}
})
if (!me) setItemMeAnonSats({ id: item.id, amount })
addCustomTip(Number(amount))
}, [me, act, down, item.id, strike])
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 (
<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 const ACT_MUTATION = gql`
mutation act($id: ID!, $sats: Int!, $act: String, $hash: String, $hmac: String) { mutation act($id: ID!, $sats: Int!, $act: String, $hash: String, $hmac: String) {
act(id: $id, sats: $sats, act: $act, hash: $hash, hmac: $hmac) { act(id: $id, sats: $sats, act: $act, hash: $hash, hmac: $hmac) {
id id
@ -254,9 +182,11 @@ export function useAct ({ onUpdate } = {}) {
path path
act act
} }
}`, { update } }`
)
return [act, update] export function useAct ({ onUpdate } = {}) {
const [act] = useMutation(ACT_MUTATION)
return act
} }
export function useZap () { export function useZap () {
@ -307,118 +237,107 @@ export function useZap () {
} }
}, []) }, [])
const [zap] = useMutation( const ZAP_MUTATION = gql`
gql` mutation idempotentAct($id: ID!, $sats: Int!, $hash: String, $hmac: String) {
mutation idempotentAct($id: ID!, $sats: Int!) { act(id: $id, sats: $sats, hash: $hash, hmac: $hmac, idempotent: true) {
act(id: $id, sats: $sats, idempotent: true) {
id id
sats sats
path path
} }
}` }`
) const [zap] = useMutation(ZAP_MUTATION)
const me = useMe()
const { notify, unnotify } = useClientNotifications()
const toaster = useToast() const toaster = useToast()
const strike = useLightning() const strike = useLightning()
const [act] = useAct() const payment = usePayment()
const client = useApolloClient()
const invoiceableAct = useInvoiceModal( return useCallback(async ({ item, mem, abortSignal }) => {
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 }) => {
const meSats = (item?.meSats || 0) const meSats = (item?.meSats || 0)
// add current sats to next tip since idempotent zaps use desired total zap not difference // add current sats to next tip since idempotent zaps use desired total zap not difference
const sats = meSats + nextTip(meSats, { ...me?.privates }) 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 variables = { id: item.id, sats, act: 'TIP' }
const insufficientFunds = me?.privates.sats < amount const notifyProps = { itemId: item.id, sats: satsDelta }
const optimisticResponse = { act: { path: item.path, ...variables } } 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 { try {
if (insufficientFunds) throw new Error('insufficient funds') revert = optimisticUpdate({ mutation: ZAP_MUTATION, variables, optimisticResponse, update })
strike() strike()
if (zapUndosThresholdReached(me, amount)) {
await zapWithUndos(zapArgs) await abortSignal.pause({ me, amount: satsDelta })
} else {
await zap(zapArgs) 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) { } catch (error) {
if (payOrLoginError(error)) { revert?.()
// call non-idempotent version
const amount = sats - meSats if (error instanceof InvoiceCanceledError || error instanceof ActCanceledError) {
optimisticResponse.act.amount = amount
try {
await invoiceableAct({ amount }, {
variables: { ...variables, sats: amount },
optimisticResponse,
update,
flowId
})
} catch (error) {}
return 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.mine && !item.deletedAt &&
(item.meDontLikeSats > meTotalSats (item.meDontLikeSats > meTotalSats
? <DropdownItemUpVote item={item} /> ? <DropdownItemUpVote item={item} />
: <DontLikeThisDropdownItem id={item.id} />)} : <DontLikeThisDropdownItem item={item} />)}
{me && sub && !item.mine && !item.outlawed && Number(me.id) === Number(sub.userId) && sub.moderated && {me && sub && !item.mine && !item.outlawed && Number(me.id) === Number(sub.userId) && sub.moderated &&
<> <>
<hr className='dropdown-divider' /> <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) {item.position && (pinnable || !item.subName)
? <Pin width={24} height={24} className={styles.pin} /> ? <Pin width={24} height={24} className={styles.pin} />
: item.meDontLikeSats > item.meSats : 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 : Number(item.user?.id) === AD_USER_ID
? <AdIcon width={24} height={24} className={styles.ad} /> ? <AdIcon width={24} height={24} className={styles.ad} />
: <UpVote item={item} className={styles.upvote} />} : <UpVote item={item} className={styles.upvote} />}

View File

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

View File

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

View File

@ -30,12 +30,19 @@ import { nextBillingWithGrace } from '@/lib/territory'
import { commentSubTreeRootId } from '@/lib/item' import { commentSubTreeRootId } from '@/lib/item'
import LinkToContext from './link-to-context' import LinkToContext from './link-to-context'
import { Badge } from 'react-bootstrap' 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 }) { function Notification ({ n, fresh }) {
const type = n.__typename 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 ( return (
<NotificationLayout nid={nid(n)} {...defaultOnClick(n)} fresh={fresh}> <NotificationLayout nid={nid(n)} {...defaultOnClick(itemN)} fresh={fresh}>
{ {
(type === 'Earn' && <EarnNotification n={n} />) || (type === 'Earn' && <EarnNotification n={n} />) ||
(type === 'Revenue' && <RevenueNotification n={n} />) || (type === 'Revenue' && <RevenueNotification n={n} />) ||
@ -53,7 +60,11 @@ function Notification ({ n, fresh }) {
(type === 'FollowActivity' && <FollowActivity n={n} />) || (type === 'FollowActivity' && <FollowActivity n={n} />) ||
(type === 'TerritoryPost' && <TerritoryPost n={n} />) || (type === 'TerritoryPost' && <TerritoryPost n={n} />) ||
(type === 'TerritoryTransfer' && <TerritoryTransfer 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> </NotificationLayout>
) )
@ -102,6 +113,8 @@ const defaultOnClick = n => {
if (type === 'Streak') return {} if (type === 'Streak') return {}
if (type === 'TerritoryTransfer') return { href: `/~${n.sub.name}` } if (type === 'TerritoryTransfer') return { href: `/~${n.sub.name}` }
if (!n.item) return {}
// Votification, Mention, JobChanged, Reply all have item // Votification, Mention, JobChanged, Reply all have item
if (!n.item.title) { if (!n.item.title) {
const rootId = commentSubTreeRootId(n.item) const rootId = commentSubTreeRootId(n.item)
@ -534,6 +547,7 @@ export default function Notifications ({ ssrData }) {
const { data, fetchMore } = useQuery(NOTIFICATIONS) const { data, fetchMore } = useQuery(NOTIFICATIONS)
const router = useRouter() const router = useRouter()
const dat = useData(data, ssrData) const dat = useData(data, ssrData)
const { notifications: clientNotifications } = useClientNotifications()
const { notifications, lastChecked, cursor } = useMemo(() => { const { notifications, lastChecked, cursor } = useMemo(() => {
if (!dat?.notifications) return {} if (!dat?.notifications) return {}
@ -561,9 +575,12 @@ export default function Notifications ({ ssrData }) {
if (!dat) return <CommentsFlatSkeleton /> if (!dat) return <CommentsFlatSkeleton />
const sorted = [...clientNotifications, ...notifications]
.sort((a, b) => new Date(b.sortTime).getTime() - new Date(a.sortTime).getTime())
return ( return (
<> <>
{notifications.map(n => {sorted.map(n =>
<Notification <Notification
n={n} key={nid(n)} n={n} key={nid(n)}
fresh={new Date(n.sortTime) > new Date(router?.query?.checkedAt ?? lastChecked)} 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 { numWithUnits } from '@/lib/format'
import { useShowModal } from './modal' import { useShowModal } from './modal'
import { useRoot } from './root' import { useRoot } from './root'
import { payOrLoginError, useInvoiceModal } from './invoice' import { useAct, actUpdate, ACT_MUTATION } from './item-act'
import { useAct } 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 }) { export default function PayBounty ({ children, item }) {
const me = useMe() const me = useMe()
const showModal = useShowModal() const showModal = useShowModal()
const root = useRoot() 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 // update root bounty status
const root = path.split('.')[0] const root = path.split('.')[0]
cache.modify({ cache.modify({
@ -25,30 +33,55 @@ export default function PayBounty ({ children, item }) {
} }
} }
}) })
}, []) strike()
onComplete()
}, [strike])
const [act] = useAct({ onUpdate }) const act = useAct()
const showInvoiceModal = useInvoiceModal(async ({ hash, hmac }, { variables }) => {
await act({ variables: { ...variables, hash, hmac } })
}, [act])
const handlePayBounty = async onComplete => { 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 { try {
await act({ revert = optimisticUpdate({
mutation: ACT_MUTATION,
variables, 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: { optimisticResponse: {
act: variables act: variables
} }
}) })
onComplete()
} catch (error) { } catch (error) {
if (payOrLoginError(error)) { revert?.()
showInvoiceModal({ amount: root.bounty }, { variables })
if (error instanceof InvoiceCanceledError) {
return 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 }) ...SubSelectInitial({ sub: item?.subName || sub?.name })
}} }}
schema={schema} schema={schema}
invoiceable prepaid
onSubmit={onSubmit} onSubmit={onSubmit}
storageKeyPrefix={storageKeyPrefix} storageKeyPrefix={storageKeyPrefix}
> >

View File

@ -8,16 +8,22 @@ import Check from '@/svgs/checkbox-circle-fill.svg'
import { signIn } from 'next-auth/react' import { signIn } from 'next-auth/react'
import ActionTooltip from './action-tooltip' import ActionTooltip from './action-tooltip'
import { POLL_COST } from '@/lib/constants' 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 }) { export default function Poll ({ item }) {
const me = useMe() const me = useMe()
const [pollVote] = useMutation( const POLL_VOTE_MUTATION = gql`
gql`
mutation pollVote($id: ID!, $hash: String, $hmac: String) { mutation pollVote($id: ID!, $hash: String, $hmac: String) {
pollVote(id: $id, hash: $hash, hmac: $hmac) pollVote(id: $id, hash: $hash, hmac: $hmac)
}`, { }`
update (cache, { data: { pollVote } }) { const [pollVote] = useMutation(POLL_VOTE_MUTATION)
const toaster = useToast()
const { notify, unnotify } = useClientNotifications()
const update = (cache, { data: { pollVote } }) => {
cache.modify({ cache.modify({
id: `Item:${item.id}`, id: `Item:${item.id}`,
fields: { fields: {
@ -41,35 +47,46 @@ export default function Poll ({ item }) {
} }
}) })
} }
}
)
const PollButton = ({ v }) => { const PollButton = ({ v }) => {
const showInvoiceModal = useInvoiceModal(async ({ hash, hmac }, { variables }) => { const payment = usePayment()
await pollVote({ variables: { ...variables, hash, hmac } })
}, [pollVote])
const variables = { id: v.id }
return ( return (
<ActionTooltip placement='left' notForm overlayText='1 sat'> <ActionTooltip placement='left' notForm overlayText='1 sat'>
<Button <Button
variant='outline-info' className={styles.pollButton} variant='outline-info' className={styles.pollButton}
onClick={me onClick={me
? async () => { ? async () => {
const variables = { id: v.id }
const notifyProps = { itemId: item.id }
const optimisticResponse = { pollVote: v.id }
let revert, cancel, nid
try { try {
await pollVote({ revert = optimisticUpdate({ mutation: POLL_VOTE_MUTATION, variables, optimisticResponse, update })
variables,
optimisticResponse: { if (me) {
pollVote: v.id 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) { } catch (error) {
if (payOrLoginError(error)) { revert?.()
showInvoiceModal({ amount: item.pollCost || POLL_COST }, { variables })
if (error instanceof InvoiceCanceledError) {
return 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} : signIn}

View File

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

View File

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

View File

@ -112,7 +112,7 @@ export default function TerritoryForm ({ sub }) {
nsfw: sub?.nsfw || false nsfw: sub?.nsfw || false
}} }}
schema={schema} schema={schema}
invoiceable prepaid
onSubmit={onSubmit} onSubmit={onSubmit}
className='mb-5' className='mb-5'
storageKeyPrefix={sub ? undefined : 'territory'} 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()] }}> <FeeButtonProvider baseLineItems={{ territory: TERRITORY_BILLING_OPTIONS('one')[sub.billingType.toLowerCase()] }}>
<Form <Form
invoiceable prepaid
initial={{ initial={{
name: sub.name name: sub.name
}} }}

View File

@ -10,21 +10,6 @@ const ToastContext = createContext(() => {})
export const TOAST_DEFAULT_DELAY_MS = 5000 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 => { const mapHidden = ({ id, tag }) => toast => {
// mark every previous toast with same tag as hidden // mark every previous toast with same tag as hidden
if (toast.tag === tag && toast.id !== id) return { ...toast, hidden: true } if (toast.tag === tag && toast.id !== id) return { ...toast, hidden: true }
@ -36,24 +21,15 @@ export const ToastProvider = ({ children }) => {
const [toasts, setToasts] = useState([]) const [toasts, setToasts] = useState([])
const toastId = useRef(0) const toastId = useRef(0)
const removeToast = useCallback(({ id, onCancel, tag }) => { const removeToast = useCallback(({ id, tag }) => {
setToasts(toasts => toasts.filter(toast => { setToasts(toasts => toasts.filter(toast => {
if (toast.id === id) { if (toast.id === id) {
// remove the toast with the passed id with no exceptions // remove the toast with the passed id with no exceptions
return false return false
} }
const sameTag = tag && tag === toast.tag const sameTag = tag && tag === toast.tag
if (!sameTag) { // remove toasts with same tag
// don't touch toasts with different tags return !sameTag
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
})) }))
}, [setToasts]) }, [setToasts])
@ -63,14 +39,10 @@ export const ToastProvider = ({ children }) => {
createdAt: +new Date(), createdAt: +new Date(),
id: toastId.current++ id: toastId.current++
} }
setToasts(toasts => ensureFlow(toasts, toast).map(mapHidden(toast))) setToasts(toasts => [...toasts, toast].map(mapHidden(toast)))
return () => removeToast(toast) return () => removeToast(toast)
}, [setToasts, removeToast]) }, [setToasts, removeToast])
const endFlow = useCallback((flowId) => {
setToasts(toasts => toasts.filter(toast => toast.flowId !== flowId))
}, [setToasts])
const toaster = useMemo(() => ({ const toaster = useMemo(() => ({
success: (body, options) => { success: (body, options) => {
const toast = { const toast = {
@ -103,14 +75,13 @@ export const ToastProvider = ({ children }) => {
...options ...options
} }
return dispatchToast(toast) return dispatchToast(toast)
}, }
endFlow }), [dispatchToast, removeToast])
}), [dispatchToast, removeToast, endFlow])
// Only clear toasts with no cancel function on page navigation // Only clear toasts with no cancel function on page navigation
// since navigation should not interfere with being able to cancel an action. // since navigation should not interfere with being able to cancel an action.
useEffect(() => { 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) router.events.on('routeChangeStart', handleRouteChangeStart)
return () => { return () => {
@ -151,16 +122,9 @@ export const ToastProvider = ({ children }) => {
{visibleToasts.map(toast => { {visibleToasts.map(toast => {
const textStyle = toast.variant === 'warning' ? 'text-dark' : '' const textStyle = toast.variant === 'warning' ? 'text-dark' : ''
const onClose = () => { const onClose = () => {
toast.onUndo?.()
toast.onCancel?.()
toast.onClose?.() toast.onClose?.()
removeToast(toast) 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 // a toast is unhidden if it was hidden before since it now gets rendered
const unhidden = toast.hidden const unhidden = toast.hidden
// we only need to start the animation at a different timing when it was hidden by another toast before. // 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' className='p-0 ps-2'
aria-label='close' aria-label='close'
onClick={onClose} onClick={onClose}
>{buttonElement} ><div className={`${styles.toastClose} ${textStyle}`}>X</div>
</Button> </Button>
</div> </div>
</ToastBody> </ToastBody>
@ -196,78 +160,3 @@ export const ToastProvider = ({ children }) => {
} }
export const useToast = () => useContext(ToastContext) 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); 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 { .toastClose {
color: #fff; color: #fff;
font-family: "lightning"; font-family: "lightning";

View File

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

View File

@ -35,3 +35,17 @@
left: 4px; left: 4px;
width: 17px; 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 { LNbitsProvider, useLNbits } from './lnbits'
import { NWCProvider, useNWC } from './nwc' import { NWCProvider, useNWC } from './nwc'
import { useToast, withToastFlow } from '@/components/toast'
import { gql, useMutation } from '@apollo/client'
import { LNCProvider, useLNC } from './lnc' import { LNCProvider, useLNC } from './lnc'
const WebLNContext = createContext({}) const WebLNContext = createContext({})
@ -86,31 +84,6 @@ function RawWebLNProvider ({ children }) {
// TODO: implement fallbacks via provider priority // TODO: implement fallbacks via provider priority
const provider = enabledProviders[0] 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) => { const setProvider = useCallback((defaultProvider) => {
// move provider to the start to set it as default // move provider to the start to set it as default
setEnabledProviders(providers => { setEnabledProviders(providers => {
@ -129,8 +102,17 @@ function RawWebLNProvider ({ children }) {
await lnc.clearConfig() await lnc.clearConfig()
}, []) }, [])
const value = useMemo(() => ({
provider: isEnabled(provider)
? { name: provider.name, sendPayment: provider.sendPayment }
: null,
enabledProviders,
setProvider,
clearConfig
}), [provider, enabledProviders, setProvider])
return ( return (
<WebLNContext.Provider value={{ provider: isEnabled(provider) ? { name: provider.name, sendPayment: sendPaymentWithToast } : null, enabledProviders, setProvider, clearConfig }}> <WebLNContext.Provider value={value}>
{children} {children}
</WebLNContext.Provider> </WebLNContext.Provider>
) )

View File

@ -9,6 +9,7 @@ import CancelButton from '../cancel-button'
import { Mutex } from 'async-mutex' import { Mutex } from 'async-mutex'
import { Wallet } from '@/lib/constants' import { Wallet } from '@/lib/constants'
import { useMe } from '../me' import { useMe } from '../me'
import { InvoiceCanceledError, InvoiceExpiredError } from '@/components/payment'
const LNCContext = createContext() const LNCContext = createContext()
const mutex = new Mutex() const mutex = new Mutex()
@ -109,7 +110,14 @@ export function LNCProvider ({ children }) {
logger.ok('payment successful:', `payment_hash=${hash}`, `preimage=${preimage}`) logger.ok('payment successful:', `payment_hash=${hash}`, `preimage=${preimage}`)
return { preimage } return { preimage }
} catch (err) { } 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 throw err
} finally { } finally {
try { try {

View File

@ -6,8 +6,9 @@ import { parseNwcUrl } from '@/lib/url'
import { useWalletLogger } from '../logger' import { useWalletLogger } from '../logger'
import { Status, migrateLocalStorage } from '.' import { Status, migrateLocalStorage } from '.'
import { bolt11Tags } from '@/lib/bolt11' import { bolt11Tags } from '@/lib/bolt11'
import { Wallet } from '@/lib/constants' import { JIT_INVOICE_TIMEOUT_MS, Wallet } from '@/lib/constants'
import { useMe } from '../me' import { useMe } from '../me'
import { InvoiceExpiredError } from '../payment'
const NWCContext = createContext() const NWCContext = createContext()
@ -205,11 +206,11 @@ export function NWCProvider ({ children }) {
(async function () { (async function () {
// timeout since NWC is async (user needs to confirm payment in wallet) // timeout since NWC is async (user needs to confirm payment in wallet)
// timeout is same as invoice expiry // timeout is same as invoice expiry
const timeout = 180_000 const timeout = JIT_INVOICE_TIMEOUT_MS
const timer = setTimeout(() => { const timer = setTimeout(() => {
const msg = 'timeout waiting for info event' const msg = 'timeout waiting for payment'
logger.error(msg) logger.error(msg)
reject(new Error(msg)) reject(new InvoiceExpiredError(hash))
sub?.close() sub?.close()
}, timeout) }, 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 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 FAST_POLL_INTERVAL = Number(process.env.NEXT_PUBLIC_FAST_POLL_INTERVAL)
export const NORMAL_POLL_INTERVAL = Number(process.env.NEXT_PUBLIC_NORMAL_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}`) 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 { WebLNProvider } from '@/components/webln'
import dynamic from 'next/dynamic' import dynamic from 'next/dynamic'
import { HasNewNotesProvider } from '@/components/use-has-new-notes' import { HasNewNotesProvider } from '@/components/use-has-new-notes'
import { ClientNotificationProvider } from '@/components/client-notifications'
const PWAPrompt = dynamic(() => import('react-ios-pwa-prompt'), { ssr: false }) const PWAPrompt = dynamic(() => import('react-ios-pwa-prompt'), { ssr: false })
@ -104,6 +105,7 @@ export default function MyApp ({ Component, pageProps: { ...props } }) {
<ApolloProvider client={client}> <ApolloProvider client={client}>
<MeProvider me={me}> <MeProvider me={me}>
<HasNewNotesProvider> <HasNewNotesProvider>
<ClientNotificationProvider>
<LoggerProvider> <LoggerProvider>
<ServiceWorkerProvider> <ServiceWorkerProvider>
<PriceProvider price={price}> <PriceProvider price={price}>
@ -126,6 +128,7 @@ export default function MyApp ({ Component, pageProps: { ...props } }) {
</PriceProvider> </PriceProvider>
</ServiceWorkerProvider> </ServiceWorkerProvider>
</LoggerProvider> </LoggerProvider>
</ClientNotificationProvider>
</HasNewNotesProvider> </HasNewNotesProvider>
</MeProvider> </MeProvider>
</ApolloProvider> </ApolloProvider>

View File

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

View File

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

View File

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