From 7b2e61bf4dd1e10ade956b2ac091dbb44d41d525 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Thu, 14 Aug 2025 09:51:40 -0500 Subject: Integrate Sonner for toasts (#8839) * Integrate Sonner for toasts * Fix animation on iOS * Refactor API * Update e2e file --- src/App.native.tsx | 2 + src/App.web.tsx | 4 +- src/components/Toast/Toast.tsx | 15 +-- src/components/Toast/const.ts | 2 +- src/components/Toast/index.e2e.tsx | 19 ++- src/components/Toast/index.tsx | 230 ++++++---------------------------- src/components/Toast/index.web.tsx | 134 +++++--------------- src/components/Toast/types.ts | 47 +++---- src/view/com/util/Toast.tsx | 8 +- src/view/screens/Storybook/Toasts.tsx | 77 ++++-------- 10 files changed, 152 insertions(+), 386 deletions(-) (limited to 'src') diff --git a/src/App.native.tsx b/src/App.native.tsx index 0b46da9dd..e22ab3f0e 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 dfa1e7480..17434bfcd 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/Toast.tsx b/src/components/Toast/Toast.tsx index 908b470a4..28220cb8d 100644 --- a/src/components/Toast/Toast.tsx +++ b/src/components/Toast/Toast.tsx @@ -13,6 +13,11 @@ type ContextType = { type: ToastType } +export type ToastComponentProps = { + type?: ToastType + content: React.ReactNode +} + export const ICONS = { default: CircleCheck, success: CircleCheck, @@ -26,13 +31,7 @@ const Context = createContext({ }) Context.displayName = 'ToastContext' -export function Toast({ - type, - content, -}: { - type: ToastType - content: React.ReactNode -}) { +export function Toast({type = 'default', content}: ToastComponentProps) { const {fonts} = useAlf() const t = useTheme() const styles = useToastStyles({type}) @@ -90,10 +89,12 @@ export function ToastText({children}: {children: React.ReactNode}) { const {textColor} = useToastStyles({type}) return ( {} +api.success = () => {} +api.wiggle = () => {} +api.error = () => {} +api.warning = () => {} +api.info = () => {} +api.promise = () => {} +api.custom = () => {} +api.loading = () => {} +api.dismiss = () => {} + +export function show() {} diff --git a/src/components/Toast/index.tsx b/src/components/Toast/index.tsx index 131a796b3..286d414a1 100644 --- a/src/components/Toast/index.tsx +++ b/src/components/Toast/index.tsx @@ -1,197 +1,49 @@ -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' - -const TOAST_ANIMATION_DURATION = 300 - -export function ToastContainer() { - return null -} - -export const toast: ToastApi = { - show(props) { - if (process.env.NODE_ENV === 'test') { - return - } - - AccessibilityInfo.announceForAccessibility(props.a11yLabel) - - const item = new RootSiblings( - ( - item.destroy()} - /> - ), - ) - }, +import {DURATION} from '#/components/Toast/const' +import { + Toast as BaseToast, + type ToastComponentProps, +} from '#/components/Toast/Toast' +import {type BaseToastOptions} from '#/components/Toast/types' + +export {DURATION} from '#/components/Toast/const' + +/** + * Toasts are rendered in a global outlet, which is placed at the top of the + * component tree. + */ +export function ToastOutlet() { + return } -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)() - } - }, +/** + * The toast UI component + */ +export function Toast({type, content}: ToastComponentProps) { + return ( + + + ) +} - const animatedStyle = useAnimatedStyle(() => { - const translation = dismissSwipeTranslateY.get() - return { - transform: [ - { - translateY: translation > 0 ? translation ** 0.7 : translation, - }, - ], - } +/** + * Access the full Sonner API + */ +export const api = sonner + +/** + * Our base toast API, using the `Toast` export of this file. + */ +export function show( + content: React.ReactNode, + {type, ...options}: BaseToastOptions = {}, +) { + sonner.custom(, { + ...options, + duration: options?.duration ?? DURATION, }) - - 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 f2517e28d..857ed7b39 100644 --- a/src/components/Toast/index.web.tsx +++ b/src/components/Toast/index.web.tsx @@ -1,112 +1,40 @@ -/* - * Note: relies on styles in #/styles.css - */ - -import {useEffect, useState} from 'react' -import {AccessibilityInfo, Pressable, View} from 'react-native' -import {msg} from '@lingui/macro' -import {useLingui} from '@lingui/react' +import {toast as sonner, Toaster} from 'sonner' -import {atoms as a, useBreakpoints} from '#/alf' -import {DEFAULT_TOAST_DURATION} from '#/components/Toast/const' +import {atoms as a} from '#/alf' +import {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 BaseToastOptions} from '#/components/Toast/types' +/** + * Toasts are rendered in a global outlet, which is placed at the top of the + * component tree. + */ +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, - }) +/** + * Access the full Sonner API + */ +export const api = sonner - toastTimeout = setTimeout(() => { - globalSetActiveToast?.(undefined) - }, props.duration || DEFAULT_TOAST_DURATION) - }, +/** + * Our base toast API, using the `Toast` export of this file. + */ +export function show( + content: React.ReactNode, + {type, ...options}: BaseToastOptions = {}, +) { + sonner(, { + unstyled: true, // required on web + ...options, + duration: options?.duration ?? DURATION, + }) } diff --git a/src/components/Toast/types.ts b/src/components/Toast/types.ts index 9f1245fa2..463e6d66c 100644 --- a/src/components/Toast/types.ts +++ b/src/components/Toast/types.ts @@ -1,24 +1,29 @@ +import {type toast as sonner} from 'sonner-native' + +/** + * This is not exported from `sonner-native` so just hacking it in here. + */ +export type ExternalToast = Exclude< + Parameters[1], + undefined +> + export type ToastType = 'default' | 'success' | 'error' | 'warning' | 'info' -export type ToastApi = { - show: (props: { - /** - * The type of toast to show. This determines the styling and icon used. - */ - type: ToastType - /** - * A string, `Text`, or `Span` components to render inside the toast. This - * allows additional formatting of the content, but should not be used for - * interactive elements link links or buttons. - */ - content: React.ReactNode | string - /** - * Accessibility label for the toast, used for screen readers. - */ - a11yLabel: string - /** - * Defaults to `DEFAULT_TOAST_DURATION` from `#components/Toast/const`. - */ - duration?: number - }) => void +/** + * Not all properties are available on all platforms, so we pick out only those + * we support. Add more here as needed. + */ +export type BaseToastOptions = Pick< + ExternalToast, + 'duration' | 'dismissible' | 'promiseOptions' +> & { + type?: ToastType + + /** + * These methods differ between web/native implementations + */ + onDismiss?: () => void + onPress?: () => void + onAutoClose?: () => void } diff --git a/src/view/com/util/Toast.tsx b/src/view/com/util/Toast.tsx index 37ec6acb5..820c9f9d7 100644 --- a/src/view/com/util/Toast.tsx +++ b/src/view/com/util/Toast.tsx @@ -1,4 +1,4 @@ -import {toast} from '#/components/Toast' +import * as toast from '#/components/Toast' import {type ToastType} from '#/components/Toast/types' /** @@ -46,9 +46,5 @@ export function show( type: ToastType | LegacyToastType = 'default', ): void { const convertedType = convertLegacyToastType(type) - toast.show({ - type: convertedType, - content: message, - a11yLabel: message, - }) + toast.show(message, {type: convertedType}) } diff --git a/src/view/screens/Storybook/Toasts.tsx b/src/view/screens/Storybook/Toasts.tsx index 8fc6f095f..98d5b05e3 100644 --- a/src/view/screens/Storybook/Toasts.tsx +++ b/src/view/screens/Storybook/Toasts.tsx @@ -2,8 +2,7 @@ import {Pressable, View} from 'react-native' import {show as deprecatedShow} from '#/view/com/util/Toast' import {atoms as a} from '#/alf' -import {Button, ButtonText} from '#/components/Button' -import {toast} from '#/components/Toast' +import * as toast from '#/components/Toast' import {Toast} from '#/components/Toast/Toast' import {H1} from '#/components/Typography' @@ -15,101 +14,77 @@ export function Toasts() { - toast.show({ - type: 'default', - content: 'Default toast', - a11yLabel: 'Default toast', - }) - }> - + onPress={() => toast.show(`Hey I'm a toast!`)}> + - toast.show({ - type: 'default', - content: 'Default toast, 6 seconds', - a11yLabel: 'Default toast, 6 seconds', + toast.show(`This toast will disappear after 6 seconds`, { duration: 6e3, }) }> - + - toast.show({ - type: 'default', - content: - 'This is a longer message to test how the toast handles multiple lines of text content.', - a11yLabel: - 'This is a longer message to test how the toast handles multiple lines of text content.', - }) + toast.show( + `This is a longer message to test how the toast handles multiple lines of text content.`, + ) }> - + - toast.show({ + toast.show(`Success! Yayyyyyyy :)`, { type: 'success', - content: 'Success toast', - a11yLabel: 'Success toast', }) }> - + - toast.show({ + toast.show(`I'm providing info!`, { type: 'info', - content: 'Info toast', - a11yLabel: 'Info toast', }) }> - + - toast.show({ + toast.show(`This is a warning toast`, { type: 'warning', - content: 'Warning toast', - a11yLabel: 'Warning toast', }) }> - + - toast.show({ + toast.show(`This is an error toast :(`, { type: 'error', - content: 'Error toast', - a11yLabel: 'Error toast', }) }> - + - + }> + + ) -- cgit 1.4.1