Allow zap undo's for short period of time (#857)

* Cancel zaps

* Hide zap error toast

* Immediately throw error about insufficient funds

* Optimistic UX

* Also hide success zap toast

* Show undo instead of cancel

* Include sat amount in toast

* Fix undo toasts removed on navigation

* Add setting for zap undos

* Add undo to custom zaps

* Use WithUndos suffix

* Fix toast flow transition

* Fix setting not respected

* Skip undo flow if funds insufficient

* Remove brackets around undo

* Fix insufficient funds detection

* Fix downzap undo

* Add progress bar to toasts

* Use 'button' instead of 'notification' in zap undo info

* Remove console.log

* Fix toast progress bar restarts

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
This commit is contained in:
ekzyis 2024-02-22 01:48:42 +01:00 committed by GitHub
parent 46a0af19eb
commit c57fcd6518
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 317 additions and 74 deletions

View File

@ -84,6 +84,7 @@ export default gql`
nsfwMode: Boolean!
tipDefault: Int!
turboTipping: Boolean!
zapUndos: Boolean!
wildWestMode: Boolean!
withdrawMaxFeeDefault: Int!
}
@ -146,6 +147,7 @@ export default gql`
nsfwMode: Boolean!
tipDefault: Int!
turboTipping: Boolean!
zapUndos: Boolean!
wildWestMode: Boolean!
withdrawMaxFeeDefault: Int!
autoWithdrawThreshold: Int

View File

