stacker.news/components/item-act.js
ekzyis 94cce9155d
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
2024-05-28 12:18:54 -05:00

344 lines
9.4 KiB
JavaScript

import Button from 'react-bootstrap/Button'
import InputGroup from 'react-bootstrap/InputGroup'
import React, { useState, useRef, useEffect, useCallback } from 'react'
import { Form, Input, SubmitButton } from './form'
import { useMe } from './me'
import UpBolt from '@/svgs/bolt.svg'
import { amountSchema } from '@/lib/validate'
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, 10_000, 100_000]
const Tips = ({ setOValue }) => {
const tips = [...getCustomTips(), ...defaultTips].sort((a, b) => a - b)
return tips.map((num, i) =>
<Button
size='sm'
className={`${i > 0 ? 'ms-2' : ''} mb-2`}
key={num}
onClick={() => { setOValue(num) }}
>
<UpBolt
className='me-1'
width={14}
height={14}
/>{num}
</Button>)
}
const getCustomTips = () => JSON.parse(window.localStorage.getItem('custom-tips')) || []
const addCustomTip = (amount) => {
if (defaultTips.includes(amount)) return
let customTips = Array.from(new Set([amount, ...getCustomTips()]))
if (customTips.length > 3) {
customTips = customTips.slice(0, 3)
}
window.localStorage.setItem('custom-tips', JSON.stringify(customTips))
}
const setItemMeAnonSats = ({ id, amount }) => {
const storageKey = `TIP-item:${id}`
const existingAmount = Number(window.localStorage.getItem(storageKey) || '0')
window.localStorage.setItem(storageKey, existingAmount + amount)
}
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()
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) {
act(id: $id, sats: $sats, act: $act, hash: $hash, hmac: $hmac) {
id
sats
path
act
}
}`
export function useAct ({ onUpdate } = {}) {
const [act] = useMutation(ACT_MUTATION)
return act
}
export function useZap () {
const update = useCallback((cache, args) => {
const { data: { act: { id, sats, path } } } = args
// determine how much we increased existing sats by by checking the
// difference between result sats and meSats
// if it's negative, skip the cache as it's an out of order update
// if it's positive, add it to sats and commentSats
const item = cache.readFragment({
id: `Item:${id}`,
fragment: gql`
fragment ItemMeSatsZap on Item {
meSats
}
`
})
const satsDelta = sats - item.meSats
if (satsDelta > 0) {
cache.modify({
id: `Item:${id}`,
fields: {
sats (existingSats = 0) {
return existingSats + satsDelta
},
meSats: () => {
return sats
}
}
})
// update all ancestors
path.split('.').forEach(aId => {
if (Number(aId) === Number(id)) return
cache.modify({
id: `Item:${aId}`,
fields: {
commentSats (existingCommentSats = 0) {
return existingCommentSats + satsDelta
}
}
})
})
}
}, [])
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 payment = usePayment()
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 satsDelta = sats - meSats
const variables = { id: item.id, sats, act: 'TIP' }
const notifyProps = { itemId: item.id, sats: satsDelta }
const optimisticResponse = { act: { path: item.path, ...variables } }
let revert, cancel, nid
try {
revert = optimisticUpdate({ mutation: ZAP_MUTATION, variables, optimisticResponse, update })
strike()
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) {
revert?.()
if (error instanceof InvoiceCanceledError || error instanceof ActCanceledError) {
return
}
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)
})
}