···11-import {useEffect, useMemo, useRef, useState} from 'react'
22-import {AccessibilityInfo} from 'react-native'
33-import {
44- Gesture,
55- GestureDetector,
66- GestureHandlerRootView,
77-} from 'react-native-gesture-handler'
88-import Animated, {
99- Easing,
1010- runOnJS,
1111- SlideInUp,
1212- SlideOutUp,
1313- useAnimatedReaction,
1414- useAnimatedStyle,
1515- useSharedValue,
1616- withDecay,
1717- withSpring,
1818-} from 'react-native-reanimated'
1919-import RootSiblings from 'react-native-root-siblings'
2020-import {useSafeAreaInsets} from 'react-native-safe-area-context'
11+import {View} from 'react-native'
22+import {toast as sonner, Toaster} from 'sonner-native'
2132222-import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
234import {atoms as a} from '#/alf'
2424-import {DEFAULT_TOAST_DURATION} from '#/components/Toast/const'
2525-import {Toast} from '#/components/Toast/Toast'
2626-import {type ToastApi, type ToastType} from '#/components/Toast/types'
55+import {DURATION} from '#/components/Toast/const'
66+import {
77+ Toast as BaseToast,
88+ type ToastComponentProps,
99+} from '#/components/Toast/Toast'
1010+import {type BaseToastOptions} from '#/components/Toast/types'
27112828-const TOAST_ANIMATION_DURATION = 300
1212+export {DURATION} from '#/components/Toast/const'
29133030-export function ToastContainer() {
3131- return null
1414+/**
1515+ * Toasts are rendered in a global outlet, which is placed at the top of the
1616+ * component tree.
1717+ */
1818+export function ToastOutlet() {
1919+ return <Toaster pauseWhenPageIsHidden gap={a.gap_sm.gap} />
3220}
33213434-export const toast: ToastApi = {
3535- show(props) {
3636- if (process.env.NODE_ENV === 'test') {
3737- return
3838- }
3939-4040- AccessibilityInfo.announceForAccessibility(props.a11yLabel)
4141-4242- const item = new RootSiblings(
4343- (
4444- <AnimatedToast
4545- type={props.type}
4646- content={props.content}
4747- a11yLabel={props.a11yLabel}
4848- duration={props.duration ?? DEFAULT_TOAST_DURATION}
4949- destroy={() => item.destroy()}
5050- />
5151- ),
5252- )
5353- },
2222+/**
2323+ * The toast UI component
2424+ */
2525+export function Toast({type, content}: ToastComponentProps) {
2626+ return (
2727+ <View style={[a.px_xl, a.w_full]}>
2828+ <BaseToast content={content} type={type} />
2929+ </View>
3030+ )
5431}
55325656-function AnimatedToast({
5757- type,
5858- content,
5959- a11yLabel,
6060- duration,
6161- destroy,
6262-}: {
6363- type: ToastType
6464- content: React.ReactNode
6565- a11yLabel: string
6666- duration: number
6767- destroy: () => void
6868-}) {
6969- const {top} = useSafeAreaInsets()
7070- const isPanning = useSharedValue(false)
7171- const dismissSwipeTranslateY = useSharedValue(0)
7272- const [cardHeight, setCardHeight] = useState(0)
7373-7474- // for the exit animation to work on iOS the animated component
7575- // must not be the root component
7676- // so we need to wrap it in a view and unmount the toast ahead of time
7777- const [alive, setAlive] = useState(true)
7878-7979- const hideAndDestroyImmediately = () => {
8080- setAlive(false)
8181- setTimeout(() => {
8282- destroy()
8383- }, 1e3)
8484- }
8585-8686- const destroyTimeoutRef = useRef<ReturnType<typeof setTimeout>>()
8787- const hideAndDestroyAfterTimeout = useNonReactiveCallback(() => {
8888- clearTimeout(destroyTimeoutRef.current)
8989- destroyTimeoutRef.current = setTimeout(hideAndDestroyImmediately, duration)
9090- })
9191- const pauseDestroy = useNonReactiveCallback(() => {
9292- clearTimeout(destroyTimeoutRef.current)
9393- })
9494-9595- useEffect(() => {
9696- hideAndDestroyAfterTimeout()
9797- }, [hideAndDestroyAfterTimeout])
9898-9999- const panGesture = useMemo(() => {
100100- return Gesture.Pan()
101101- .activeOffsetY([-10, 10])
102102- .failOffsetX([-10, 10])
103103- .maxPointers(1)
104104- .onStart(() => {
105105- 'worklet'
106106- if (!alive) return
107107- isPanning.set(true)
108108- runOnJS(pauseDestroy)()
109109- })
110110- .onUpdate(e => {
111111- 'worklet'
112112- if (!alive) return
113113- dismissSwipeTranslateY.value = e.translationY
114114- })
115115- .onEnd(e => {
116116- 'worklet'
117117- if (!alive) return
118118- runOnJS(hideAndDestroyAfterTimeout)()
119119- isPanning.set(false)
120120- if (e.velocityY < -100) {
121121- if (dismissSwipeTranslateY.value === 0) {
122122- // HACK: If the initial value is 0, withDecay() animation doesn't start.
123123- // This is a bug in Reanimated, but for now we'll work around it like this.
124124- dismissSwipeTranslateY.value = 1
125125- }
126126- dismissSwipeTranslateY.value = withDecay({
127127- velocity: e.velocityY,
128128- velocityFactor: Math.max(3500 / Math.abs(e.velocityY), 1),
129129- deceleration: 1,
130130- })
131131- } else {
132132- dismissSwipeTranslateY.value = withSpring(0, {
133133- stiffness: 500,
134134- damping: 50,
135135- })
136136- }
137137- })
138138- }, [
139139- dismissSwipeTranslateY,
140140- isPanning,
141141- alive,
142142- hideAndDestroyAfterTimeout,
143143- pauseDestroy,
144144- ])
145145-146146- const topOffset = top + 10
147147-148148- useAnimatedReaction(
149149- () =>
150150- !isPanning.get() &&
151151- dismissSwipeTranslateY.get() < -topOffset - cardHeight,
152152- (isSwipedAway, prevIsSwipedAway) => {
153153- 'worklet'
154154- if (isSwipedAway && !prevIsSwipedAway) {
155155- runOnJS(destroy)()
156156- }
157157- },
158158- )
3333+/**
3434+ * Access the full Sonner API
3535+ */
3636+export const api = sonner
15937160160- const animatedStyle = useAnimatedStyle(() => {
161161- const translation = dismissSwipeTranslateY.get()
162162- return {
163163- transform: [
164164- {
165165- translateY: translation > 0 ? translation ** 0.7 : translation,
166166- },
167167- ],
168168- }
3838+/**
3939+ * Our base toast API, using the `Toast` export of this file.
4040+ */
4141+export function show(
4242+ content: React.ReactNode,
4343+ {type, ...options}: BaseToastOptions = {},
4444+) {
4545+ sonner.custom(<Toast content={content} type={type} />, {
4646+ ...options,
4747+ duration: options?.duration ?? DURATION,
16948 })
170170-171171- return (
172172- <GestureHandlerRootView
173173- style={[a.absolute, {top: topOffset, left: 16, right: 16}]}
174174- pointerEvents="box-none">
175175- {alive && (
176176- <Animated.View
177177- entering={SlideInUp.easing(Easing.out(Easing.exp)).duration(
178178- TOAST_ANIMATION_DURATION,
179179- )}
180180- exiting={SlideOutUp.easing(Easing.in(Easing.exp)).duration(
181181- TOAST_ANIMATION_DURATION * 0.7,
182182- )}
183183- onLayout={evt => setCardHeight(evt.nativeEvent.layout.height)}
184184- accessibilityRole="alert"
185185- accessible={true}
186186- accessibilityLabel={a11yLabel}
187187- accessibilityHint=""
188188- onAccessibilityEscape={hideAndDestroyImmediately}
189189- style={[a.flex_1, animatedStyle]}>
190190- <GestureDetector gesture={panGesture}>
191191- <Toast content={content} type={type} />
192192- </GestureDetector>
193193- </Animated.View>
194194- )}
195195- </GestureHandlerRootView>
196196- )
19749}
+31-103
src/components/Toast/index.web.tsx
···11-/*
22- * Note: relies on styles in #/styles.css
33- */
11+import {toast as sonner, Toaster} from 'sonner'
4255-import {useEffect, useState} from 'react'
66-import {AccessibilityInfo, Pressable, View} from 'react-native'
77-import {msg} from '@lingui/macro'
88-import {useLingui} from '@lingui/react'
99-1010-import {atoms as a, useBreakpoints} from '#/alf'
1111-import {DEFAULT_TOAST_DURATION} from '#/components/Toast/const'
33+import {atoms as a} from '#/alf'
44+import {DURATION} from '#/components/Toast/const'
125import {Toast} from '#/components/Toast/Toast'
1313-import {type ToastApi, type ToastType} from '#/components/Toast/types'
1414-1515-const TOAST_ANIMATION_STYLES = {
1616- entering: {
1717- animation: 'toastFadeIn 0.3s ease-out forwards',
1818- },
1919- exiting: {
2020- animation: 'toastFadeOut 0.2s ease-in forwards',
2121- },
2222-}
66+import {type BaseToastOptions} from '#/components/Toast/types'
2372424-interface ActiveToast {
2525- type: ToastType
2626- content: React.ReactNode
2727- a11yLabel: string
2828-}
2929-type GlobalSetActiveToast = (_activeToast: ActiveToast | undefined) => void
3030-let globalSetActiveToast: GlobalSetActiveToast | undefined
3131-let toastTimeout: NodeJS.Timeout | undefined
3232-type ToastContainerProps = {}
3333-3434-export const ToastContainer: React.FC<ToastContainerProps> = ({}) => {
3535- const {_} = useLingui()
3636- const {gtPhone} = useBreakpoints()
3737- const [activeToast, setActiveToast] = useState<ActiveToast | undefined>()
3838- const [isExiting, setIsExiting] = useState(false)
3939-4040- useEffect(() => {
4141- globalSetActiveToast = (t: ActiveToast | undefined) => {
4242- if (!t && activeToast) {
4343- setIsExiting(true)
4444- setTimeout(() => {
4545- setActiveToast(t)
4646- setIsExiting(false)
4747- }, 200)
4848- } else {
4949- if (t) {
5050- AccessibilityInfo.announceForAccessibility(t.a11yLabel)
5151- }
5252- setActiveToast(t)
5353- setIsExiting(false)
5454- }
5555- }
5656- }, [activeToast])
5757-88+/**
99+ * Toasts are rendered in a global outlet, which is placed at the top of the
1010+ * component tree.
1111+ */
1212+export function ToastOutlet() {
5813 return (
5959- <>
6060- {activeToast && (
6161- <View
6262- style={[
6363- a.fixed,
6464- {
6565- left: a.px_xl.paddingLeft,
6666- right: a.px_xl.paddingLeft,
6767- bottom: a.px_xl.paddingLeft,
6868- ...(isExiting
6969- ? TOAST_ANIMATION_STYLES.exiting
7070- : TOAST_ANIMATION_STYLES.entering),
7171- },
7272- gtPhone && [
7373- {
7474- maxWidth: 380,
7575- },
7676- ],
7777- ]}>
7878- <Toast content={activeToast.content} type={activeToast.type} />
7979- <Pressable
8080- style={[a.absolute, a.inset_0]}
8181- accessibilityLabel={_(
8282- msg({
8383- message: `Dismiss message`,
8484- comment: `Accessibility label for dismissing a toast notification`,
8585- }),
8686- )}
8787- accessibilityHint=""
8888- onPress={() => setActiveToast(undefined)}
8989- />
9090- </View>
9191- )}
9292- </>
1414+ <Toaster
1515+ position="bottom-left"
1616+ gap={a.gap_sm.gap}
1717+ offset={a.p_xl.padding}
1818+ mobileOffset={a.p_xl.padding}
1919+ />
9320 )
9421}
95229696-export const toast: ToastApi = {
9797- show(props) {
9898- if (toastTimeout) {
9999- clearTimeout(toastTimeout)
100100- }
2323+/**
2424+ * Access the full Sonner API
2525+ */
2626+export const api = sonner
10127102102- globalSetActiveToast?.({
103103- type: props.type,
104104- content: props.content,
105105- a11yLabel: props.a11yLabel,
106106- })
107107-108108- toastTimeout = setTimeout(() => {
109109- globalSetActiveToast?.(undefined)
110110- }, props.duration || DEFAULT_TOAST_DURATION)
111111- },
2828+/**
2929+ * Our base toast API, using the `Toast` export of this file.
3030+ */
3131+export function show(
3232+ content: React.ReactNode,
3333+ {type, ...options}: BaseToastOptions = {},
3434+) {
3535+ sonner(<Toast content={content} type={type} />, {
3636+ unstyled: true, // required on web
3737+ ...options,
3838+ duration: options?.duration ?? DURATION,
3939+ })
11240}
+26-21
src/components/Toast/types.ts
···11+import {type toast as sonner} from 'sonner-native'
22+33+/**
44+ * This is not exported from `sonner-native` so just hacking it in here.
55+ */
66+export type ExternalToast = Exclude<
77+ Parameters<typeof sonner.custom>[1],
88+ undefined
99+>
1010+111export type ToastType = 'default' | 'success' | 'error' | 'warning' | 'info'
21233-export type ToastApi = {
44- show: (props: {
55- /**
66- * The type of toast to show. This determines the styling and icon used.
77- */
88- type: ToastType
99- /**
1010- * A string, `Text`, or `Span` components to render inside the toast. This
1111- * allows additional formatting of the content, but should not be used for
1212- * interactive elements link links or buttons.
1313- */
1414- content: React.ReactNode | string
1515- /**
1616- * Accessibility label for the toast, used for screen readers.
1717- */
1818- a11yLabel: string
1919- /**
2020- * Defaults to `DEFAULT_TOAST_DURATION` from `#components/Toast/const`.
2121- */
2222- duration?: number
2323- }) => void
1313+/**
1414+ * Not all properties are available on all platforms, so we pick out only those
1515+ * we support. Add more here as needed.
1616+ */
1717+export type BaseToastOptions = Pick<
1818+ ExternalToast,
1919+ 'duration' | 'dismissible' | 'promiseOptions'
2020+> & {
2121+ type?: ToastType
2222+2323+ /**
2424+ * These methods differ between web/native implementations
2525+ */
2626+ onDismiss?: () => void
2727+ onPress?: () => void
2828+ onAutoClose?: () => void
2429}