Bluesky app fork with some witchin' additions 💫 witchsky.app
bluesky fork client

Integrate Sonner for toasts (#8839)

* Integrate Sonner for toasts

* Fix animation on iOS

* Refactor API

* Update e2e file

authored by

Eric Bailey and committed by
GitHub
7b2e61bf 221623f5

+161 -383
+2
package.json
··· 214 214 "react-remove-scroll-bar": "^2.3.8", 215 215 "react-responsive": "^9.0.2", 216 216 "react-textarea-autosize": "^8.5.3", 217 + "sonner": "^2.0.7", 218 + "sonner-native": "^0.21.0", 217 219 "statsig-react-native-expo": "^4.6.1", 218 220 "tippy.js": "^6.3.7", 219 221 "tlds": "^1.234.0",
+2
src/App.native.tsx
··· 74 74 import {Provider as PolicyUpdateOverlayProvider} from '#/components/PolicyUpdateOverlay' 75 75 import {Provider as PortalProvider} from '#/components/Portal' 76 76 import {Provider as VideoVolumeProvider} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext' 77 + import {ToastOutlet} from '#/components/Toast' 77 78 import {Splash} from '#/Splash' 78 79 import {BottomSheetProvider} from '../modules/bottom-sheet' 79 80 import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider' ··· 165 166 <TestCtrls /> 166 167 <Shell /> 167 168 <NuxDialogs /> 169 + <ToastOutlet /> 168 170 </IntentDialogProvider> 169 171 </GlobalGestureEventsProvider> 170 172 </GestureHandlerRootView>
+2 -2
src/App.web.tsx
··· 62 62 import {Provider as PortalProvider} from '#/components/Portal' 63 63 import {Provider as ActiveVideoProvider} from '#/components/Post/Embed/VideoEmbed/ActiveVideoWebContext' 64 64 import {Provider as VideoVolumeProvider} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext' 65 - import {ToastContainer} from '#/components/Toast' 65 + import {ToastOutlet} from '#/components/Toast' 66 66 import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider' 67 67 import {Provider as HideBottomBarBorderProvider} from './lib/hooks/useHideBottomBarBorder' 68 68 ··· 142 142 <IntentDialogProvider> 143 143 <Shell /> 144 144 <NuxDialogs /> 145 + <ToastOutlet /> 145 146 </IntentDialogProvider> 146 147 </HideBottomBarBorderProvider> 147 148 </EmailVerificationProvider> ··· 163 164 </StatsigProvider> 164 165 </PolicyUpdateOverlayProvider> 165 166 </QueryProvider> 166 - <ToastContainer /> 167 167 </React.Fragment> 168 168 </ActiveVideoProvider> 169 169 </VideoVolumeProvider>
+8 -7
src/components/Toast/Toast.tsx
··· 13 13 type: ToastType 14 14 } 15 15 16 + export type ToastComponentProps = { 17 + type?: ToastType 18 + content: React.ReactNode 19 + } 20 + 16 21 export const ICONS = { 17 22 default: CircleCheck, 18 23 success: CircleCheck, ··· 26 31 }) 27 32 Context.displayName = 'ToastContext' 28 33 29 - export function Toast({ 30 - type, 31 - content, 32 - }: { 33 - type: ToastType 34 - content: React.ReactNode 35 - }) { 34 + export function Toast({type = 'default', content}: ToastComponentProps) { 36 35 const {fonts} = useAlf() 37 36 const t = useTheme() 38 37 const styles = useToastStyles({type}) ··· 90 89 const {textColor} = useToastStyles({type}) 91 90 return ( 92 91 <Text 92 + selectable={false} 93 93 style={[ 94 94 a.text_md, 95 95 a.font_bold, 96 96 a.leading_snug, 97 + a.pointer_events_none, 97 98 { 98 99 color: textColor, 99 100 },
+1 -1
src/components/Toast/const.ts
··· 1 - export const DEFAULT_TOAST_DURATION = 3000 1 + export const DURATION = 3e3
+13 -6
src/components/Toast/index.e2e.tsx
··· 1 - import {type ToastApi} from '#/components/Toast/types' 2 - 3 - export function ToastContainer() { 1 + export function ToastOutlet() { 4 2 return null 5 3 } 6 4 7 - export const toast: ToastApi = { 8 - show() {}, 9 - } 5 + export const api = () => {} 6 + api.success = () => {} 7 + api.wiggle = () => {} 8 + api.error = () => {} 9 + api.warning = () => {} 10 + api.info = () => {} 11 + api.promise = () => {} 12 + api.custom = () => {} 13 + api.loading = () => {} 14 + api.dismiss = () => {} 15 + 16 + export function show() {}
+38 -186
src/components/Toast/index.tsx
··· 1 - import {useEffect, useMemo, useRef, useState} from 'react' 2 - import {AccessibilityInfo} from 'react-native' 3 - import { 4 - Gesture, 5 - GestureDetector, 6 - GestureHandlerRootView, 7 - } from 'react-native-gesture-handler' 8 - import Animated, { 9 - Easing, 10 - runOnJS, 11 - SlideInUp, 12 - SlideOutUp, 13 - useAnimatedReaction, 14 - useAnimatedStyle, 15 - useSharedValue, 16 - withDecay, 17 - withSpring, 18 - } from 'react-native-reanimated' 19 - import RootSiblings from 'react-native-root-siblings' 20 - import {useSafeAreaInsets} from 'react-native-safe-area-context' 1 + import {View} from 'react-native' 2 + import {toast as sonner, Toaster} from 'sonner-native' 21 3 22 - import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' 23 4 import {atoms as a} from '#/alf' 24 - import {DEFAULT_TOAST_DURATION} from '#/components/Toast/const' 25 - import {Toast} from '#/components/Toast/Toast' 26 - import {type ToastApi, type ToastType} from '#/components/Toast/types' 5 + import {DURATION} from '#/components/Toast/const' 6 + import { 7 + Toast as BaseToast, 8 + type ToastComponentProps, 9 + } from '#/components/Toast/Toast' 10 + import {type BaseToastOptions} from '#/components/Toast/types' 27 11 28 - const TOAST_ANIMATION_DURATION = 300 12 + export {DURATION} from '#/components/Toast/const' 29 13 30 - export function ToastContainer() { 31 - return null 14 + /** 15 + * Toasts are rendered in a global outlet, which is placed at the top of the 16 + * component tree. 17 + */ 18 + export function ToastOutlet() { 19 + return <Toaster pauseWhenPageIsHidden gap={a.gap_sm.gap} /> 32 20 } 33 21 34 - export const toast: ToastApi = { 35 - show(props) { 36 - if (process.env.NODE_ENV === 'test') { 37 - return 38 - } 39 - 40 - AccessibilityInfo.announceForAccessibility(props.a11yLabel) 41 - 42 - const item = new RootSiblings( 43 - ( 44 - <AnimatedToast 45 - type={props.type} 46 - content={props.content} 47 - a11yLabel={props.a11yLabel} 48 - duration={props.duration ?? DEFAULT_TOAST_DURATION} 49 - destroy={() => item.destroy()} 50 - /> 51 - ), 52 - ) 53 - }, 22 + /** 23 + * The toast UI component 24 + */ 25 + export function Toast({type, content}: ToastComponentProps) { 26 + return ( 27 + <View style={[a.px_xl, a.w_full]}> 28 + <BaseToast content={content} type={type} /> 29 + </View> 30 + ) 54 31 } 55 32 56 - function AnimatedToast({ 57 - type, 58 - content, 59 - a11yLabel, 60 - duration, 61 - destroy, 62 - }: { 63 - type: ToastType 64 - content: React.ReactNode 65 - a11yLabel: string 66 - duration: number 67 - destroy: () => void 68 - }) { 69 - const {top} = useSafeAreaInsets() 70 - const isPanning = useSharedValue(false) 71 - const dismissSwipeTranslateY = useSharedValue(0) 72 - const [cardHeight, setCardHeight] = useState(0) 73 - 74 - // for the exit animation to work on iOS the animated component 75 - // must not be the root component 76 - // so we need to wrap it in a view and unmount the toast ahead of time 77 - const [alive, setAlive] = useState(true) 78 - 79 - const hideAndDestroyImmediately = () => { 80 - setAlive(false) 81 - setTimeout(() => { 82 - destroy() 83 - }, 1e3) 84 - } 85 - 86 - const destroyTimeoutRef = useRef<ReturnType<typeof setTimeout>>() 87 - const hideAndDestroyAfterTimeout = useNonReactiveCallback(() => { 88 - clearTimeout(destroyTimeoutRef.current) 89 - destroyTimeoutRef.current = setTimeout(hideAndDestroyImmediately, duration) 90 - }) 91 - const pauseDestroy = useNonReactiveCallback(() => { 92 - clearTimeout(destroyTimeoutRef.current) 93 - }) 94 - 95 - useEffect(() => { 96 - hideAndDestroyAfterTimeout() 97 - }, [hideAndDestroyAfterTimeout]) 98 - 99 - const panGesture = useMemo(() => { 100 - return Gesture.Pan() 101 - .activeOffsetY([-10, 10]) 102 - .failOffsetX([-10, 10]) 103 - .maxPointers(1) 104 - .onStart(() => { 105 - 'worklet' 106 - if (!alive) return 107 - isPanning.set(true) 108 - runOnJS(pauseDestroy)() 109 - }) 110 - .onUpdate(e => { 111 - 'worklet' 112 - if (!alive) return 113 - dismissSwipeTranslateY.value = e.translationY 114 - }) 115 - .onEnd(e => { 116 - 'worklet' 117 - if (!alive) return 118 - runOnJS(hideAndDestroyAfterTimeout)() 119 - isPanning.set(false) 120 - if (e.velocityY < -100) { 121 - if (dismissSwipeTranslateY.value === 0) { 122 - // HACK: If the initial value is 0, withDecay() animation doesn't start. 123 - // This is a bug in Reanimated, but for now we'll work around it like this. 124 - dismissSwipeTranslateY.value = 1 125 - } 126 - dismissSwipeTranslateY.value = withDecay({ 127 - velocity: e.velocityY, 128 - velocityFactor: Math.max(3500 / Math.abs(e.velocityY), 1), 129 - deceleration: 1, 130 - }) 131 - } else { 132 - dismissSwipeTranslateY.value = withSpring(0, { 133 - stiffness: 500, 134 - damping: 50, 135 - }) 136 - } 137 - }) 138 - }, [ 139 - dismissSwipeTranslateY, 140 - isPanning, 141 - alive, 142 - hideAndDestroyAfterTimeout, 143 - pauseDestroy, 144 - ]) 145 - 146 - const topOffset = top + 10 147 - 148 - useAnimatedReaction( 149 - () => 150 - !isPanning.get() && 151 - dismissSwipeTranslateY.get() < -topOffset - cardHeight, 152 - (isSwipedAway, prevIsSwipedAway) => { 153 - 'worklet' 154 - if (isSwipedAway && !prevIsSwipedAway) { 155 - runOnJS(destroy)() 156 - } 157 - }, 158 - ) 33 + /** 34 + * Access the full Sonner API 35 + */ 36 + export const api = sonner 159 37 160 - const animatedStyle = useAnimatedStyle(() => { 161 - const translation = dismissSwipeTranslateY.get() 162 - return { 163 - transform: [ 164 - { 165 - translateY: translation > 0 ? translation ** 0.7 : translation, 166 - }, 167 - ], 168 - } 38 + /** 39 + * Our base toast API, using the `Toast` export of this file. 40 + */ 41 + export function show( 42 + content: React.ReactNode, 43 + {type, ...options}: BaseToastOptions = {}, 44 + ) { 45 + sonner.custom(<Toast content={content} type={type} />, { 46 + ...options, 47 + duration: options?.duration ?? DURATION, 169 48 }) 170 - 171 - return ( 172 - <GestureHandlerRootView 173 - style={[a.absolute, {top: topOffset, left: 16, right: 16}]} 174 - pointerEvents="box-none"> 175 - {alive && ( 176 - <Animated.View 177 - entering={SlideInUp.easing(Easing.out(Easing.exp)).duration( 178 - TOAST_ANIMATION_DURATION, 179 - )} 180 - exiting={SlideOutUp.easing(Easing.in(Easing.exp)).duration( 181 - TOAST_ANIMATION_DURATION * 0.7, 182 - )} 183 - onLayout={evt => setCardHeight(evt.nativeEvent.layout.height)} 184 - accessibilityRole="alert" 185 - accessible={true} 186 - accessibilityLabel={a11yLabel} 187 - accessibilityHint="" 188 - onAccessibilityEscape={hideAndDestroyImmediately} 189 - style={[a.flex_1, animatedStyle]}> 190 - <GestureDetector gesture={panGesture}> 191 - <Toast content={content} type={type} /> 192 - </GestureDetector> 193 - </Animated.View> 194 - )} 195 - </GestureHandlerRootView> 196 - ) 197 49 }
+31 -103
src/components/Toast/index.web.tsx
··· 1 - /* 2 - * Note: relies on styles in #/styles.css 3 - */ 1 + import {toast as sonner, Toaster} from 'sonner' 4 2 5 - import {useEffect, useState} from 'react' 6 - import {AccessibilityInfo, Pressable, View} from 'react-native' 7 - import {msg} from '@lingui/macro' 8 - import {useLingui} from '@lingui/react' 9 - 10 - import {atoms as a, useBreakpoints} from '#/alf' 11 - import {DEFAULT_TOAST_DURATION} from '#/components/Toast/const' 3 + import {atoms as a} from '#/alf' 4 + import {DURATION} from '#/components/Toast/const' 12 5 import {Toast} from '#/components/Toast/Toast' 13 - import {type ToastApi, type ToastType} from '#/components/Toast/types' 14 - 15 - const TOAST_ANIMATION_STYLES = { 16 - entering: { 17 - animation: 'toastFadeIn 0.3s ease-out forwards', 18 - }, 19 - exiting: { 20 - animation: 'toastFadeOut 0.2s ease-in forwards', 21 - }, 22 - } 6 + import {type BaseToastOptions} from '#/components/Toast/types' 23 7 24 - interface ActiveToast { 25 - type: ToastType 26 - content: React.ReactNode 27 - a11yLabel: string 28 - } 29 - type GlobalSetActiveToast = (_activeToast: ActiveToast | undefined) => void 30 - let globalSetActiveToast: GlobalSetActiveToast | undefined 31 - let toastTimeout: NodeJS.Timeout | undefined 32 - type ToastContainerProps = {} 33 - 34 - export const ToastContainer: React.FC<ToastContainerProps> = ({}) => { 35 - const {_} = useLingui() 36 - const {gtPhone} = useBreakpoints() 37 - const [activeToast, setActiveToast] = useState<ActiveToast | undefined>() 38 - const [isExiting, setIsExiting] = useState(false) 39 - 40 - useEffect(() => { 41 - globalSetActiveToast = (t: ActiveToast | undefined) => { 42 - if (!t && activeToast) { 43 - setIsExiting(true) 44 - setTimeout(() => { 45 - setActiveToast(t) 46 - setIsExiting(false) 47 - }, 200) 48 - } else { 49 - if (t) { 50 - AccessibilityInfo.announceForAccessibility(t.a11yLabel) 51 - } 52 - setActiveToast(t) 53 - setIsExiting(false) 54 - } 55 - } 56 - }, [activeToast]) 57 - 8 + /** 9 + * Toasts are rendered in a global outlet, which is placed at the top of the 10 + * component tree. 11 + */ 12 + export function ToastOutlet() { 58 13 return ( 59 - <> 60 - {activeToast && ( 61 - <View 62 - style={[ 63 - a.fixed, 64 - { 65 - left: a.px_xl.paddingLeft, 66 - right: a.px_xl.paddingLeft, 67 - bottom: a.px_xl.paddingLeft, 68 - ...(isExiting 69 - ? TOAST_ANIMATION_STYLES.exiting 70 - : TOAST_ANIMATION_STYLES.entering), 71 - }, 72 - gtPhone && [ 73 - { 74 - maxWidth: 380, 75 - }, 76 - ], 77 - ]}> 78 - <Toast content={activeToast.content} type={activeToast.type} /> 79 - <Pressable 80 - style={[a.absolute, a.inset_0]} 81 - accessibilityLabel={_( 82 - msg({ 83 - message: `Dismiss message`, 84 - comment: `Accessibility label for dismissing a toast notification`, 85 - }), 86 - )} 87 - accessibilityHint="" 88 - onPress={() => setActiveToast(undefined)} 89 - /> 90 - </View> 91 - )} 92 - </> 14 + <Toaster 15 + position="bottom-left" 16 + gap={a.gap_sm.gap} 17 + offset={a.p_xl.padding} 18 + mobileOffset={a.p_xl.padding} 19 + /> 93 20 ) 94 21 } 95 22 96 - export const toast: ToastApi = { 97 - show(props) { 98 - if (toastTimeout) { 99 - clearTimeout(toastTimeout) 100 - } 23 + /** 24 + * Access the full Sonner API 25 + */ 26 + export const api = sonner 101 27 102 - globalSetActiveToast?.({ 103 - type: props.type, 104 - content: props.content, 105 - a11yLabel: props.a11yLabel, 106 - }) 107 - 108 - toastTimeout = setTimeout(() => { 109 - globalSetActiveToast?.(undefined) 110 - }, props.duration || DEFAULT_TOAST_DURATION) 111 - }, 28 + /** 29 + * Our base toast API, using the `Toast` export of this file. 30 + */ 31 + export function show( 32 + content: React.ReactNode, 33 + {type, ...options}: BaseToastOptions = {}, 34 + ) { 35 + sonner(<Toast content={content} type={type} />, { 36 + unstyled: true, // required on web 37 + ...options, 38 + duration: options?.duration ?? DURATION, 39 + }) 112 40 }
+26 -21
src/components/Toast/types.ts
··· 1 + import {type toast as sonner} from 'sonner-native' 2 + 3 + /** 4 + * This is not exported from `sonner-native` so just hacking it in here. 5 + */ 6 + export type ExternalToast = Exclude< 7 + Parameters<typeof sonner.custom>[1], 8 + undefined 9 + > 10 + 1 11 export type ToastType = 'default' | 'success' | 'error' | 'warning' | 'info' 2 12 3 - export type ToastApi = { 4 - show: (props: { 5 - /** 6 - * The type of toast to show. This determines the styling and icon used. 7 - */ 8 - type: ToastType 9 - /** 10 - * A string, `Text`, or `Span` components to render inside the toast. This 11 - * allows additional formatting of the content, but should not be used for 12 - * interactive elements link links or buttons. 13 - */ 14 - content: React.ReactNode | string 15 - /** 16 - * Accessibility label for the toast, used for screen readers. 17 - */ 18 - a11yLabel: string 19 - /** 20 - * Defaults to `DEFAULT_TOAST_DURATION` from `#components/Toast/const`. 21 - */ 22 - duration?: number 23 - }) => void 13 + /** 14 + * Not all properties are available on all platforms, so we pick out only those 15 + * we support. Add more here as needed. 16 + */ 17 + export type BaseToastOptions = Pick< 18 + ExternalToast, 19 + 'duration' | 'dismissible' | 'promiseOptions' 20 + > & { 21 + type?: ToastType 22 + 23 + /** 24 + * These methods differ between web/native implementations 25 + */ 26 + onDismiss?: () => void 27 + onPress?: () => void 28 + onAutoClose?: () => void 24 29 }
+2 -6
src/view/com/util/Toast.tsx
··· 1 - import {toast} from '#/components/Toast' 1 + import * as toast from '#/components/Toast' 2 2 import {type ToastType} from '#/components/Toast/types' 3 3 4 4 /** ··· 46 46 type: ToastType | LegacyToastType = 'default', 47 47 ): void { 48 48 const convertedType = convertLegacyToastType(type) 49 - toast.show({ 50 - type: convertedType, 51 - content: message, 52 - a11yLabel: message, 53 - }) 49 + toast.show(message, {type: convertedType}) 54 50 }
+26 -51
src/view/screens/Storybook/Toasts.tsx
··· 2 2 3 3 import {show as deprecatedShow} from '#/view/com/util/Toast' 4 4 import {atoms as a} from '#/alf' 5 - import {Button, ButtonText} from '#/components/Button' 6 - import {toast} from '#/components/Toast' 5 + import * as toast from '#/components/Toast' 7 6 import {Toast} from '#/components/Toast/Toast' 8 7 import {H1} from '#/components/Typography' 9 8 ··· 15 14 <View style={[a.gap_md]}> 16 15 <Pressable 17 16 accessibilityRole="button" 18 - onPress={() => 19 - toast.show({ 20 - type: 'default', 21 - content: 'Default toast', 22 - a11yLabel: 'Default toast', 23 - }) 24 - }> 25 - <Toast content="Default toast" type="default" /> 17 + onPress={() => toast.show(`Hey I'm a toast!`)}> 18 + <Toast content="Hey I'm a toast!" /> 26 19 </Pressable> 27 20 <Pressable 28 21 accessibilityRole="button" 29 22 onPress={() => 30 - toast.show({ 31 - type: 'default', 32 - content: 'Default toast, 6 seconds', 33 - a11yLabel: 'Default toast, 6 seconds', 23 + toast.show(`This toast will disappear after 6 seconds`, { 34 24 duration: 6e3, 35 25 }) 36 26 }> 37 - <Toast content="Default toast, 6 seconds" type="default" /> 27 + <Toast content="This toast will disappear after 6 seconds" /> 38 28 </Pressable> 39 29 <Pressable 40 30 accessibilityRole="button" 41 31 onPress={() => 42 - toast.show({ 43 - type: 'default', 44 - content: 45 - 'This is a longer message to test how the toast handles multiple lines of text content.', 46 - a11yLabel: 47 - 'This is a longer message to test how the toast handles multiple lines of text content.', 48 - }) 32 + toast.show( 33 + `This is a longer message to test how the toast handles multiple lines of text content.`, 34 + ) 49 35 }> 50 - <Toast 51 - content="This is a longer message to test how the toast handles multiple lines of text content." 52 - type="default" 53 - /> 36 + <Toast content="This is a longer message to test how the toast handles multiple lines of text content." /> 54 37 </Pressable> 55 38 <Pressable 56 39 accessibilityRole="button" 57 40 onPress={() => 58 - toast.show({ 41 + toast.show(`Success! Yayyyyyyy :)`, { 59 42 type: 'success', 60 - content: 'Success toast', 61 - a11yLabel: 'Success toast', 62 43 }) 63 44 }> 64 - <Toast content="Success toast" type="success" /> 45 + <Toast content="Success! Yayyyyyyy :)" type="success" /> 65 46 </Pressable> 66 47 <Pressable 67 48 accessibilityRole="button" 68 49 onPress={() => 69 - toast.show({ 50 + toast.show(`I'm providing info!`, { 70 51 type: 'info', 71 - content: 'Info toast', 72 - a11yLabel: 'Info toast', 73 52 }) 74 53 }> 75 - <Toast content="Info" type="info" /> 54 + <Toast content="I'm providing info!" type="info" /> 76 55 </Pressable> 77 56 <Pressable 78 57 accessibilityRole="button" 79 58 onPress={() => 80 - toast.show({ 59 + toast.show(`This is a warning toast`, { 81 60 type: 'warning', 82 - content: 'Warning toast', 83 - a11yLabel: 'Warning toast', 84 61 }) 85 62 }> 86 - <Toast content="Warning" type="warning" /> 63 + <Toast content="This is a warning toast" type="warning" /> 87 64 </Pressable> 88 65 <Pressable 89 66 accessibilityRole="button" 90 67 onPress={() => 91 - toast.show({ 68 + toast.show(`This is an error toast :(`, { 92 69 type: 'error', 93 - content: 'Error toast', 94 - a11yLabel: 'Error toast', 95 70 }) 96 71 }> 97 - <Toast content="Error" type="error" /> 72 + <Toast content="This is an error toast :(" type="error" /> 98 73 </Pressable> 99 74 100 - <Button 101 - label="Deprecated toast example" 75 + <Pressable 76 + accessibilityRole="button" 102 77 onPress={() => 103 78 deprecatedShow( 104 - 'This is a deprecated toast example', 79 + `This is a test of the deprecated API`, 105 80 'exclamation-circle', 106 81 ) 107 - } 108 - size="large" 109 - variant="solid" 110 - color="secondary"> 111 - <ButtonText>Deprecated toast example</ButtonText> 112 - </Button> 82 + }> 83 + <Toast 84 + content="This is a test of the deprecated API" 85 + type="warning" 86 + /> 87 + </Pressable> 113 88 </View> 114 89 </View> 115 90 )
+10
yarn.lock
··· 18178 18178 dependencies: 18179 18179 atomic-sleep "^1.0.0" 18180 18180 18181 + sonner-native@^0.21.0: 18182 + version "0.21.0" 18183 + resolved "https://registry.yarnpkg.com/sonner-native/-/sonner-native-0.21.0.tgz#b7968f8a7fdcdbc3b13b478455d63b24a45db829" 18184 + integrity sha512-pIdyMd722xuDukIvCZCmWh9Jy5FV+m9uKYl3oAzonYE+91eX5556vYrIik5MisDlBniSXaWqC9CnPoS6DKo2Lg== 18185 + 18186 + sonner@^2.0.7: 18187 + version "2.0.7" 18188 + resolved "https://registry.yarnpkg.com/sonner/-/sonner-2.0.7.tgz#810c1487a67ec3370126e0f400dfb9edddc3e4f6" 18189 + integrity sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w== 18190 + 18181 18191 source-list-map@^2.0.1: 18182 18192 version "2.0.1" 18183 18193 resolved "https://registry.yarnpkg.com/source-list-map/-/source-list-map-2.0.1.tgz#3993bd873bfc48479cca9ea3a547835c7c154b34"