From dbcef53e7070b7c8dbaa2ee894e90f0fada9d8f3 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Wed, 13 Aug 2025 14:44:11 -0500 Subject: [PATCH] Integrate Sonner for toasts --- package.json | 2 + src/App.native.tsx | 2 + src/App.web.tsx | 4 +- src/components/Toast/const.ts | 2 +- src/components/Toast/index.e2e.tsx | 2 +- src/components/Toast/index.tsx | 202 +++-------------------------- src/components/Toast/index.web.tsx | 112 ++-------------- yarn.lock | 10 ++ 8 files changed, 48 insertions(+), 288 deletions(-) diff --git a/package.json b/package.json index 6b9d45e317..ee5d276f4a 100644 --- a/package.json +++ b/package.json @@ -214,6 +214,8 @@ "react-remove-scroll-bar": "^2.3.8", "react-responsive": "^9.0.2", "react-textarea-autosize": "^8.5.3", + "sonner": "^2.0.7", + "sonner-native": "^0.21.0", "statsig-react-native-expo": "^4.6.1", "tippy.js": "^6.3.7", "tlds": "^1.234.0", diff --git a/src/App.native.tsx b/src/App.native.tsx index 0b46da9ddf..e22ab3f0e2 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -74,6 +74,7 @@ import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialo import {Provider as PolicyUpdateOverlayProvider} from '#/components/PolicyUpdateOverlay' import {Provider as PortalProvider} from '#/components/Portal' import {Provider as VideoVolumeProvider} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext' +import {ToastOutlet} from '#/components/Toast' import {Splash} from '#/Splash' import {BottomSheetProvider} from '../modules/bottom-sheet' import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider' @@ -165,6 +166,7 @@ function InnerApp() { + diff --git a/src/App.web.tsx b/src/App.web.tsx index dfa1e7480f..17434bfcdc 100644 --- a/src/App.web.tsx +++ b/src/App.web.tsx @@ -62,7 +62,7 @@ import {Provider as PolicyUpdateOverlayProvider} from '#/components/PolicyUpdate import {Provider as PortalProvider} from '#/components/Portal' import {Provider as ActiveVideoProvider} from '#/components/Post/Embed/VideoEmbed/ActiveVideoWebContext' import {Provider as VideoVolumeProvider} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext' -import {ToastContainer} from '#/components/Toast' +import {ToastOutlet} from '#/components/Toast' import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider' import {Provider as HideBottomBarBorderProvider} from './lib/hooks/useHideBottomBarBorder' @@ -142,6 +142,7 @@ function InnerApp() { + @@ -163,7 +164,6 @@ function InnerApp() { - diff --git a/src/components/Toast/const.ts b/src/components/Toast/const.ts index 034d0a2fce..77290a600e 100644 --- a/src/components/Toast/const.ts +++ b/src/components/Toast/const.ts @@ -1 +1 @@ -export const DEFAULT_TOAST_DURATION = 3000 +export const DEFAULT_TOAST_DURATION = 3e3 diff --git a/src/components/Toast/index.e2e.tsx b/src/components/Toast/index.e2e.tsx index 64072d88d0..eff368ac4f 100644 --- a/src/components/Toast/index.e2e.tsx +++ b/src/components/Toast/index.e2e.tsx @@ -1,6 +1,6 @@ import {type ToastApi} from '#/components/Toast/types' -export function ToastContainer() { +export function ToastOutlet() { return null } diff --git a/src/components/Toast/index.tsx b/src/components/Toast/index.tsx index 131a796b3f..1e73ffe641 100644 --- a/src/components/Toast/index.tsx +++ b/src/components/Toast/index.tsx @@ -1,197 +1,29 @@ -import {useEffect, useMemo, useRef, useState} from 'react' -import {AccessibilityInfo} from 'react-native' -import { - Gesture, - GestureDetector, - GestureHandlerRootView, -} from 'react-native-gesture-handler' -import Animated, { - Easing, - runOnJS, - SlideInUp, - SlideOutUp, - useAnimatedReaction, - useAnimatedStyle, - useSharedValue, - withDecay, - withSpring, -} from 'react-native-reanimated' -import RootSiblings from 'react-native-root-siblings' -import {useSafeAreaInsets} from 'react-native-safe-area-context' +import {View} from 'react-native' +import {toast as sonner, Toaster} from 'sonner-native' -import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {atoms as a} from '#/alf' import {DEFAULT_TOAST_DURATION} from '#/components/Toast/const' import {Toast} from '#/components/Toast/Toast' -import {type ToastApi, type ToastType} from '#/components/Toast/types' +import {type ToastApi} from '#/components/Toast/types' -const TOAST_ANIMATION_DURATION = 300 +function ToastOuter({children}: {children: React.ReactNode}) { + return {children} +} -export function ToastContainer() { - return null +export function ToastOutlet() { + return ( + + ) } export const toast: ToastApi = { show(props) { - if (process.env.NODE_ENV === 'test') { - return - } - - AccessibilityInfo.announceForAccessibility(props.a11yLabel) - - const item = new RootSiblings( - ( - item.destroy()} - /> - ), - ) + sonner.custom(, { + duration: props.duration ?? DEFAULT_TOAST_DURATION, + }) }, } - -function AnimatedToast({ - type, - content, - a11yLabel, - duration, - destroy, -}: { - type: ToastType - content: React.ReactNode - a11yLabel: string - duration: number - destroy: () => void -}) { - const {top} = useSafeAreaInsets() - const isPanning = useSharedValue(false) - const dismissSwipeTranslateY = useSharedValue(0) - const [cardHeight, setCardHeight] = useState(0) - - // for the exit animation to work on iOS the animated component - // must not be the root component - // so we need to wrap it in a view and unmount the toast ahead of time - const [alive, setAlive] = useState(true) - - const hideAndDestroyImmediately = () => { - setAlive(false) - setTimeout(() => { - destroy() - }, 1e3) - } - - const destroyTimeoutRef = useRef>() - const hideAndDestroyAfterTimeout = useNonReactiveCallback(() => { - clearTimeout(destroyTimeoutRef.current) - destroyTimeoutRef.current = setTimeout(hideAndDestroyImmediately, duration) - }) - const pauseDestroy = useNonReactiveCallback(() => { - clearTimeout(destroyTimeoutRef.current) - }) - - useEffect(() => { - hideAndDestroyAfterTimeout() - }, [hideAndDestroyAfterTimeout]) - - const panGesture = useMemo(() => { - return Gesture.Pan() - .activeOffsetY([-10, 10]) - .failOffsetX([-10, 10]) - .maxPointers(1) - .onStart(() => { - 'worklet' - if (!alive) return - isPanning.set(true) - runOnJS(pauseDestroy)() - }) - .onUpdate(e => { - 'worklet' - if (!alive) return - dismissSwipeTranslateY.value = e.translationY - }) - .onEnd(e => { - 'worklet' - if (!alive) return - runOnJS(hideAndDestroyAfterTimeout)() - isPanning.set(false) - if (e.velocityY < -100) { - if (dismissSwipeTranslateY.value === 0) { - // HACK: If the initial value is 0, withDecay() animation doesn't start. - // This is a bug in Reanimated, but for now we'll work around it like this. - dismissSwipeTranslateY.value = 1 - } - dismissSwipeTranslateY.value = withDecay({ - velocity: e.velocityY, - velocityFactor: Math.max(3500 / Math.abs(e.velocityY), 1), - deceleration: 1, - }) - } else { - dismissSwipeTranslateY.value = withSpring(0, { - stiffness: 500, - damping: 50, - }) - } - }) - }, [ - dismissSwipeTranslateY, - isPanning, - alive, - hideAndDestroyAfterTimeout, - pauseDestroy, - ]) - - const topOffset = top + 10 - - useAnimatedReaction( - () => - !isPanning.get() && - dismissSwipeTranslateY.get() < -topOffset - cardHeight, - (isSwipedAway, prevIsSwipedAway) => { - 'worklet' - if (isSwipedAway && !prevIsSwipedAway) { - runOnJS(destroy)() - } - }, - ) - - const animatedStyle = useAnimatedStyle(() => { - const translation = dismissSwipeTranslateY.get() - return { - transform: [ - { - translateY: translation > 0 ? translation ** 0.7 : translation, - }, - ], - } - }) - - return ( - - {alive && ( - setCardHeight(evt.nativeEvent.layout.height)} - accessibilityRole="alert" - accessible={true} - accessibilityLabel={a11yLabel} - accessibilityHint="" - onAccessibilityEscape={hideAndDestroyImmediately} - style={[a.flex_1, animatedStyle]}> - - - - - )} - - ) -} diff --git a/src/components/Toast/index.web.tsx b/src/components/Toast/index.web.tsx index f2517e28da..b432ca6b0f 100644 --- a/src/components/Toast/index.web.tsx +++ b/src/components/Toast/index.web.tsx @@ -1,112 +1,26 @@ -/* - * Note: relies on styles in #/styles.css - */ +import {toast as sonner, Toaster} from 'sonner' -import {useEffect, useState} from 'react' -import {AccessibilityInfo, Pressable, View} from 'react-native' -import {msg} from '@lingui/macro' -import {useLingui} from '@lingui/react' - -import {atoms as a, useBreakpoints} from '#/alf' +import {atoms as a} from '#/alf' import {DEFAULT_TOAST_DURATION} from '#/components/Toast/const' import {Toast} from '#/components/Toast/Toast' -import {type ToastApi, type ToastType} from '#/components/Toast/types' - -const TOAST_ANIMATION_STYLES = { - entering: { - animation: 'toastFadeIn 0.3s ease-out forwards', - }, - exiting: { - animation: 'toastFadeOut 0.2s ease-in forwards', - }, -} - -interface ActiveToast { - type: ToastType - content: React.ReactNode - a11yLabel: string -} -type GlobalSetActiveToast = (_activeToast: ActiveToast | undefined) => void -let globalSetActiveToast: GlobalSetActiveToast | undefined -let toastTimeout: NodeJS.Timeout | undefined -type ToastContainerProps = {} - -export const ToastContainer: React.FC = ({}) => { - const {_} = useLingui() - const {gtPhone} = useBreakpoints() - const [activeToast, setActiveToast] = useState() - const [isExiting, setIsExiting] = useState(false) - - useEffect(() => { - globalSetActiveToast = (t: ActiveToast | undefined) => { - if (!t && activeToast) { - setIsExiting(true) - setTimeout(() => { - setActiveToast(t) - setIsExiting(false) - }, 200) - } else { - if (t) { - AccessibilityInfo.announceForAccessibility(t.a11yLabel) - } - setActiveToast(t) - setIsExiting(false) - } - } - }, [activeToast]) +import {type ToastApi} from '#/components/Toast/types' +export function ToastOutlet() { return ( - <> - {activeToast && ( - - - setActiveToast(undefined)} - /> - - )} - + ) } export const toast: ToastApi = { show(props) { - if (toastTimeout) { - clearTimeout(toastTimeout) - } - - globalSetActiveToast?.({ - type: props.type, - content: props.content, - a11yLabel: props.a11yLabel, + sonner(, { + duration: props.duration ?? DEFAULT_TOAST_DURATION, + unstyled: true, }) - - toastTimeout = setTimeout(() => { - globalSetActiveToast?.(undefined) - }, props.duration || DEFAULT_TOAST_DURATION) }, } diff --git a/yarn.lock b/yarn.lock index d5c203fa42..dc6489bf85 100644 --- a/yarn.lock +++ b/yarn.lock @@ -18178,6 +18178,16 @@ sonic-boom@^3.7.0: dependencies: atomic-sleep "^1.0.0" +sonner-native@^0.21.0: + version "0.21.0" + resolved "https://registry.yarnpkg.com/sonner-native/-/sonner-native-0.21.0.tgz#b7968f8a7fdcdbc3b13b478455d63b24a45db829" + integrity sha512-pIdyMd722xuDukIvCZCmWh9Jy5FV+m9uKYl3oAzonYE+91eX5556vYrIik5MisDlBniSXaWqC9CnPoS6DKo2Lg== + +sonner@^2.0.7: + version "2.0.7" + resolved "https://registry.yarnpkg.com/sonner/-/sonner-2.0.7.tgz#810c1487a67ec3370126e0f400dfb9edddc3e4f6" + integrity sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w== + source-list-map@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/source-list-map/-/source-list-map-2.0.1.tgz#3993bd873bfc48479cca9ea3a547835c7c154b34"