UX latency enhancements for paid actions (#1434)

* prevent multiple retries & pulse retry button

* fix lint

* don't wait for settlement on pessimistic zaps

* optimistic act modal
This commit is contained in:
Keyan 2024-09-25 13:32:52 -05:00 committed by GitHub
parent 450c969dfc
commit 9f06fd65ee
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 177 additions and 107 deletions

View File

@ -160,7 +160,7 @@ async function performPessimisticAction (actionType, args, context) {
export async function retryPaidAction (actionType, args, context) {
const { models, me } = context
const { invoiceId } = args
const { invoice: failedInvoice } = args
console.log('retryPaidAction', actionType, args)
@ -181,18 +181,13 @@ export async function retryPaidAction (actionType, args, context) {
throw new Error(`retryPaidAction - action does not support retrying ${actionType}`)
}
if (!invoiceId) {
throw new Error(`retryPaidAction - missing invoiceId ${actionType}`)
if (!failedInvoice) {
throw new Error(`retryPaidAction - missing invoice ${actionType}`)
}
context.optimistic = true
context.me = await models.user.findUnique({ where: { id: me.id } })
const failedInvoice = await models.invoice.findUnique({ where: { id: invoiceId, actionState: 'FAILED' } })
if (!failedInvoice) {
throw new Error(`retryPaidAction ${actionType} - invoice ${invoiceId} not found or not in failed state`)
}
const { msatsRequested, actionId } = failedInvoice
context.cost = BigInt(msatsRequested)
context.actionId = actionId
@ -204,7 +199,7 @@ export async function retryPaidAction (actionType, args, context) {
// update the old invoice to RETRYING, so that it's not confused with FAILED
await tx.invoice.update({
where: {
id: invoiceId,
id: failedInvoice.id,
actionState: 'FAILED'
},
data: {
@ -216,7 +211,7 @@ export async function retryPaidAction (actionType, args, context) {
const invoice = await createDbInvoice(actionType, args, context, invoiceArgs)
return {
result: await action.retry({ invoiceId, newInvoiceId: invoice.id }, context),
result: await action.retry({ invoiceId: failedInvoice.id, newInvoiceId: invoice.id }, context),
invoice,
paymentMethod: 'OPTIMISTIC'
}

View File

@ -56,7 +56,14 @@ export default {
throw new Error('Invoice not found')
}
const result = await retryPaidAction(invoice.actionType, { invoiceId }, { models, me, lnd })
if (invoice.actionState !== 'FAILED') {
if (invoice.actionState === 'PAID') {
throw new Error('Invoice is already paid')
}
throw new Error(`Invoice is not in failed state: ${invoice.actionState}`)
}
const result = await retryPaidAction(invoice.actionType, { invoice }, { models, me, lnd })
return {
...result,

View File

@ -94,7 +94,7 @@ export function CommentFlat ({ item, rank, siblingComments, ...props }) {
export default function Comment ({
item, children, replyOpen, includeParent, topLevel,
rootText, noComments, noReply, truncate, depth, pin
rootText, noComments, noReply, truncate, depth, pin, setDisableRetry, disableRetry
}) {
const [edit, setEdit] = useState()
const { me } = useMe()
@ -169,6 +169,8 @@ export default function Comment ({
embellishUser={op && <><span> </span><Badge bg={op === 'fwd' ? 'secondary' : 'boost'} className={`${styles.op} bg-opacity-75`}>{op}</Badge></>}
onQuoteReply={quoteReply}
nested={!includeParent}
setDisableRetry={setDisableRetry}
disableRetry={disableRetry}
extraInfo={
<>
{includeParent && <Parent item={item} rootText={rootText} />}

View File

@ -53,7 +53,7 @@ export function SubmitButton ({
return (
<Button
variant={variant || 'main'}
className={classNames(formik.isSubmitting && styles.pending, className)}
className={classNames(formik.isSubmitting && 'pulse', className)}
type='submit'
disabled={disabled}
onClick={value

View File

@ -13,8 +13,12 @@ import ItemJob from './item-job'
import Item from './item'
import { CommentFlat } from './comment'
import classNames from 'classnames'
import Moon from '@/svgs/moon-fill.svg'
export default function Invoice ({ id, query = INVOICE, modal, onPayment, onCanceled, info, successVerb, useWallet = true, walletError, poll, waitFor, ...props }) {
export default function Invoice ({
id, query = INVOICE, modal, onPayment, onCanceled, info, successVerb = 'deposited',
heldVerb = 'settling', useWallet = true, walletError, poll, waitFor, ...props
}) {
const [expired, setExpired] = useState(false)
const { data, error } = useQuery(query, SSR
? {}
@ -55,9 +59,17 @@ export default function Invoice ({ id, query = INVOICE, modal, onPayment, onCanc
variant = 'failed'
status = 'cancelled'
useWallet = false
} else if (invoice.confirmedAt || (invoice.isHeld && invoice.satsReceived && !expired)) {
} else if (invoice.isHeld && invoice.satsReceived && !expired) {
variant = 'pending'
status = (
<div className='d-flex justify-content-center'>
<Moon className='spin fill-grey me-2' /> {heldVerb}
</div>
)
useWallet = false
} else if (invoice.confirmedAt) {
variant = 'confirmed'
status = `${numWithUnits(invoice.satsReceived, { abbreviate: false })} ${successVerb || 'deposited'}`
status = `${numWithUnits(invoice.satsReceived, { abbreviate: false })} ${successVerb}`
useWallet = false
} else if (expired) {
variant = 'failed'

View File

@ -90,6 +90,7 @@ function BoostForm ({ step, onSubmit, children, item, oValue, inputRef, act = 'B
export default function ItemAct ({ onClose, item, act = 'TIP', step, children, abortSignal }) {
const inputRef = useRef(null)
const { me } = useMe()
const wallet = useWallet()
const [oValue, setOValue] = useState()
useEffect(() => {
@ -110,6 +111,18 @@ export default function ItemAct ({ onClose, item, act = 'TIP', step, children, a
}
}
}
const onPaid = () => {
strike()
onClose?.()
if (!me) setItemMeAnonSats({ id: item.id, amount })
}
const closeImmediately = !!wallet || me?.privates?.sats > Number(amount)
if (closeImmediately) {
onPaid()
}
const { error } = await actor({
variables: {
id: item.id,
@ -127,15 +140,11 @@ export default function ItemAct ({ onClose, item, act = 'TIP', step, children, a
}
: undefined,
// don't close modal immediately because we want the QR modal to stack
onCompleted: () => {
strike()
onClose?.()
if (!me) setItemMeAnonSats({ id: item.id, amount })
}
onPaid: closeImmediately ? undefined : onPaid
})
if (error) throw error
addCustomTip(Number(amount))
}, [me, actor, act, item.id, onClose, abortSignal, strike])
}, [me, actor, !!wallet, act, item.id, onClose, abortSignal, strike])
return act === 'BOOST'
? <BoostForm step={step} onSubmit={onSubmit} item={item} oValue={oValue} inputRef={inputRef} act={act}>{children}</BoostForm>
@ -226,6 +235,7 @@ export function useAct ({ query = ACT_MUTATION, ...options } = {}) {
const getPaidActionResult = data => Object.values(data)[0]
const [act] = usePaidMutation(query, {
waitFor: inv => inv?.satsReceived > 0,
...options,
update: (cache, { data }) => {
const response = getPaidActionResult(data)

View File

@ -26,20 +26,20 @@ import { useQrPayment } from './payment'
import { useRetryCreateItem } from './use-item-submit'
import { useToast } from './toast'
import { useShowModal } from './modal'
import classNames from 'classnames'
export default function ItemInfo ({
item, full, commentsText = 'comments',
commentTextSingular = 'comment', className, embellishUser, extraInfo, onEdit, editText,
onQuoteReply, extraBadges, nested, pinnable, showActionDropdown = true, showUser = true
onQuoteReply, extraBadges, nested, pinnable, showActionDropdown = true, showUser = true,
setDisableRetry, disableRetry
}) {
const editThreshold = new Date(item.invoice?.confirmedAt ?? item.createdAt).getTime() + 10 * 60000
const { me } = useMe()
const toaster = useToast()
const router = useRouter()
const [canEdit, setCanEdit] = useState(item.mine && (Date.now() < editThreshold))
const [hasNewComments, setHasNewComments] = useState(false)
const root = useRoot()
const retryCreateItem = useRetryCreateItem({ id: item.id })
const sub = item?.sub || root?.sub
useEffect(() => {
@ -64,72 +64,6 @@ export default function ItemInfo ({
const canPin = (isPost && mySub) || (myPost && rootReply)
const meSats = (me ? item.meSats : item.meAnonSats) || 0
const EditInfo = () => {
if (canEdit) {
return (
<>
<span> \ </span>
<span
className='text-reset pointer fw-bold'
onClick={() => onEdit ? onEdit() : router.push(`/items/${item.id}/edit`)}
>
<span>{editText || 'edit'} </span>
{(!item.invoice?.actionState || item.invoice?.actionState === 'PAID') &&
<Countdown
date={editThreshold}
onComplete={() => { setCanEdit(false) }}
/>}
</span>
</>
)
}
return null
}
const PaymentInfo = () => {
const waitForQrPayment = useQrPayment()
if (item.deletedAt) return null
let Component
let onClick
if (me && item.invoice?.actionState && item.invoice?.actionState !== 'PAID') {
if (item.invoice?.actionState === 'FAILED') {
Component = () => <span className='text-warning'>retry payment</span>
onClick = async () => {
try {
const { error } = await retryCreateItem({ variables: { invoiceId: parseInt(item.invoice?.id) } })
if (error) throw error
} catch (error) {
toaster.danger(error.message)
}
}
} else {
Component = () => (
<span
className='text-info'
>pending
</span>
)
onClick = () => waitForQrPayment({ id: item.invoice?.id }, null, { cancelOnClose: false }).catch(console.error)
}
} else {
return null
}
return (
<>
<span> \ </span>
<span
className='text-reset pointer fw-bold'
onClick={onClick}
>
<Component />
</span>
</>
)
}
return (
<div className={className || `${styles.other}`}>
{!(item.position && (pinnable || !item.subName)) && !(!item.parentId && Number(item.user?.id) === USER_ID.ad) &&
@ -217,8 +151,11 @@ export default function ItemInfo ({
{
showActionDropdown &&
<>
<EditInfo />
<PaymentInfo />
<EditInfo
item={item} canEdit={canEdit}
setCanEdit={setCanEdit} onEdit={onEdit} editText={editText} editThreshold={editThreshold}
/>
<PaymentInfo item={item} disableRetry={disableRetry} setDisableRetry={setDisableRetry} />
<ActionDropdown>
<CopyLinkDropdownItem item={item} />
<InfoDropdownItem item={item} />
@ -317,3 +254,85 @@ function InfoDropdownItem ({ item }) {
</Dropdown.Item>
)
}
function PaymentInfo ({ item, disableRetry, setDisableRetry }) {
const { me } = useMe()
const toaster = useToast()
const retryCreateItem = useRetryCreateItem({ id: item.id })
const waitForQrPayment = useQrPayment()
const [disableInfoRetry, setDisableInfoRetry] = useState(disableRetry)
if (item.deletedAt) return null
const disableDualRetry = disableRetry || disableInfoRetry
function setDisableDualRetry (value) {
setDisableInfoRetry(value)
setDisableRetry?.(value)
}
let Component
let onClick
if (me && item.invoice?.actionState && item.invoice?.actionState !== 'PAID') {
if (item.invoice?.actionState === 'FAILED') {
Component = () => <span className={classNames('text-warning', disableDualRetry && 'pulse')}>retry payment</span>
onClick = async () => {
if (disableDualRetry) return
setDisableDualRetry(true)
try {
const { error } = await retryCreateItem({ variables: { invoiceId: parseInt(item.invoice?.id) } })
if (error) throw error
} catch (error) {
toaster.danger(error.message)
} finally {
setDisableDualRetry(false)
}
}
} else {
Component = () => (
<span
className='text-info'
>pending
</span>
)
onClick = () => waitForQrPayment({ id: item.invoice?.id }, null, { cancelOnClose: false }).catch(console.error)
}
} else {
return null
}
return (
<>
<span> \ </span>
<span
className='text-reset pointer fw-bold'
onClick={onClick}
>
<Component />
</span>
</>
)
}
function EditInfo ({ item, canEdit, setCanEdit, onEdit, editText, editThreshold }) {
const router = useRouter()
if (canEdit) {
return (
<>
<span> \ </span>
<span
className='text-reset pointer fw-bold'
onClick={() => onEdit ? onEdit() : router.push(`/items/${item.id}/edit`)}
>
<span>{editText || 'edit'} </span>
{(!item.invoice?.actionState || item.invoice?.actionState === 'PAID') &&
<Countdown
date={editThreshold}
onComplete={() => { setCanEdit(false) }}
/>}
</span>
</>
)
}
return null
}

View File

@ -87,7 +87,7 @@ function ItemLink ({ url, rel }) {
export default function Item ({
item, rank, belowTitle, right, full, children, itemClassName,
onQuoteReply, pinnable
onQuoteReply, pinnable, setDisableRetry, disableRetry
}) {
const titleRef = useRef()
const router = useRouter()
@ -139,6 +139,8 @@ export default function Item ({
onQuoteReply={onQuoteReply}
pinnable={pinnable}
extraBadges={Number(item?.user?.id) === USER_ID.ad && <Badge className={styles.newComment} bg={null}>AD</Badge>}
setDisableRetry={setDisableRetry}
disableRetry={disableRetry}
/>
{belowTitle}
</div>

View File

@ -38,6 +38,7 @@ import { paidActionCacheMods } from './use-paid-mutation'
import { useRetryCreateItem } from './use-item-submit'
import { payBountyCacheMods } from './pay-bounty'
import { useToast } from './toast'
import classNames from 'classnames'
function Notification ({ n, fresh }) {
const type = n.__typename
@ -102,14 +103,14 @@ function NoteHeader ({ color, children, big }) {
)
}
function NoteItem ({ item }) {
function NoteItem ({ item, ...props }) {
return (
<div>
{item.title
? <Item item={item} itemClassName='pt-0' />
? <Item item={item} itemClassName='pt-0' {...props} />
: (
<RootProvider root={item.root}>
<Comment item={item} noReply includeParent clickToContext />
<Comment item={item} noReply includeParent clickToContext {...props} />
</RootProvider>)}
</div>
)
@ -343,7 +344,10 @@ function InvoicePaid ({ n }) {
}
function useActRetry ({ invoice }) {
const bountyCacheMods = invoice.item?.bounty ? payBountyCacheMods() : {}
const bountyCacheMods =
invoice.item.root?.bounty === invoice.satsRequested && invoice.item.root?.mine
? payBountyCacheMods()
: {}
return useAct({
query: RETRY_PAID_ACTION,
onPayError: (e, cache, { data }) => {
@ -383,6 +387,7 @@ function Invoicification ({ n: { invoice, sortTime } }) {
const actRetry = useActRetry({ invoice })
const retryCreateItem = useRetryCreateItem({ id: invoice.item?.id })
const retryPollVote = usePollVote({ query: RETRY_PAID_ACTION, itemId: invoice.item?.id })
const [disableRetry, setDisableRetry] = useState(false)
// XXX if we navigate to an invoice after it is retried in notifications
// the cache will clear invoice.item and will error on window.back
// alternatively, we could/should
@ -407,7 +412,7 @@ function Invoicification ({ n: { invoice, sortTime } }) {
invoiceActionState = invoice.item.poll?.meInvoiceActionState
} else {
if (invoice.actionType === 'ZAP') {
if (invoice.item.root?.bounty === invoice.satsRequested && invoice.item.root.mine) {
if (invoice.item.root?.bounty === invoice.satsRequested && invoice.item.root?.mine) {
actionString = 'bounty payment'
} else {
actionString = 'zap'
@ -443,14 +448,19 @@ function Invoicification ({ n: { invoice, sortTime } }) {
<span className='ms-1 text-muted fw-light'> {numWithUnits(invoice.satsRequested)}</span>
<span className={invoiceActionState === 'FAILED' ? 'visible' : 'invisible'}>
<Button
size='sm' variant='outline-warning ms-2 border-1 rounded py-0'
size='sm' variant={classNames('outline-warning ms-2 border-1 rounded py-0', disableRetry && 'pulse')}
style={{ '--bs-btn-hover-color': '#fff', '--bs-btn-active-color': '#fff' }}
disabled={disableRetry}
onClick={async () => {
if (disableRetry) return
setDisableRetry(true)
try {
const { error } = await retry({ variables: { invoiceId: parseInt(invoiceId) } })
if (error) throw error
} catch (error) {
toaster.danger(error?.message || error?.toString?.())
} finally {
setDisableRetry(false)
}
}}
>
@ -459,7 +469,7 @@ function Invoicification ({ n: { invoice, sortTime } }) {
<span className='text-muted ms-2 fw-normal' suppressHydrationWarning>{timeSince(new Date(sortTime))}</span>
</span>
</NoteHeader>
<NoteItem item={invoice.item} />
<NoteItem item={invoice.item} setDisableRetry={setDisableRetry} disableRetry={disableRetry} />
</div>
)
}

View File

@ -57,7 +57,10 @@ export function usePaidMutation (mutation,
let { data, ...rest } = await mutate(innerOptions)
// use the most inner callbacks/options if they exist
const { onPaid, onPayError, forceWaitForPayment, persistOnNavigate, update } = { ...options, ...innerOptions }
const {
onPaid, onPayError, forceWaitForPayment, persistOnNavigate,
update, waitFor = inv => inv?.actionState === 'PAID'
} = { ...options, ...innerOptions }
const ourOnCompleted = innerOnCompleted || onCompleted
// get invoice without knowing the mutation name
@ -95,7 +98,7 @@ export function usePaidMutation (mutation,
// the action is pessimistic
try {
// wait for the invoice to be paid
await waitForPayment(invoice, { alwaysShowQROnFailure: true, persistOnNavigate, waitFor: inv => inv?.actionState === 'PAID' })
await waitForPayment(invoice, { alwaysShowQROnFailure: true, persistOnNavigate, waitFor })
if (!response.result) {
// if the mutation didn't return any data, ie pessimistic, we need to fetch it
const { data: { paidAction } } = await getPaidAction({ variables: { invoiceId: parseInt(invoice.id) } })

View File

@ -263,6 +263,20 @@ $zindex-sticky: 900;
justify-self: center;
}
.pulse {
animation-name: pulse;
animation-iteration-count: infinite;
animation-timing-function: ease-in-out;
animation-duration: 0.66s;
animation-direction: alternate;
}
@keyframes pulse {
0% {
opacity: 42%;
}
}
svg {
fill: var(--bs-body-color);
}

View File

@ -104,10 +104,6 @@ async function work () {
await boss.work('paidActionCanceling', jobWrapper(paidActionCanceling))
await boss.work('paidActionFailed', jobWrapper(paidActionFailed))
await boss.work('paidActionPaid', jobWrapper(paidActionPaid))
// we renamed these jobs so we leave them so they can "migrate"
await boss.work('holdAction', jobWrapper(paidActionHeld))
await boss.work('settleActionError', jobWrapper(paidActionFailed))
await boss.work('settleAction', jobWrapper(paidActionPaid))
}
if (isServiceEnabled('search')) {
await boss.work('indexItem', jobWrapper(indexItem))