Add tag and cancel support to toasts
This commit is contained in:
parent
50c4a9c8e6
commit
878d661154
|
@ -12,53 +12,63 @@ export const ToastProvider = ({ children }) => {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [toasts, setToasts] = useState([])
|
const [toasts, setToasts] = useState([])
|
||||||
const toastId = useRef(0)
|
const toastId = useRef(0)
|
||||||
|
|
||||||
const dispatchToast = useCallback((toastConfig) => {
|
const dispatchToast = useCallback((toastConfig) => {
|
||||||
toastConfig = {
|
toastConfig = {
|
||||||
...toastConfig,
|
...toastConfig,
|
||||||
id: toastId.current++
|
id: toastId.current++
|
||||||
}
|
}
|
||||||
setToasts(toasts => [...toasts, toastConfig])
|
setToasts(toasts => [...toasts, toastConfig])
|
||||||
|
return () => removeToast(toastConfig)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const removeToast = useCallback(id => {
|
const removeToast = useCallback(({ id, onCancel, tag }) => {
|
||||||
setToasts(toasts => toasts.filter(toast => toast.id !== id))
|
setToasts(toasts => toasts.filter(toast => {
|
||||||
|
if (tag && !onCancel) {
|
||||||
|
// if tag onCancel is not set, toast did show X for closing.
|
||||||
|
// if additionally tag is set, we close all toasts with same tag.
|
||||||
|
return toast.tag !== tag
|
||||||
|
}
|
||||||
|
return toast.id !== id
|
||||||
|
}))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const toaster = useMemo(() => ({
|
const toaster = useMemo(() => ({
|
||||||
success: (body, delay = 5000) => {
|
success: (body, options) => {
|
||||||
dispatchToast({
|
const toast = {
|
||||||
body,
|
body,
|
||||||
variant: 'success',
|
variant: 'success',
|
||||||
autohide: true,
|
autohide: true,
|
||||||
delay
|
delay: 5000,
|
||||||
})
|
...options
|
||||||
|
}
|
||||||
|
return dispatchToast(toast)
|
||||||
},
|
},
|
||||||
warning: (body, delay = 5000) => {
|
warning: (body, options) => {
|
||||||
dispatchToast({
|
const toast = {
|
||||||
body,
|
body,
|
||||||
variant: 'warning',
|
variant: 'warning',
|
||||||
autohide: true,
|
autohide: true,
|
||||||
delay
|
delay: 5000,
|
||||||
})
|
...options
|
||||||
|
}
|
||||||
|
return dispatchToast(toast)
|
||||||
},
|
},
|
||||||
danger: (body, onCloseCallback) => {
|
danger: (body, options) => {
|
||||||
const id = toastId.current
|
const toast = {
|
||||||
dispatchToast({
|
|
||||||
id,
|
|
||||||
body,
|
body,
|
||||||
variant: 'danger',
|
variant: 'danger',
|
||||||
autohide: false,
|
autohide: false,
|
||||||
onCloseCallback
|
...options
|
||||||
})
|
|
||||||
return {
|
|
||||||
removeToast: () => removeToast(id)
|
|
||||||
}
|
}
|
||||||
|
return dispatchToast(toast)
|
||||||
}
|
}
|
||||||
}), [dispatchToast, removeToast])
|
}), [dispatchToast, removeToast])
|
||||||
|
|
||||||
// Clear all toasts 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.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleRouteChangeStart = () => setToasts([])
|
const handleRouteChangeStart = () => setToasts(toasts => toasts.filter(({ onCancel }) => onCancel), [])
|
||||||
router.events.on('routeChangeStart', handleRouteChangeStart)
|
router.events.on('routeChangeStart', handleRouteChangeStart)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
@ -66,31 +76,63 @@ export const ToastProvider = ({ children }) => {
|
||||||
}
|
}
|
||||||
}, [router])
|
}, [router])
|
||||||
|
|
||||||
|
// this function merges toasts with the same tag into one toast.
|
||||||
|
// for example: 3x 'zap pending' -> '(3) zap pending'
|
||||||
|
const tagReducer = (toasts, toast) => {
|
||||||
|
const { tag } = toast
|
||||||
|
|
||||||
|
// has tag?
|
||||||
|
if (!tag) return [...toasts, toast]
|
||||||
|
|
||||||
|
// existing tag?
|
||||||
|
const idx = toasts.findIndex(toast => toast.tag === tag)
|
||||||
|
if (idx === -1) return [...toasts, toast]
|
||||||
|
|
||||||
|
// merge toasts with same tag
|
||||||
|
const prevToast = toasts[idx]
|
||||||
|
let { rawBody, body, amount } = prevToast
|
||||||
|
rawBody ??= body
|
||||||
|
amount = amount ? amount + 1 : 2
|
||||||
|
body = `(${amount}) ${rawBody}`
|
||||||
|
return [
|
||||||
|
...toasts.slice(0, idx),
|
||||||
|
{ ...toast, rawBody, amount, body },
|
||||||
|
...toasts.slice(idx + 1)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// only show toast with highest ID of each tag
|
||||||
|
const visibleToasts = toasts.reduce(tagReducer, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ToastContext.Provider value={toaster}>
|
<ToastContext.Provider value={toaster}>
|
||||||
<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'>
|
||||||
{toasts.map(toast => (
|
{visibleToasts.map(toast => {
|
||||||
<Toast
|
const textStyle = toast.variant === 'warning' ? 'text-dark' : ''
|
||||||
key={toast.id} bg={toast.variant} show autohide={toast.autohide}
|
return (
|
||||||
delay={toast.delay} className={`${styles.toast} ${styles[toast.variant]} ${toast.variant === 'warning' ? 'text-dark' : ''}`} onClose={() => removeToast(toast.id)}
|
<Toast
|
||||||
>
|
key={toast.id} bg={toast.variant} show autohide={toast.autohide}
|
||||||
<ToastBody>
|
delay={toast.delay} className={`${styles.toast} ${styles[toast.variant]} ${textStyle}`} onClose={() => removeToast(toast.id)}
|
||||||
<div className='d-flex align-items-center'>
|
>
|
||||||
<div className='flex-grow-1'>{toast.body}</div>
|
<ToastBody>
|
||||||
<Button
|
<div className='d-flex align-items-center'>
|
||||||
variant={null}
|
<div className='flex-grow-1'>{toast.body}</div>
|
||||||
className='p-0 ps-2'
|
<Button
|
||||||
aria-label='close'
|
variant={null}
|
||||||
onClick={() => {
|
className='p-0 ps-2'
|
||||||
if (toast.onCloseCallback) toast.onCloseCallback()
|
aria-label='close'
|
||||||
removeToast(toast.id)
|
onClick={() => {
|
||||||
}}
|
toast.onCancel?.()
|
||||||
><div className={`${styles.toastClose} ${toast.variant === 'warning' ? 'text-dark' : ''}`}>X</div>
|
toast.onClose?.()
|
||||||
</Button>
|
removeToast(toast)
|
||||||
</div>
|
}}
|
||||||
</ToastBody>
|
>{toast.onCancel ? <div className={`${styles.toastCancel} ${textStyle}`}>cancel</div> : <div className={`${styles.toastClose} ${textStyle}`}>X</div>}
|
||||||
</Toast>
|
</Button>
|
||||||
))}
|
</div>
|
||||||
|
</ToastBody>
|
||||||
|
</Toast>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</ToastContainer>
|
</ToastContainer>
|
||||||
{children}
|
{children}
|
||||||
</ToastContext.Provider>
|
</ToastContext.Provider>
|
||||||
|
|
|
@ -21,6 +21,13 @@
|
||||||
border-color: var(--bs-warning-border-subtle);
|
border-color: var(--bs-warning-border-subtle);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.toastCancel {
|
||||||
|
font-style: italic;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
.toastClose {
|
.toastClose {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-family: "lightning";
|
font-family: "lightning";
|
||||||
|
|
|
@ -27,7 +27,7 @@ export default function useCrossposter () {
|
||||||
|
|
||||||
const relayError = (failedRelays) => {
|
const relayError = (failedRelays) => {
|
||||||
return new Promise(resolve => {
|
return new Promise(resolve => {
|
||||||
const { removeToast } = toast.danger(
|
const removeToast = toast.danger(
|
||||||
<>
|
<>
|
||||||
Crossposting failed for {failedRelays.join(', ')} <br />
|
Crossposting failed for {failedRelays.join(', ')} <br />
|
||||||
<Button
|
<Button
|
||||||
|
|
Loading…
Reference in New Issue