diff --git a/components/form.js b/components/form.js index d271da979..2eda65d50 100644 --- a/components/form.js +++ b/components/form.js @@ -33,6 +33,7 @@ import EyeClose from '@/svgs/eye-close-line.svg' import Info from './info' import { InvoiceCanceledError, usePayment } from './payment' import { useMe } from './me' +import { ActCanceledError } from './item-act' export class SessionRequiredError extends Error { constructor () { @@ -841,7 +842,7 @@ export function Form ({ } let hash, hmac if (invoiceable) { - revert = optimisticUpdate?.({ amount, ...values }, ...args); + revert = await optimisticUpdate?.({ amount, ...values }, ...args); [{ hash, hmac }, cancel] = await payment.request(amount) } await onSubmit({ hash, hmac, amount, ...values }, ...args) @@ -850,7 +851,7 @@ export function Form ({ } } catch (err) { revert?.() - if (err instanceof InvoiceCanceledError) { + if (err instanceof InvoiceCanceledError || err instanceof ActCanceledError) { return } const msg = err.message || err.toString?.() diff --git a/components/item-act.js b/components/item-act.js index 42fc92b82..8505b1187 100644 --- a/components/item-act.js +++ b/components/item-act.js @@ -6,7 +6,7 @@ import { useMe } from './me' import UpBolt from '@/svgs/bolt.svg' import { amountSchema } from '@/lib/validate' import { gql, useApolloClient, useMutation } from '@apollo/client' -import { useToast } from './toast' +import { TOAST_DEFAULT_DELAY_MS, useToast } from './toast' import { useLightning } from './lightning' import { nextTip } from './upvote' import { InvoiceCanceledError, PaymentProvider, usePayment } from './payment' @@ -47,6 +47,7 @@ export default function ItemAct ({ onClose, item, down, children }) { const [oValue, setOValue] = useState() const strike = useLightning() const cache = useApolloClient().cache + const toaster = useToast() useEffect(() => { inputRef.current?.focus() @@ -67,10 +68,19 @@ export default function ItemAct ({ onClose, item, down, children }) { addCustomTip(Number(amount)) }, [me, act, down, item.id, strike, onClose]) - const optimisticUpdate = useCallback(({ amount }) => { + const optimisticUpdate = useCallback(async ({ amount }) => { onClose() strike() - return actOptimisticUpdate(cache, { ...item, sats: Number(amount), act: down ? 'DONT_LIKE_THIS' : 'TIP' }, { me }) + const revert = actOptimisticUpdate(cache, { ...item, sats: Number(amount), act: down ? 'DONT_LIKE_THIS' : 'TIP' }, { me }) + if (zapUndoTrigger(me, amount)) { + try { + await zapUndo(toaster) + } catch (err) { + revert() + throw err + } + }; + return revert }, [cache, strike, onClose]) // we need to wrap with PaymentProvider here since modals don't have access to PaymentContext by default @@ -276,12 +286,15 @@ export function useZap () { try { const variables = { path: item.path, id: item.id, sats, act: 'TIP' } revert = zapOptimisticUpdate(cache, variables) - strike(); + strike() + if (zapUndoTrigger(me, sats)) { + await zapUndo(toaster) + }; [{ hash, hmac }, cancel] = await payment.request(sats - meSats) await zap({ variables: { ...variables, hash, hmac } }) } catch (error) { revert?.() - if (error instanceof InvoiceCanceledError) { + if (error instanceof InvoiceCanceledError || error instanceof ActCanceledError) { return } console.error(error) @@ -290,3 +303,24 @@ export function useZap () { } }, [strike, payment]) } + +export class ActCanceledError extends Error { + constructor () { + super('act canceled') + this.name = 'ActCanceledError' + } +} + +const zapUndoTrigger = (me, amount) => { + if (!me) return false + const enabled = me.privates.zapUndos !== null + return enabled ? amount >= me.privates.zapUndos : false +} + +const zapUndo = async (toaster) => { + return await new Promise((resolve, reject) => { + const delay = TOAST_DEFAULT_DELAY_MS + toaster.undo({ onClick: () => reject(new ActCanceledError()) }) + setTimeout(resolve, delay) + }) +} diff --git a/components/toast.js b/components/toast.js index e71548306..8ecfc9ed6 100644 --- a/components/toast.js +++ b/components/toast.js @@ -75,6 +75,16 @@ export const ToastProvider = ({ children }) => { ...options } return dispatchToast(toast) + }, + undo: (options) => { + const toast = { + body: (onClose) => , + variant: 'undo', + autohide: true, + delay: TOAST_DEFAULT_DELAY_MS, + ...options + } + return dispatchToast(toast) } }), [dispatchToast, removeToast]) @@ -137,18 +147,22 @@ export const ToastProvider = ({ children }) => { key={toast.id} bg={toast.variant} show autohide={toast.autohide} delay={toast.delay} className={`${styles.toast} ${styles[toast.variant]} ${textStyle}`} onClose={() => removeToast(toast)} > - -
-
{toast.body}
- -
-
+ {typeof toast.body === 'function' + ? toast.body(onClose) + : ( + +
+
{toast.body}
+ +
+
+ )} {toast.progressBar &&
} ) @@ -160,3 +174,19 @@ export const ToastProvider = ({ children }) => { } export const useToast = () => useContext(ToastContext) + +const ToastUndo = ({ handleClick, onClose }) => { + // TODO: make more pretty + return ( + { + handleClick() + onClose() + }} + > +
+ undo +
+
+ ) +} diff --git a/components/toast.module.css b/components/toast.module.css index 2223f7cf7..9808b74f0 100644 --- a/components/toast.module.css +++ b/components/toast.module.css @@ -1,5 +1,6 @@ .toastContainer { transform: translate3d(0, 0, 0); + margin-bottom: 53px; /* padding for bottom nav bar on mobile */ } .toast { @@ -32,6 +33,12 @@ align-items: center; } +.undo { + background-color: var(--bs-warning); + border-width: 0px; + color: black; +} + .progressBar { width: 0; height: 5px; @@ -54,8 +61,6 @@ background-color: var(--bs-warning); } - - @keyframes progressBar { 0% { width: 0; @@ -71,7 +76,7 @@ } @media screen and (min-width: 400px) { - .toast { - width: var(--bs-toast-max-width); + .toastContainer { + margin-bottom: 0px; } } \ No newline at end of file