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:
parent
46a0af19eb
commit
c57fcd6518
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 }))
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "users" ADD COLUMN "zapUndos" BOOLEAN NOT NULL DEFAULT false;
|
|
@ -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?
|
||||
|
|
Loading…
Reference in New Issue