diff --git a/README.md b/README.md index 4937a35..2612ac3 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,7 @@ toast.success('Saved!', { style: { backgroundColor: '#fff' }, dismissible: true, showCloseButton: true, + deduplication: false, // Opt out of deduplication for this toast }); ``` @@ -202,10 +203,41 @@ Available options include: - **dismissible**: Allow swipe to dismiss - **showCloseButton**: Show X button - **defaultDuration**: Default display time in ms +- **deduplication**: Prevent duplicate toasts (default: `true`, see below) - **colors**: Custom colors per toast type - **icons**: Custom icons per toast type - **toastStyle**, **titleStyle**, **descriptionStyle**: Global style overrides +### Deduplication + +Deduplication is **enabled by default**. When the same toast is shown repeatedly (e.g., rapid button taps), it prevents stacking identical toasts. Instead, it resets the timer and plays a feedback animation: + +- **Non-error toasts**: subtle pulse (scale bump) +- **Error toasts**: shake effect + +Disable globally: + +```tsx + +``` + +Or per-toast (overrides global config): + +```tsx +// Opt out for a specific toast +toast.info('New message', { deduplication: false }); + +// Explicitly enable for a specific toast (redundant when global is on) +toast.success('Liked!', { deduplication: true }); +``` + +By default, a toast is considered a duplicate when it matches the **front toast** by title, type, and description. For stable matching across different content, provide an `id` — the existing toast's content will be updated: + +```tsx +toast.success('Saved item 1', { deduplication: true, id: 'save-action' }); +toast.success('Saved item 2', { deduplication: true, id: 'save-action' }); // updates content, resets timer +``` + ## API Reference | Method | Description | diff --git a/example/app/(custom)/index.tsx b/example/app/(custom)/index.tsx index 9ff114d..9541f87 100644 --- a/example/app/(custom)/index.tsx +++ b/example/app/(custom)/index.tsx @@ -112,6 +112,30 @@ export default function CustomScreen() { No Close Button + + toast.success("Liked!", { + description: "Tap again — it won't stack", + deduplication: true, + }) + } + > + Deduplication (Pulse) + + + + toast.error("Rate limited", { + description: "Please wait before retrying", + deduplication: true, + }) + } + > + Deduplication (Shake) + + diff --git a/example/app/(global)/index.tsx b/example/app/(global)/index.tsx index d8b5b0c..1b98d2c 100644 --- a/example/app/(global)/index.tsx +++ b/example/app/(global)/index.tsx @@ -15,6 +15,7 @@ export default function GlobalConfigScreen() { const [showCloseButton, setShowCloseButton] = useState(true); const [customStyle, setCustomStyle] = useState(true); const [rtl, setRtl] = useState(false); + const [deduplication, setDeduplication] = useState(true); const showToast = () => { toast.success("Hello!", "This toast uses the global config"); @@ -38,6 +39,7 @@ export default function GlobalConfigScreen() { rtl, offset: 8, defaultDuration: 4000, + deduplication, ...(customStyle && { toastStyle: { borderRadius: 30, @@ -146,6 +148,14 @@ export default function GlobalConfigScreen() { + + + Deduplication + Pulse/shake on repeated toasts + + + + Custom Styling diff --git a/package/src/constants.ts b/package/src/constants.ts index ff02975..235a271 100644 --- a/package/src/constants.ts +++ b/package/src/constants.ts @@ -20,4 +20,7 @@ export const DISMISS_VELOCITY_THRESHOLD = 300; export const STACK_OFFSET_PER_ITEM = 10; export const STACK_SCALE_PER_ITEM = 0.05; +export const DEDUPLICATION_PULSE_DURATION = 300; +export const DEDUPLICATION_SHAKE_DURATION = 400; + export const EASING = Easing.bezier(0.25, 0.1, 0.25, 1.0); diff --git a/package/src/pool.ts b/package/src/pool.ts index 23f5163..b1c8389 100644 --- a/package/src/pool.ts +++ b/package/src/pool.ts @@ -5,24 +5,14 @@ export interface AnimSlot { progress: SharedValue; translationY: SharedValue; stackIndex: SharedValue; -} - -export interface SlotTracker { - wasExiting: boolean; - prevIndex: number; - initialized: boolean; + deduplication: SharedValue; } export const animationPool: AnimSlot[] = Array.from({ length: POOL_SIZE }, () => ({ progress: makeMutable(0), translationY: makeMutable(0), stackIndex: makeMutable(0), -})); - -export const slotTrackers: SlotTracker[] = Array.from({ length: POOL_SIZE }, () => ({ - wasExiting: false, - prevIndex: 0, - initialized: false, + deduplication: makeMutable(0), })); const slotAssignments = new Map(); @@ -36,9 +26,6 @@ export const getSlotIndex = (toastId: string): number => { if (!usedSlots.has(i)) { slotAssignments.set(toastId, i); usedSlots.add(i); - slotTrackers[i].initialized = false; - slotTrackers[i].wasExiting = false; - slotTrackers[i].prevIndex = 0; return i; } } @@ -50,8 +37,5 @@ export const releaseSlot = (toastId: string) => { if (idx !== undefined) { usedSlots.delete(idx); slotAssignments.delete(toastId); - slotTrackers[idx].initialized = false; - slotTrackers[idx].wasExiting = false; - slotTrackers[idx].prevIndex = 0; } }; diff --git a/package/src/toast-icons.tsx b/package/src/toast-icons.tsx index 2c6bcea..da57813 100644 --- a/package/src/toast-icons.tsx +++ b/package/src/toast-icons.tsx @@ -46,8 +46,8 @@ export const AnimatedIcon = memo( }, [progress]); const style = useAnimatedStyle(() => ({ - opacity: progress.value, - transform: [{ scale: 0.7 + progress.value * 0.3 }], + opacity: progress.get(), + transform: [{ scale: 0.7 + progress.get() * 0.3 }], })); return {resolveIcon(type, color, custom, config)}; diff --git a/package/src/toast-provider.tsx b/package/src/toast-provider.tsx index a0d168c..6ff9ffd 100644 --- a/package/src/toast-provider.tsx +++ b/package/src/toast-provider.tsx @@ -20,9 +20,15 @@ interface BreadLoafProps { * * @property position - Where toasts appear: `'top'` (default) or `'bottom'` * @property offset - Extra spacing from screen edge in pixels (default: `0`) + * @property rtl - Enable right-to-left layout (default: `false`) * @property stacking - Show multiple toasts stacked (default: `true`). When `false`, only one toast shows at a time + * @property maxStack - Maximum visible toasts when stacking (default: `3`) + * @property dismissible - Whether toasts can be swiped to dismiss (default: `true`) + * @property showCloseButton - Show close button on toasts (default: `true`) * @property defaultDuration - Default display time in ms (default: `4000`) + * @property deduplication - Deduplicate repeated toasts, resetting timer with pulse/shake animation (default: `true`) * @property colors - Customize colors per toast type (`success`, `error`, `info`, `loading`) + * @property icons - Custom icons per toast type * @property toastStyle - Style overrides for the toast container (borderRadius, shadow, padding, etc.) * @property titleStyle - Style overrides for the title text * @property descriptionStyle - Style overrides for the description text diff --git a/package/src/toast-store.ts b/package/src/toast-store.ts index 1d21603..ff89967 100644 --- a/package/src/toast-store.ts +++ b/package/src/toast-store.ts @@ -23,6 +23,7 @@ const DEFAULT_THEME: ToastTheme = { titleStyle: {}, descriptionStyle: {}, defaultDuration: 4000, + deduplication: true, }; function mergeConfig(config: ToastConfig | undefined): ToastTheme { @@ -55,6 +56,7 @@ function mergeConfig(config: ToastConfig | undefined): ToastTheme { titleStyle: { ...DEFAULT_THEME.titleStyle, ...config.titleStyle }, descriptionStyle: { ...DEFAULT_THEME.descriptionStyle, ...config.descriptionStyle }, defaultDuration: config.defaultDuration ?? DEFAULT_THEME.defaultDuration, + deduplication: config.deduplication ?? DEFAULT_THEME.deduplication, }; } @@ -104,12 +106,38 @@ class ToastStore { ): string => { const actualDuration = duration ?? options?.duration ?? this.theme.defaultDuration; const maxToasts = this.theme.stacking ? this.theme.maxStack : 1; + const resolvedDescription = description ?? options?.description; + + const shouldDedup = type !== "loading" && (options?.deduplication ?? this.theme.deduplication); + if (shouldDedup) { + const deduplicationId = options?.id; + const frontToast = this.state.visibleToasts.find(t => !t.isExiting); + const duplicate = deduplicationId + ? this.state.visibleToasts.find(t => !t.isExiting && t.options?.id === deduplicationId) + : frontToast && + frontToast.title === title && + frontToast.type === type && + frontToast.description === resolvedDescription + ? frontToast + : undefined; + + if (duplicate) { + this.updateToast(duplicate.id, { + title, + description: resolvedDescription, + type, + deduplicatedAt: Date.now(), + duration: actualDuration, + }); + return duplicate.id; + } + } const id = `toast-${++this.toastIdCounter}`; const newToast: Toast = { id, title, - description: description ?? options?.description, + description: resolvedDescription, type, duration: actualDuration, createdAt: Date.now(), diff --git a/package/src/toast.tsx b/package/src/toast.tsx index 298cb10..003ef59 100644 --- a/package/src/toast.tsx +++ b/package/src/toast.tsx @@ -1,10 +1,18 @@ -import { memo, useCallback, useEffect, useState } from "react"; +import { memo, useCallback, useEffect, useRef, useState } from "react"; import { Pressable, StyleSheet, Text, View } from "react-native"; import { Gesture, GestureDetector } from "react-native-gesture-handler"; -import Animated, { interpolate, useAnimatedStyle, useSharedValue, withTiming } from "react-native-reanimated"; +import Animated, { + interpolate, + useAnimatedStyle, + useSharedValue, + withSequence, + withTiming, +} from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { scheduleOnRN } from "react-native-worklets"; import { + DEDUPLICATION_PULSE_DURATION, + DEDUPLICATION_SHAKE_DURATION, DISMISS_THRESHOLD, DISMISS_VELOCITY_THRESHOLD, EASING, @@ -22,7 +30,7 @@ import { SWIPE_EXIT_OFFSET, } from "./constants"; import { CloseIcon } from "./icons"; -import { type AnimSlot, animationPool, getSlotIndex, releaseSlot, slotTrackers } from "./pool"; +import { type AnimSlot, animationPool, getSlotIndex, releaseSlot } from "./pool"; import { AnimatedIcon, resolveIcon } from "./toast-icons"; import { toastStore } from "./toast-store"; import type { CustomContentRenderFn, ToastItemProps, TopToastRef } from "./types"; @@ -42,12 +50,12 @@ export const ToastContainer = () => { }) .onUpdate(event => { "worklet"; - if (!isDismissibleMutable.value) return; - const ref = topToastMutable.value; + if (!isDismissibleMutable.get()) return; + const ref = topToastMutable.get(); if (!ref) return; const { slot } = ref; - const bottom = isBottomMutable.value; + const bottom = isBottomMutable.get(); const rawY = event.translationY; const dismissDrag = bottom ? rawY : -rawY; const resistDrag = bottom ? -rawY : rawY; @@ -70,17 +78,17 @@ export const ToastContainer = () => { }) .onEnd(() => { "worklet"; - if (!isDismissibleMutable.value) return; - const ref = topToastMutable.value; + if (!isDismissibleMutable.get()) return; + const ref = topToastMutable.get(); if (!ref) return; const { slot } = ref; - const bottom = isBottomMutable.value; - if (shouldDismiss.value) { + const bottom = isBottomMutable.get(); + if (shouldDismiss.get()) { slot.progress.set(withTiming(0, { duration: EXIT_DURATION, easing: EASING })); const exitOffset = bottom ? SWIPE_EXIT_OFFSET : -SWIPE_EXIT_OFFSET; slot.translationY.set( - withTiming(slot.translationY.value + exitOffset, { duration: EXIT_DURATION, easing: EASING }) + withTiming(slot.translationY.get() + exitOffset, { duration: EXIT_DURATION, easing: EASING }) ); scheduleOnRN(ref.dismiss); } else { @@ -122,7 +130,7 @@ export const ToastContainer = () => { const ToastItem = ({ toast, index, theme, position, isTopToast, registerTopToast }: ToastItemProps) => { const [slotIdx] = useState(() => getSlotIndex(toast.id)); const slot = animationPool[slotIdx]; - const tracker = slotTrackers[slotIdx]; + const tracker = useRef({ wasExiting: false, prevIndex: 0, initialized: false }); const isBottom = position === "bottom"; const entryFromY = isBottom ? ENTRY_OFFSET : -ENTRY_OFFSET; @@ -130,12 +138,14 @@ const ToastItem = ({ toast, index, theme, position, isTopToast, registerTopToast const [wasLoading, setWasLoading] = useState(toast.type === "loading"); const [showIcon, setShowIcon] = useState(false); + const lastDeduplicatedAt = useRef(toast.deduplicatedAt); // biome-ignore lint/correctness/useExhaustiveDependencies: mount-only effect useEffect(() => { slot.progress.set(0); slot.translationY.set(0); slot.stackIndex.set(index); + slot.deduplication.set(0); slot.progress.set(withTiming(1, { duration: ENTRY_DURATION, easing: EASING })); const iconTimeout = setTimeout(() => setShowIcon(true), 50); @@ -153,17 +163,17 @@ const ToastItem = ({ toast, index, theme, position, isTopToast, registerTopToast useEffect(() => { let loadingTimeout: ReturnType | null = null; - if (toast.isExiting && !tracker.wasExiting) { - tracker.wasExiting = true; + if (toast.isExiting && !tracker.current.wasExiting) { + tracker.current.wasExiting = true; slot.progress.set(withTiming(0, { duration: EXIT_DURATION, easing: EASING })); slot.translationY.set(withTiming(exitToY, { duration: EXIT_DURATION, easing: EASING })); } - if (tracker.initialized && index !== tracker.prevIndex) { + if (tracker.current.initialized && index !== tracker.current.prevIndex) { slot.stackIndex.set(withTiming(index, { duration: STACK_TRANSITION_DURATION, easing: EASING })); } - tracker.prevIndex = index; - tracker.initialized = true; + tracker.current.prevIndex = index; + tracker.current.initialized = true; if (toast.type === "loading") { setWasLoading(true); @@ -175,6 +185,34 @@ const ToastItem = ({ toast, index, theme, position, isTopToast, registerTopToast registerTopToast({ slot: slot as AnimSlot, dismiss: dismissToast }); } + if (toast.deduplicatedAt && toast.deduplicatedAt !== lastDeduplicatedAt.current) { + lastDeduplicatedAt.current = toast.deduplicatedAt; + + if (toast.type === "error") { + const step = DEDUPLICATION_SHAKE_DURATION / 8; + slot.deduplication.set(0); + slot.deduplication.set( + withSequence( + withTiming(6, { duration: step }), + withTiming(-5, { duration: step }), + withTiming(4, { duration: step }), + withTiming(-3, { duration: step }), + withTiming(2, { duration: step }), + withTiming(-1, { duration: step }), + withTiming(0, { duration: step * 2 }) + ) + ); + } else { + slot.deduplication.set(0); + slot.deduplication.set( + withSequence( + withTiming(1, { duration: DEDUPLICATION_PULSE_DURATION / 2, easing: EASING }), + withTiming(0, { duration: DEDUPLICATION_PULSE_DURATION / 2, easing: EASING }) + ) + ); + } + } + return () => { if (loadingTimeout) clearTimeout(loadingTimeout); if (isTopToast) registerTopToast(null); @@ -183,38 +221,42 @@ const ToastItem = ({ toast, index, theme, position, isTopToast, registerTopToast toast.isExiting, index, slot, - tracker, exitToY, toast.type, wasLoading, isTopToast, registerTopToast, dismissToast, + toast.deduplicatedAt, ]); const shouldAnimateIcon = wasLoading && toast.type !== "loading"; + const isErrorType = toast.type === "error"; const animatedStyle = useAnimatedStyle(() => { - const baseTranslateY = interpolate(slot.progress.value, [0, 1], [entryFromY, 0]); + const baseTranslateY = interpolate(slot.progress.get(), [0, 1], [entryFromY, 0]); const stackOffsetY = isBottom - ? slot.stackIndex.value * STACK_OFFSET_PER_ITEM - : slot.stackIndex.value * -STACK_OFFSET_PER_ITEM; - const stackScale = 1 - slot.stackIndex.value * STACK_SCALE_PER_ITEM; + ? slot.stackIndex.get() * STACK_OFFSET_PER_ITEM + : slot.stackIndex.get() * -STACK_OFFSET_PER_ITEM; + const stackScale = 1 - slot.stackIndex.get() * STACK_SCALE_PER_ITEM; - const finalTranslateY = baseTranslateY + slot.translationY.value + stackOffsetY; + const finalTranslateY = baseTranslateY + slot.translationY.get() + stackOffsetY; - const progressOpacity = interpolate(slot.progress.value, [0, 1], [0, 1]); - const dismissDirection = isBottom ? slot.translationY.value : -slot.translationY.value; + const progressOpacity = interpolate(slot.progress.get(), [0, 1], [0, 1]); + const dismissDirection = isBottom ? slot.translationY.get() : -slot.translationY.get(); const dragOpacity = dismissDirection > 0 ? interpolate(dismissDirection, [0, 130], [1, 0], "clamp") : 1; const opacity = progressOpacity * dragOpacity; - const dragScale = interpolate(Math.abs(slot.translationY.value), [0, 50], [1, 0.98], "clamp"); - const scale = stackScale * dragScale; + const dedupVal = slot.deduplication.get(); + const dragScale = interpolate(Math.abs(slot.translationY.get()), [0, 50], [1, 0.98], "clamp"); + const pulseScale = isErrorType ? 1 : 1 + dedupVal * 0.03; + const scale = stackScale * dragScale * pulseScale; + const shakeTranslateX = isErrorType ? dedupVal : 0; return { - transform: [{ translateY: finalTranslateY }, { scale }], + transform: [{ translateY: finalTranslateY }, { translateX: shakeTranslateX }, { scale }], opacity, - zIndex: 1000 - Math.round(slot.stackIndex.value), + zIndex: 1000 - Math.round(slot.stackIndex.get()), }; }); @@ -320,6 +362,7 @@ const MemoizedToastItem = memo(ToastItem, (prev, next) => { prev.toast.title === next.toast.title && prev.toast.description === next.toast.description && prev.toast.isExiting === next.toast.isExiting && + prev.toast.deduplicatedAt === next.toast.deduplicatedAt && prev.index === next.index && prev.position === next.position && prev.theme === next.theme && diff --git a/package/src/types.ts b/package/src/types.ts index 4e9b907..eccb6c1 100644 --- a/package/src/types.ts +++ b/package/src/types.ts @@ -70,10 +70,14 @@ export interface ToastTheme { descriptionStyle: TextStyle; /** Default duration in ms for toasts (default: 4000) */ defaultDuration: number; + /** When true, duplicate toasts reset the timer and play a feedback animation. Matches by title+type+description, or by `id` if provided. (default: true) */ + deduplication: boolean; } /** Per-toast options for customizing individual toasts */ export interface ToastOptions { + /** Stable key for deduplication. When set, toasts with the same `id` deduplicate and update the existing toast's content. Without an `id`, matching falls back to title+type+description against the front toast. */ + id?: string; /** Description text */ description?: string; /** Duration in ms (overrides default) */ @@ -96,6 +100,8 @@ export interface ToastOptions { * Receives props: { id, dismiss, type, isExiting } */ customContent?: ReactNode | CustomContentRenderFn; + /** Enable deduplication for this toast (overrides global config). Plays a pulse animation for non-error toasts or a shake for errors. Use with `id` for stable matching across different content. */ + deduplication?: boolean; } /** Configuration options for customizing toast behavior and appearance. All properties are optional. */ @@ -115,6 +121,7 @@ export interface Toast { duration: number; createdAt: number; isExiting?: boolean; + deduplicatedAt?: number; /** Per-toast style/icon overrides */ options?: ToastOptions; }