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!
|
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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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 }))
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
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?
|
||||||
|
|
Loading…
Reference in New Issue