From 878d6611549658395520565d4b27890471845ce6 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Thu, 1 Feb 2024 14:18:42 +0100 Subject: [PATCH] Add tag and cancel support to toasts --- components/toast.js | 126 ++++++++++++++++++++++------------ components/toast.module.css | 7 ++ components/use-crossposter.js | 2 +- 3 files changed, 92 insertions(+), 43 deletions(-) diff --git a/components/toast.js b/components/toast.js index 936c0750..c96f6e31 100644 --- a/components/toast.js +++ b/components/toast.js @@ -12,53 +12,63 @@ export const ToastProvider = ({ children }) => { const router = useRouter() const [toasts, setToasts] = useState([]) const toastId = useRef(0) + const dispatchToast = useCallback((toastConfig) => { toastConfig = { ...toastConfig, id: toastId.current++ } setToasts(toasts => [...toasts, toastConfig]) + return () => removeToast(toastConfig) }, []) - const removeToast = useCallback(id => { - setToasts(toasts => toasts.filter(toast => toast.id !== id)) + const removeToast = useCallback(({ id, onCancel, tag }) => { + 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(() => ({ - success: (body, delay = 5000) => { - dispatchToast({ + success: (body, options) => { + const toast = { body, variant: 'success', autohide: true, - delay - }) + delay: 5000, + ...options + } + return dispatchToast(toast) }, - warning: (body, delay = 5000) => { - dispatchToast({ + warning: (body, options) => { + const toast = { body, variant: 'warning', autohide: true, - delay - }) + delay: 5000, + ...options + } + return dispatchToast(toast) }, - danger: (body, onCloseCallback) => { - const id = toastId.current - dispatchToast({ - id, + danger: (body, options) => { + const toast = { body, variant: 'danger', autohide: false, - onCloseCallback - }) - return { - removeToast: () => removeToast(id) + ...options } + return dispatchToast(toast) } }), [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(() => { - const handleRouteChangeStart = () => setToasts([]) + const handleRouteChangeStart = () => setToasts(toasts => toasts.filter(({ onCancel }) => onCancel), []) router.events.on('routeChangeStart', handleRouteChangeStart) return () => { @@ -66,31 +76,63 @@ export const ToastProvider = ({ children }) => { } }, [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 ( - {toasts.map(toast => ( - removeToast(toast.id)} - > - -
-
{toast.body}
- -
-
-
- ))} + {visibleToasts.map(toast => { + const textStyle = toast.variant === 'warning' ? 'text-dark' : '' + return ( + removeToast(toast.id)} + > + +
+
{toast.body}
+ +
+
+
+ ) + })}
{children}
diff --git a/components/toast.module.css b/components/toast.module.css index 6d09ad7b..c66d1363 100644 --- a/components/toast.module.css +++ b/components/toast.module.css @@ -21,6 +21,13 @@ border-color: var(--bs-warning-border-subtle); } +.toastCancel { + font-style: italic; + cursor: pointer; + display: flex; + align-items: center; +} + .toastClose { color: #fff; font-family: "lightning"; diff --git a/components/use-crossposter.js b/components/use-crossposter.js index ede0f45d..552bf50d 100644 --- a/components/use-crossposter.js +++ b/components/use-crossposter.js @@ -27,7 +27,7 @@ export default function useCrossposter () { const relayError = (failedRelays) => { return new Promise(resolve => { - const { removeToast } = toast.danger( + const removeToast = toast.danger( <> Crossposting failed for {failedRelays.join(', ')}