Add tag and cancel support to toasts

This commit is contained in:
ekzyis 2024-02-01 14:18:42 +01:00
parent 50c4a9c8e6
commit 878d661154
3 changed files with 92 additions and 43 deletions

View File

@ -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>

View File

@ -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";

View File

@ -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