@ -7,6 +7,7 @@ import Flag from '../svgs/flag-fill.svg'
import { useMemo } from 'react'
import getColor from '../lib/rainbow'
import { gql, useMutation } from '@apollo/client'
import { useMe } from './me'
export function DownZap ({ id, meDontLikeSats, ...props }) {
const style = useMemo(() => (meDontLikeSats
@ -23,6 +24,7 @@ export function DownZap ({ id, meDontLikeSats, ...props }) {
function DownZapper ({ id, As, children }) {
const toaster = useToast()
const showModal = useShowModal()
const me = useMe()
return (
<As
@ -32,7 +34,10 @@ function DownZapper ({ id, As, children }) {
<ItemAct
onClose={() => {
onClose()
toaster.success('item downzapped')
// undo prompt was toasted before closing modal if zap undos are enabled
// so an additional success toast would be confusing
const zapUndosEnabled = me && me?.privates?.zapUndos
if (!zapUndosEnabled) toaster.success('item downzapped')
}} itemId={id} down
>
<AccordianItem

View File

@ -186,12 +186,15 @@ export const useInvoiceable = (onSubmit, options = defaultOptions) => {
const onSubmitWrapper = useCallback(async (
{ cost, ...formValues },
{ variables, optimisticResponse, update, ...submitArgs }) => {
{ 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
@ -201,7 +204,7 @@ export const useInvoiceable = (onSubmit, options = defaultOptions) => {
try {
const insufficientFunds = me?.privates.sats < cost
return await onSubmit(formValues,
{ ...submitArgs, variables, optimisticsResponse: insufficientFunds ? null : optimisticResponse })
{ ...submitArgs, flowId, variables, optimisticsResponse: insufficientFunds ? null : optimisticResponse, update })
} catch (error) {
if (!payOrLoginError(error) || !cost) {
// can't handle error here - bail
@ -249,12 +252,14 @@ export const useInvoiceable = (onSubmit, options = defaultOptions) => {
showModal,
provider,
pollInvoice,
gqlCacheUpdate: _update
gqlCacheUpdate: _update,
flowId
})
const retry = () => onSubmit(
{ hash: inv.hash, hmac: inv.hmac, ...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 {
@ -294,10 +299,10 @@ export const useInvoiceable = (onSubmit, options = defaultOptions) => {
}
const INVOICE_CANCELED_ERROR = 'invoice canceled'
const waitForPayment = async ({ invoice, showModal, provider, pollInvoice, gqlCacheUpdate }) => {
const waitForPayment = async ({ invoice, showModal, provider, pollInvoice, gqlCacheUpdate, flowId }) => {
if (provider.enabled) {
try {
return await waitForWebLNPayment({ provider, invoice, pollInvoice, gqlCacheUpdate })
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) {
@ -319,7 +324,7 @@ const waitForPayment = async ({ invoice, showModal, provider, pollInvoice, gqlCa
})
}
const waitForWebLNPayment = async ({ provider, invoice, pollInvoice, gqlCacheUpdate }) => {
const waitForWebLNPayment = async ({ provider, invoice, pollInvoice, gqlCacheUpdate, flowId }) => {
let undoUpdate
try {
// try WebLN provider first
@ -329,7 +334,7 @@ const waitForWebLNPayment = async ({ provider, invoice, pollInvoice, gqlCacheUpd
// 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)
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 }))

View File

@ -5,9 +5,9 @@ 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 { gql, useApolloClient, 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'
const defaultTips = [100, 1000, 10000, 100000]
@ -45,14 +45,16 @@ export default function ItemAct ({ onClose, itemId, down, children }) {
const me = useMe()
const [oValue, setOValue] = useState()
const strike = useLightning()
const toaster = useToast()
const client = useApolloClient()
useEffect(() => {
inputRef.current?.focus()
}, [onClose, itemId])
const [act] = useAct()
const [act, actUpdate] = useAct()
const onSubmit = useCallback(async ({ amount, hash, hmac }) => {
const onSubmit = useCallback(async ({ amount, hash, hmac }, { update }) => {
if (!me) {
const storageKey = `TIP-item:${itemId}`
const existingAmount = Number(window.localStorage.getItem(storageKey) || '0')
@ -65,12 +67,75 @@ export default function ItemAct ({ onClose, itemId, down, children }) {
act: down ? 'DONT_LIKE_THIS' : 'TIP',
hash,
hmac
}
},
update
})
await strike()
// only strike when zap undos not enabled
// due to optimistic UX on zap undos
if (!me || !me.privates.zapUndos) await strike()
addCustomTip(Number(amount))
onClose()
}, [act, down, itemId, strike])
}, [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
if (insufficientFunds) throw new Error('insufficient funds')
// update function for optimistic UX
const update = () => {
const fragment = {
id: `Item:${itemId}`,
fragment: gql`
fragment ItemMeSats 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 {
flowId,
type: 'zap',
pendingMessage: `${down ? 'down' : ''}zapped ${sats} sats`,
onPending: async () => {
await strike()
onClose()
return new Promise((resolve, reject) => {
undoUpdate = update()
setTimeout(() => {
if (canceled) return resolve()
onSubmit(values, { flowId, ...args, update: null })
.then(resolve)
.catch((err) => {
undoUpdate()
reject(err)
})
}, TOAST_DEFAULT_DELAY_MS)
})
},
onUndo: () => {
canceled = true
undoUpdate?.()
},
hideSuccess: true,
hideError: true
}
}
)
return (
<Form
@ -80,7 +145,7 @@ export default function ItemAct ({ onClose, itemId, down, children }) {
}}
schema={amountSchema}
invoiceable
onSubmit={onSubmit}
onSubmit={me?.privates?.zapUndos ? onSubmitWithUndos : onSubmit}
>
<Input
label='amount'
@ -158,7 +223,7 @@ export function useAct ({ onUpdate } = {}) {
}
}, [!!me, onUpdate])
return useMutation(
const [act] = useMutation(
gql`
mutation act($id: ID!, $sats: Int!, $act: String, $hash: String, $hmac: String) {
act(id: $id, sats: $sats, act: $act, hash: $hash, hmac: $hmac) {
@ -169,6 +234,7 @@ export function useAct ({ onUpdate } = {}) {
}
}`, { update }
)
return [act, update]
}
export function useZap () {
@ -227,12 +293,13 @@ export function useZap () {
sats
path
}
}`, { update }
}`
)
const toaster = useToast()
const strike = useLightning()
const [act] = useAct()
const client = useApolloClient()
const invoiceableAct = useInvoiceModal(
async ({ hash, hmac }, { variables, ...apolloArgs }) => {
@ -240,6 +307,57 @@ export function useZap () {
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 ItemMeSats 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,
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
}
}
)
return useCallback(async ({ item, me }) => {
const meSats = (item?.meSats || 0)
@ -253,12 +371,19 @@ export function useZap () {
sats = meSats + sats
}
const variables = { id: item.id, sats, act: 'TIP' }
const insufficientFunds = me?.privates.sats < sats
const variables = { id: item.id, sats, act: 'TIP', amount: sats - meSats }
const insufficientFunds = me?.privates.sats < (sats - meSats)
const optimisticResponse = { act: { path: item.path, ...variables } }
const flowId = (+new Date()).toString(16)
const zapArgs = { variables, optimisticResponse: insufficientFunds ? null : optimisticResponse, update, flowId }
try {
if (!insufficientFunds) strike()
await zap({ variables, optimisticResponse: insufficientFunds ? null : optimisticResponse })
if (insufficientFunds) throw new Error('insufficient funds')
strike()
if (me?.privates?.zapUndos) {
await zapWithUndos(zapArgs)
} else {
await zap(zapArgs)
}
} catch (error) {
if (payOrLoginError(error)) {
// call non-idempotent version
@ -268,7 +393,8 @@ export function useZap () {
await invoiceableAct({ amount }, {
variables: { ...variables, sats: amount },
optimisticResponse,
update
update,
flowId
})
} catch (error) {}
return

View File

@ -8,6 +8,8 @@ import styles from './toast.module.css'
const ToastContext = createContext(() => {})
export const TOAST_DEFAULT_DELAY_MS = 5000
export const ToastProvider = ({ children }) => {
const router = useRouter()
const [toasts, setToasts] = useState([])
@ -16,6 +18,7 @@ export const ToastProvider = ({ children }) => {
const dispatchToast = useCallback((toast) => {
toast = {
...toast,
createdAt: +new Date(),
id: toastId.current++
}
const { flowId } = toast
@ -46,7 +49,7 @@ export const ToastProvider = ({ children }) => {
// don't touch toasts with different tags
return true
}
const toRemoveHasCancel = !!toast.onCancel
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
@ -62,7 +65,7 @@ export const ToastProvider = ({ children }) => {
body,
variant: 'success',
autohide: true,
delay: 5000,
delay: TOAST_DEFAULT_DELAY_MS,
tag: options?.tag || body,
...options
}
@ -73,7 +76,7 @@ export const ToastProvider = ({ children }) => {
body,
variant: 'warning',
autohide: true,
delay: 5000,
delay: TOAST_DEFAULT_DELAY_MS,
tag: options?.tag || body,
...options
}
@ -94,7 +97,7 @@ export const ToastProvider = ({ children }) => {
// Only clear toasts with no cancel function on page navigation
// since navigation should not interfere with being able to cancel an action.
useEffect(() => {
const handleRouteChangeStart = () => setToasts(toasts => toasts.filter(({ onCancel }) => onCancel), [])
const handleRouteChangeStart = () => setToasts(toasts => toasts.filter(({ onCancel, onUndo }) => onCancel || onUndo), [])
router.events.on('routeChangeStart', handleRouteChangeStart)
return () => {
@ -135,6 +138,18 @@ export const ToastProvider = ({ children }) => {
<ToastContainer className={`pb-3 pe-3 ${styles.toastContainer}`} position='bottom-end' containerPosition='fixed'>
{visibleToasts.map(toast => {
const textStyle = toast.variant === 'warning' ? 'text-dark' : ''
const onClose = () => {
toast.onUndo?.()
toast.onCancel?.()
toast.onClose?.()
removeToast(toast)
}
const buttonElement = toast.onUndo
? <div className={`${styles.toastUndo} ${textStyle}`}>undo</div>
: toast.onCancel
? <div className={`${styles.toastCancel} ${textStyle}`}>cancel</div>
: <div className={`${styles.toastClose} ${textStyle}`}>X</div>
const elapsed = (+new Date() - toast.createdAt)
return (
<Toast
key={toast.id} bg={toast.variant} show autohide={toast.autohide}
@ -147,15 +162,12 @@ export const ToastProvider = ({ children }) => {
variant={null}
className='p-0 ps-2'
aria-label='close'
onClick={() => {
toast.onCancel?.()
toast.onClose?.()
removeToast(toast)
}}
>{toast.onCancel ? <div className={`${styles.toastCancel} ${textStyle}`}>cancel</div> : <div className={`${styles.toastClose} ${textStyle}`}>X</div>}
onClick={onClose}
>{buttonElement}
</Button>
</div>
</ToastBody>
{toast.delay > 0 && <div className={`${styles.progressBar} ${styles[toast.variant]}`} style={{ animationDelay: `-${elapsed}ms` }} />}
</Toast>
)
})}
@ -173,28 +185,54 @@ export const withToastFlow = (toaster) => flowFn => {
flowId,
type: t,
onPending,
pendingMessage,
onSuccess,
onCancel,
onError
onError,
onUndo,
hideError,
hideSuccess,
...toastProps
} = flowFn(...args)
let canceled
toaster.warning(`${t} pending`, {
// XXX HACK this ends the flow by using flow toast which immediately closes itself
const endFlow = () => toaster.warning('', { ...toastProps, delay: 0, autohide: true, flowId })
toaster.warning(pendingMessage || `${t} pending`, {
autohide: false,
onCancel: async () => {
onCancel: onCancel
? async () => {
try {
await onCancel?.()
await onCancel()
canceled = true
toaster.warning(`${t} canceled`, { flowId })
toaster.warning(`${t} canceled`, { ...toastProps, flowId })
} catch (err) {
toaster.danger(`failed to cancel ${t}`, { flowId })
toaster.danger(`failed to cancel ${t}`, { ...toastProps, flowId })
}
},
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) {
toaster.success(`${t} successful`, { flowId })
if (hideSuccess) {
endFlow()
} else {
toaster.success(`${t} successful`, { ...toastProps, flowId })
}
await onSuccess?.()
}
return ret
@ -202,7 +240,11 @@ export const withToastFlow = (toaster) => flowFn => {
// ignore errors if canceled since they might be caused by cancellation
if (canceled) return
const reason = err?.message?.toString().toLowerCase() || 'unknown reason'
toaster.danger(`${t} failed: ${reason}`, { flowId })
if (hideError) {
endFlow()
} else {
toaster.danger(`${t} failed: ${reason}`, { ...toastProps, flowId })
}
await onError?.()
throw err
}

View File

@ -21,6 +21,13 @@
border-color: var(--bs-warning-border-subtle);
}
.toastUndo {
font-style: normal;
cursor: pointer;
display: flex;
align-items: center;
}
.toastCancel {
font-style: italic;
cursor: pointer;
@ -39,6 +46,38 @@
align-items: center;
}
.progressBar {
width: 0;
height: 5px;
filter: brightness(66%);
/* same duration as toast delay */
animation: progressBar 5s linear;
}
.progressBar.success {
background-color: var(--bs-success);
}
.progressBar.danger {
background-color: var(--bs-danger);
}
.progressBar.warning {
background-color: var(--bs-warning);
}
@keyframes progressBar {
0% {
width: 0;
}
100% {
width: 100%;
}
}
.toastClose:hover {
opacity: 0.7;
}

View File

@ -82,9 +82,9 @@ function RawWebLNProvider ({ children }) {
`)
const sendPaymentWithToast = withToastFlow(toaster)(
({ bolt11, hash, hmac }) => {
({ bolt11, hash, hmac, flowId }) => {
return {
flowId: hash,
flowId: flowId || hash,
type: 'payment',
onPending: () => provider.sendPayment(bolt11),
// hash and hmac are only passed for JIT invoices

View File

@ -39,6 +39,7 @@ export const ME = gql`
tipDefault
tipPopover
turboTipping
zapUndos
upvotePopover
wildWestMode
withdrawMaxFeeDefault
@ -62,6 +63,7 @@ export const SETTINGS_FIELDS = gql`
privates {
tipDefault
turboTipping
zapUndos
fiatCurrency
withdrawMaxFeeDefault
noteItemSats

View File

@ -63,6 +63,7 @@ export default function Settings ({ ssrData }) {
initial={{
tipDefault: settings?.tipDefault || 21,
turboTipping: settings?.turboTipping,
zapUndos: settings?.zapUndos,
fiatCurrency: settings?.fiatCurrency || 'USD',
withdrawMaxFeeDefault: settings?.withdrawMaxFeeDefault,
noteItemSats: settings?.noteItemSats,
@ -139,7 +140,9 @@ export default function Settings ({ ssrData }) {
<AccordianItem
show={settings?.turboTipping}
header={<div style={{ fontWeight: 'bold', fontSize: '92%' }}>advanced</div>}
body={<Checkbox
body={
<>
<Checkbox
name='turboTipping'
label={
<div className='d-flex align-items-center'>turbo zapping
@ -164,7 +167,23 @@ export default function Settings ({ ssrData }) {
</Info>
</div>
}
/>}
groupClassName='mb-0'
/>
<Checkbox
name='zapUndos'
label={
<div className='d-flex align-items-center'>zap undos
<Info>
<ul className='fw-bold'>
<li>An undo button is shown after every zap</li>
<li>The button is shown for 5 seconds</li>
</ul>
</Info>
</div>
}
/>
</>
}
/>
</div>
<Select

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "users" ADD COLUMN "zapUndos" BOOLEAN NOT NULL DEFAULT false;

View File

@ -55,6 +55,7 @@ model User {
autoDropBolt11s Boolean @default(false)
hideFromTopUsers Boolean @default(false)
turboTipping Boolean @default(false)
zapUndos Boolean @default(false)
imgproxyOnly Boolean @default(false)
hideWalletBalance Boolean @default(false)
referrerId Int?