From 7b2e61bf4dd1e10ade956b2ac091dbb44d41d525 Mon Sep 17 00:00:00 2001
From: Eric Bailey <git@esb.lol>
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/components/Toast/index.web.tsx | 134 +++++++++----------------------------
 1 file changed, 31 insertions(+), 103 deletions(-)

(limited to 'src/components/Toast/index.web.tsx')

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,
+  })
 }
-- 
cgit 1.4.1

