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! nsfwMode: Boolean!
tipDefault: Int! tipDefault: Int!
turboTipping: Boolean! turboTipping: Boolean!
zapUndos: Boolean!
wildWestMode: Boolean! wildWestMode: Boolean!
withdrawMaxFeeDefault: Int! withdrawMaxFeeDefault: Int!
} }
@ -146,6 +147,7 @@ export default gql`
nsfwMode: Boolean! nsfwMode: Boolean!
tipDefault: Int! tipDefault: Int!
turboTipping: Boolean! turboTipping: Boolean!
zapUndos: Boolean!
wildWestMode: Boolean! wildWestMode: Boolean!
withdrawMaxFeeDefault: Int! withdrawMaxFeeDefault: Int!
autoWithdrawThreshold: Int autoWithdrawThreshold: Int

View File

@ -7,6 +7,7 @@ import Flag from '../svgs/flag-fill.svg'
import { useMemo } from 'react' import { useMemo } from 'react'
import getColor from '../lib/rainbow' import getColor from '../lib/rainbow'
import { gql, useMutation } from '@apollo/client' import { gql, useMutation } from '@apollo/client'
import { useMe } from './me'
export function DownZap ({ id, meDontLikeSats, ...props }) { export function DownZap ({ id, meDontLikeSats, ...props }) {
const style = useMemo(() => (meDontLikeSats const style = useMemo(() => (meDontLikeSats
@ -23,6 +24,7 @@ export function DownZap ({ id, meDontLikeSats, ...props }) {
function DownZapper ({ id, As, children }) { function DownZapper ({ id, As, children }) {
const toaster = useToast() const toaster = useToast()
const showModal = useShowModal() const showModal = useShowModal()
const me = useMe()
return ( return (
<As <As
@ -32,7 +34,10 @@ function DownZapper ({ id, As, children }) {
<ItemAct <ItemAct
onClose={() => { onClose={() => {
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 }} itemId={id} down
> >
<AccordianItem <AccordianItem

View File

@ -186,12 +186,15 @@ export const useInvoiceable = (onSubmit, options = defaultOptions) => {
const onSubmitWrapper = useCallback(async ( const onSubmitWrapper = useCallback(async (
{ cost, ...formValues }, { cost, ...formValues },
{ variables, optimisticResponse, update, ...submitArgs }) => { { variables, optimisticResponse, update, flowId, ...submitArgs }) => {
// some actions require a session // some actions require a session
if (!me && options.requireSession) { if (!me && options.requireSession) {
throw new Error('you must be logged in') 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 // educated guesses where action might pass in the invoice amount
// (field 'cost' has highest precedence) // (field 'cost' has highest precedence)
cost ??= formValues.amount cost ??= formValues.amount
@ -201,7 +204,7 @@ export const useInvoiceable = (onSubmit, options = defaultOptions) => {
try { try {
const insufficientFunds = me?.privates.sats < cost const insufficientFunds = me?.privates.sats < cost
return await onSubmit(formValues, return await onSubmit(formValues,
{ ...submitArgs, variables, optimisticsResponse: insufficientFunds ? null : optimisticResponse }) { ...submitArgs, flowId, variables, optimisticsResponse: insufficientFunds ? null : optimisticResponse, update })
} catch (error) { } catch (error) {
if (!payOrLoginError(error) || !cost) { if (!payOrLoginError(error) || !cost) {
// can't handle error here - bail // can't handle error here - bail
@ -249,12 +252,14 @@ export const useInvoiceable = (onSubmit, options = defaultOptions) => {
showModal, showModal,
provider, provider,
pollInvoice, pollInvoice,
gqlCacheUpdate: _update gqlCacheUpdate: _update,
flowId
}) })
const retry = () => onSubmit( const retry = () => onSubmit(
{ hash: inv.hash, hmac: inv.hmac, ...formValues }, { hash: inv.hash, hmac: inv.hmac, ...formValues },
// unset update function since we already ran an cache update if we paid using WebLN // 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 }) { ...submitArgs, variables, update: webLn ? null : undefined })
// first retry // first retry
try { try {
@ -294,10 +299,10 @@ export const useInvoiceable = (onSubmit, options = defaultOptions) => {
} }
const INVOICE_CANCELED_ERROR = 'invoice canceled' 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) { if (provider.enabled) {
try { try {
return await waitForWebLNPayment({ provider, invoice, pollInvoice, gqlCacheUpdate }) return await waitForWebLNPayment({ provider, invoice, pollInvoice, gqlCacheUpdate, flowId })
} catch (err) { } catch (err) {
// check for errors which mean that QR code will also fail // check for errors which mean that QR code will also fail
if (err.message === INVOICE_CANCELED_ERROR) { 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 let undoUpdate
try { try {
// try WebLN provider first // 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 // can't use await here since we might be paying JIT invoices
// and sendPaymentAsync is not supported yet. // and sendPaymentAsync is not supported yet.
// see https://www.webln.guide/building-lightning-apps/webln-reference/webln.sendpaymentasync // 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 // WebLN payment will never resolve here for JIT invoices
// since they only get resolved after settlement which can't happen here // since they only get resolved after settlement which can't happen here
.then(() => resolve({ webLn: true, gqlCacheUpdateUndo: undoUpdate })) .then(() => resolve({ webLn: true, gqlCacheUpdateUndo: undoUpdate }))

View File

@ -5,9 +5,9 @@ import { Form, Input, SubmitButton } from './form'
import { useMe } from './me' import { useMe } from './me'
import UpBolt from '../svgs/bolt.svg' import UpBolt from '../svgs/bolt.svg'
import { amountSchema } from '../lib/validate' import { amountSchema } from '../lib/validate'
import { gql, useMutation } from '@apollo/client' import { gql, useApolloClient, useMutation } from '@apollo/client'
import { payOrLoginError, useInvoiceModal } from './invoice' import { payOrLoginError, useInvoiceModal } from './invoice'
import { useToast } from './toast' import { TOAST_DEFAULT_DELAY_MS, useToast, withToastFlow } from './toast'
import { useLightning } from './lightning' import { useLightning } from './lightning'
const defaultTips = [100, 1000, 10000, 100000] const defaultTips = [100, 1000, 10000, 100000]
@ -45,14 +45,16 @@ export default function ItemAct ({ onClose, itemId, down, children }) {
const me = useMe() const me = useMe()
const [oValue, setOValue] = useState() const [oValue, setOValue] = useState()
const strike = useLightning() const strike = useLightning()
const toaster = useToast()
const client = useApolloClient()
useEffect(() => { useEffect(() => {
inputRef.current?.focus() inputRef.current?.focus()
}, [onClose, itemId]) }, [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) { if (!me) {
const storageKey = `TIP-item:${itemId}` const storageKey = `TIP-item:${itemId}`
const existingAmount = Number(window.localStorage.getItem(storageKey) || '0') 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', act: down ? 'DONT_LIKE_THIS' : 'TIP',
hash, hash,
hmac 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)) addCustomTip(Number(amount))
onClose() 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 ( return (
<Form <Form
@ -80,7 +145,7 @@ export default function ItemAct ({ onClose, itemId, down, children }) {
}} }}
schema={amountSchema} schema={amountSchema}
invoiceable invoiceable
onSubmit={onSubmit} onSubmit={me?.privates?.zapUndos ? onSubmitWithUndos : onSubmit}
> >
<Input <Input
label='amount' label='amount'
@ -158,7 +223,7 @@ export function useAct ({ onUpdate } = {}) {
} }
}, [!!me, onUpdate]) }, [!!me, onUpdate])
return useMutation( const [act] = useMutation(
gql` gql`
mutation act($id: ID!, $sats: Int!, $act: String, $hash: String, $hmac: String) { mutation act($id: ID!, $sats: Int!, $act: String, $hash: String, $hmac: String) {
act(id: $id, sats: $sats, act: $act, hash: $hash, hmac: $hmac) { act(id: $id, sats: $sats, act: $act, hash: $hash, hmac: $hmac) {
@ -169,6 +234,7 @@ export function useAct ({ onUpdate } = {}) {
} }
}`, { update } }`, { update }
) )
return [act, update]
} }
export function useZap () { export function useZap () {
@ -227,12 +293,13 @@ export function useZap () {
sats sats
path path
} }
}`, { update } }`
) )
const toaster = useToast() const toaster = useToast()
const strike = useLightning() const strike = useLightning()
const [act] = useAct() const [act] = useAct()
const client = useApolloClient()
const invoiceableAct = useInvoiceModal( const invoiceableAct = useInvoiceModal(
async ({ hash, hmac }, { variables, ...apolloArgs }) => { async ({ hash, hmac }, { variables, ...apolloArgs }) => {
@ -240,6 +307,57 @@ export function useZap () {
strike() strike()
}, [act, 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 }) => { return useCallback(async ({ item, me }) => {
const meSats = (item?.meSats || 0) const meSats = (item?.meSats || 0)
@ -253,12 +371,19 @@ export function useZap () {
sats = meSats + sats sats = meSats + sats
} }
const variables = { id: item.id, sats, act: 'TIP' } const variables = { id: item.id, sats, act: 'TIP', amount: sats - meSats }
const insufficientFunds = me?.privates.sats < sats const insufficientFunds = me?.privates.sats < (sats - meSats)
const optimisticResponse = { act: { path: item.path, ...variables } } const optimisticResponse = { act: { path: item.path, ...variables } }
const flowId = (+new Date()).toString(16)
const zapArgs = { variables, optimisticResponse: insufficientFunds ? null : optimisticResponse, update, flowId }
try { try {
if (!insufficientFunds) strike() if (insufficientFunds) throw new Error('insufficient funds')
await zap({ variables, optimisticResponse: insufficientFunds ? null : optimisticResponse }) strike()
if (me?.privates?.zapUndos) {
await zapWithUndos(zapArgs)
} else {
await zap(zapArgs)
}
} catch (error) { } catch (error) {
if (payOrLoginError(error)) { if (payOrLoginError(error)) {
// call non-idempotent version // call non-idempotent version
@ -268,7 +393,8 @@ export function useZap () {
await invoiceableAct({ amount }, { await invoiceableAct({ amount }, {
variables: { ...variables, sats: amount }, variables: { ...variables, sats: amount },
optimisticResponse, optimisticResponse,
update update,
flowId
}) })
} catch (error) {} } catch (error) {}
return return

View File

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

View File

@ -1,5 +1,5 @@
.toastContainer { .toastContainer {
transform: translate3d(0,0,0); transform: translate3d(0, 0, 0);
} }
.toast { .toast {
@ -21,6 +21,13 @@
border-color: var(--bs-warning-border-subtle); border-color: var(--bs-warning-border-subtle);
} }
.toastUndo {
font-style: normal;
cursor: pointer;
display: flex;
align-items: center;
}
.toastCancel { .toastCancel {
font-style: italic; font-style: italic;
cursor: pointer; cursor: pointer;
@ -39,6 +46,38 @@
align-items: center; 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 { .toastClose:hover {
opacity: 0.7; opacity: 0.7;
} }
@ -47,4 +86,4 @@
.toast { .toast {
width: var(--bs-toast-max-width); width: var(--bs-toast-max-width);
} }
} }

View File

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

View File

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

View File

@ -63,6 +63,7 @@ export default function Settings ({ ssrData }) {
initial={{ initial={{
tipDefault: settings?.tipDefault || 21, tipDefault: settings?.tipDefault || 21,
turboTipping: settings?.turboTipping, turboTipping: settings?.turboTipping,
zapUndos: settings?.zapUndos,
fiatCurrency: settings?.fiatCurrency || 'USD', fiatCurrency: settings?.fiatCurrency || 'USD',
withdrawMaxFeeDefault: settings?.withdrawMaxFeeDefault, withdrawMaxFeeDefault: settings?.withdrawMaxFeeDefault,
noteItemSats: settings?.noteItemSats, noteItemSats: settings?.noteItemSats,
@ -139,32 +140,50 @@ export default function Settings ({ ssrData }) {
<AccordianItem <AccordianItem
show={settings?.turboTipping} show={settings?.turboTipping}
header={<div style={{ fontWeight: 'bold', fontSize: '92%' }}>advanced</div>} header={<div style={{ fontWeight: 'bold', fontSize: '92%' }}>advanced</div>}
body={<Checkbox body={
name='turboTipping' <>
label={ <Checkbox
<div className='d-flex align-items-center'>turbo zapping name='turboTipping'
<Info> label={
<ul className='fw-bold'> <div className='d-flex align-items-center'>turbo zapping
<li>Makes every additional bolt click raise your total zap to another 10x multiple of your default zap</li> <Info>
<li>e.g. if your zap default is 10 sats <ul className='fw-bold'>
<ul> <li>Makes every additional bolt click raise your total zap to another 10x multiple of your default zap</li>
<li>1st click: 10 sats total zapped</li> <li>e.g. if your zap default is 10 sats
<li>2nd click: 100 sats total zapped</li> <ul>
<li>3rd click: 1000 sats total zapped</li> <li>1st click: 10 sats total zapped</li>
<li>4th click: 10000 sats total zapped</li> <li>2nd click: 100 sats total zapped</li>
<li>and so on ...</li> <li>3rd click: 1000 sats total zapped</li>
<li>4th click: 10000 sats total zapped</li>
<li>and so on ...</li>
</ul>
</li>
<li>You can still custom zap via long press
<ul>
<li>the next bolt click rounds up to the next greatest 10x multiple of your default</li>
</ul>
</li>
</ul> </ul>
</li> </Info>
<li>You can still custom zap via long press </div>
<ul> }
<li>the next bolt click rounds up to the next greatest 10x multiple of your default</li> 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> </ul>
</li> </Info>
</ul> </div>
</Info> }
</div> />
} </>
/>} }
/> />
</div> </div>
<Select <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) autoDropBolt11s Boolean @default(false)
hideFromTopUsers Boolean @default(false) hideFromTopUsers Boolean @default(false)
turboTipping Boolean @default(false) turboTipping Boolean @default(false)
zapUndos Boolean @default(false)
imgproxyOnly Boolean @default(false) imgproxyOnly Boolean @default(false)
hideWalletBalance Boolean @default(false) hideWalletBalance Boolean @default(false)
referrerId Int? referrerId Int?