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/Toast.tsx b/src/components/Toast/Toast.tsx
index 908b470a4a..28220cb8d3 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 131a796b3f..286d414a14 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'
+import {DURATION} from '#/components/Toast/const'
+import {
+ Toast as BaseToast,
+ type ToastComponentProps,
+} from '#/components/Toast/Toast'
+import {type BaseToastOptions} from '#/components/Toast/types'
-const TOAST_ANIMATION_DURATION = 300
+export {DURATION} from '#/components/Toast/const'
-export function ToastContainer() {
- return null
+/**
+ * Toasts are rendered in a global outlet, which is placed at the top of the
+ * component tree.
+ */
+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()}
- />
- ),
- )
- },
-}
-
-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,
- },
- ],
- }
- })
-
+/**
+ * The toast UI component
+ */
+export function Toast({type, content}: ToastComponentProps) {
return (
-
- {alive && (
- setCardHeight(evt.nativeEvent.layout.height)}
- accessibilityRole="alert"
- accessible={true}
- accessibilityLabel={a11yLabel}
- accessibilityHint=""
- onAccessibilityEscape={hideAndDestroyImmediately}
- style={[a.flex_1, animatedStyle]}>
-
-
-
-
- )}
-
+
+
+
)
}
+
+/**
+ * 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,
+ })
+}
diff --git a/src/components/Toast/index.web.tsx b/src/components/Toast/index.web.tsx
index f2517e28da..857ed7b39a 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 {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 {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)
- }
+/**
+ * Access the full Sonner API
+ */
+export const api = sonner
- globalSetActiveToast?.({
- type: props.type,
- content: props.content,
- a11yLabel: props.a11yLabel,
- })
-
- 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 9f1245fa22..463e6d66ca 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 37ec6acb53..820c9f9d71 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 8fc6f095f7..98d5b05e32 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',
})
}>
-
+
-
+ }>
+
+
)
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"