about summary refs log tree commit diff
path: root/src/components
diff options
context:
space:
mode:
authorEric Bailey <git@esb.lol>2025-08-14 09:51:40 -0500
committerGitHub <noreply@github.com>2025-08-14 09:51:40 -0500
commit7b2e61bf4dd1e10ade956b2ac091dbb44d41d525 (patch)
treea29f4b3543bb4846e97af2d4425e311c86826947 /src/components
parent221623f55aa6c1bbe699c8d409832da110923c76 (diff)
downloadvoidsky-7b2e61bf4dd1e10ade956b2ac091dbb44d41d525.tar.zst
Integrate Sonner for toasts (#8839)
* Integrate Sonner for toasts

* Fix animation on iOS

* Refactor API

* Update e2e file
Diffstat (limited to 'src/components')
-rw-r--r--src/components/Toast/Toast.tsx15
-rw-r--r--src/components/Toast/const.ts2
-rw-r--r--src/components/Toast/index.e2e.tsx19
-rw-r--r--src/components/Toast/index.tsx230
-rw-r--r--src/components/Toast/index.web.tsx134
-rw-r--r--src/components/Toast/types.ts47
6 files changed, 120 insertions, 327 deletions
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<ContextType>({
 })
 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 (
     <Text
+      selectable={false}
       style={[
         a.text_md,
         a.font_bold,
         a.leading_snug,
+        a.pointer_events_none,
         {
           color: textColor,
         },
diff --git a/src/components/Toast/const.ts b/src/components/Toast/const.ts
index 034d0a2fc..d63832bdd 100644
--- a/src/components/Toast/const.ts
+++ b/src/components/Toast/const.ts
@@ -1 +1 @@
-export const DEFAULT_TOAST_DURATION = 3000
+export const DURATION = 3e3
diff --git a/src/components/Toast/index.e2e.tsx b/src/components/Toast/index.e2e.tsx
index 64072d88d..357bd8dda 100644
--- a/src/components/Toast/index.e2e.tsx
+++ b/src/components/Toast/index.e2e.tsx
@@ -1,9 +1,16 @@
-import {type ToastApi} from '#/components/Toast/types'
-
-export function ToastContainer() {
+export function ToastOutlet() {
   return null
 }
 
-export const toast: ToastApi = {
-  show() {},
-}
+export const api = () => {}
+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(
-      (
-        <AnimatedToast
-          type={props.type}
-          content={props.content}
-          a11yLabel={props.a11yLabel}
-          duration={props.duration ?? DEFAULT_TOAST_DURATION}
-          destroy={() => 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 <Toaster pauseWhenPageIsHidden gap={a.gap_sm.gap} />
 }
 
-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<ReturnType<typeof setTimeout>>()
-  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 (
+    <View style={[a.px_xl, a.w_full]}>
+      <BaseToast content={content} type={type} />
+    </View>
   )
+}
 
-  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(<Toast content={content} type={type} />, {
+    ...options,
+    duration: options?.duration ?? DURATION,
   })
-
-  return (
-    <GestureHandlerRootView
-      style={[a.absolute, {top: topOffset, left: 16, right: 16}]}
-      pointerEvents="box-none">
-      {alive && (
-        <Animated.View
-          entering={SlideInUp.easing(Easing.out(Easing.exp)).duration(
-            TOAST_ANIMATION_DURATION,
-          )}
-          exiting={SlideOutUp.easing(Easing.in(Easing.exp)).duration(
-            TOAST_ANIMATION_DURATION * 0.7,
-          )}
-          onLayout={evt => setCardHeight(evt.nativeEvent.layout.height)}
-          accessibilityRole="alert"
-          accessible={true}
-          accessibilityLabel={a11yLabel}
-          accessibilityHint=""
-          onAccessibilityEscape={hideAndDestroyImmediately}
-          style={[a.flex_1, animatedStyle]}>
-          <GestureDetector gesture={panGesture}>
-            <Toast content={content} type={type} />
-          </GestureDetector>
-        </Animated.View>
-      )}
-    </GestureHandlerRootView>
-  )
 }
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<ToastContainerProps> = ({}) => {
-  const {_} = useLingui()
-  const {gtPhone} = useBreakpoints()
-  const [activeToast, setActiveToast] = useState<ActiveToast | undefined>()
-  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 && (
-        <View
-          style={[
-            a.fixed,
-            {
-              left: a.px_xl.paddingLeft,
-              right: a.px_xl.paddingLeft,
-              bottom: a.px_xl.paddingLeft,
-              ...(isExiting
-                ? TOAST_ANIMATION_STYLES.exiting
-                : TOAST_ANIMATION_STYLES.entering),
-            },
-            gtPhone && [
-              {
-                maxWidth: 380,
-              },
-            ],
-          ]}>
-          <Toast content={activeToast.content} type={activeToast.type} />
-          <Pressable
-            style={[a.absolute, a.inset_0]}
-            accessibilityLabel={_(
-              msg({
-                message: `Dismiss message`,
-                comment: `Accessibility label for dismissing a toast notification`,
-              }),
-            )}
-            accessibilityHint=""
-            onPress={() => setActiveToast(undefined)}
-          />
-        </View>
-      )}
-    </>
+    <Toaster
+      position="bottom-left"
+      gap={a.gap_sm.gap}
+      offset={a.p_xl.padding}
+      mobileOffset={a.p_xl.padding}
+    />
   )
 }
 
-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(<Toast content={content} type={type} />, {
+    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<typeof sonner.custom>[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
 }