From c57fcd65186bb8d5f91b8bf258a2db702c9144fd Mon Sep 17 00:00:00 2001 From: ekzyis Date: Thu, 22 Feb 2024 01:48:42 +0100 Subject: [PATCH] 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> --- api/typeDefs/user.js | 2 + components/dont-link-this.js | 7 +- components/invoice.js | 19 ++- components/item-act.js | 156 ++++++++++++++++-- components/toast.js | 88 +++++++--- components/toast.module.css | 43 ++++- components/webln/index.js | 4 +- fragments/users.js | 2 + pages/settings/index.js | 67 +++++--- .../20240219225338_zap_undos/migration.sql | 2 + prisma/schema.prisma | 1 + 11 files changed, 317 insertions(+), 74 deletions(-) create mode 100644 prisma/migrations/20240219225338_zap_undos/migration.sql diff --git a/api/typeDefs/user.js b/api/typeDefs/user.js index 48593d10..c0f2441c 100644 --- a/api/typeDefs/user.js +++ b/api/typeDefs/user.js @@ -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 diff --git a/components/dont-link-this.js b/components/dont-link-this.js index a5a9f104..5cf71aa6 100644 --- a/components/dont-link-this.js +++ b/components/dont-link-this.js @@ -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 ( { 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 > { 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 })) diff --git a/components/item-act.js b/components/item-act.js index 574d18b1..9629caa8 100644 --- a/components/item-act.js +++ b/components/item-act.js @@ -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 (
{ @@ -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 diff --git a/components/toast.js b/components/toast.js index 4a0614a7..0d9537f3 100644 --- a/components/toast.js +++ b/components/toast.js @@ -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 }) => { {visibleToasts.map(toast => { const textStyle = toast.variant === 'warning' ? 'text-dark' : '' + const onClose = () => { + toast.onUndo?.() + toast.onCancel?.() + toast.onClose?.() + removeToast(toast) + } + const buttonElement = toast.onUndo + ?
undo
+ : toast.onCancel + ?
cancel
+ :
X
+ const elapsed = (+new Date() - toast.createdAt) return ( { variant={null} className='p-0 ps-2' aria-label='close' - onClick={() => { - toast.onCancel?.() - toast.onClose?.() - removeToast(toast) - }} - >{toast.onCancel ?
cancel
:
X
} + onClick={onClose} + >{buttonElement} + {toast.delay > 0 &&
} ) })} @@ -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 () => { - try { - await onCancel?.() - canceled = true - toaster.warning(`${t} canceled`, { flowId }) - } catch (err) { - toaster.danger(`failed to cancel ${t}`, { flowId }) + onCancel: onCancel + ? async () => { + try { + await onCancel() + canceled = true + toaster.warning(`${t} canceled`, { ...toastProps, flowId }) + } catch (err) { + 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 } diff --git a/components/toast.module.css b/components/toast.module.css index c66d1363..d1ad302f 100644 --- a/components/toast.module.css +++ b/components/toast.module.css @@ -1,5 +1,5 @@ .toastContainer { - transform: translate3d(0,0,0); + transform: translate3d(0, 0, 0); } .toast { @@ -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; } @@ -47,4 +86,4 @@ .toast { width: var(--bs-toast-max-width); } -} +} \ No newline at end of file diff --git a/components/webln/index.js b/components/webln/index.js index 256ffa7b..91af2ddf 100644 --- a/components/webln/index.js +++ b/components/webln/index.js @@ -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 diff --git a/fragments/users.js b/fragments/users.js index 71fd2672..6831b6a5 100644 --- a/fragments/users.js +++ b/fragments/users.js @@ -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 diff --git a/pages/settings/index.js b/pages/settings/index.js index 2eb71de7..e2667062 100644 --- a/pages/settings/index.js +++ b/pages/settings/index.js @@ -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,32 +140,50 @@ export default function Settings ({ ssrData }) { advanced
} - body={turbo zapping - -
    -
  • Makes every additional bolt click raise your total zap to another 10x multiple of your default zap
  • -
  • e.g. if your zap default is 10 sats -
      -
    • 1st click: 10 sats total zapped
    • -
    • 2nd click: 100 sats total zapped
    • -
    • 3rd click: 1000 sats total zapped
    • -
    • 4th click: 10000 sats total zapped
    • -
    • and so on ...
    • + body={ + <> + turbo zapping + +
        +
      • Makes every additional bolt click raise your total zap to another 10x multiple of your default zap
      • +
      • e.g. if your zap default is 10 sats +
          +
        • 1st click: 10 sats total zapped
        • +
        • 2nd click: 100 sats total zapped
        • +
        • 3rd click: 1000 sats total zapped
        • +
        • 4th click: 10000 sats total zapped
        • +
        • and so on ...
        • +
        +
      • +
      • You can still custom zap via long press +
          +
        • the next bolt click rounds up to the next greatest 10x multiple of your default
        • +
        +
      - -
    • You can still custom zap via long press -
        -
      • the next bolt click rounds up to the next greatest 10x multiple of your default
      • + + + } + groupClassName='mb-0' + /> + zap undos + +
          +
        • An undo button is shown after every zap
        • +
        • The button is shown for 5 seconds
        - -
      - - - } - />} + + + } + /> + + } />