mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
1import {useEffect, useMemo, useRef, useState} from 'react'
2import {AccessibilityInfo, View} from 'react-native'
3import {
4 Gesture,
5 GestureDetector,
6 GestureHandlerRootView,
7} from 'react-native-gesture-handler'
8import Animated, {
9 FadeInUp,
10 FadeOutUp,
11 runOnJS,
12 useAnimatedReaction,
13 useAnimatedStyle,
14 useSharedValue,
15 withDecay,
16 withSpring,
17} from 'react-native-reanimated'
18import RootSiblings from 'react-native-root-siblings'
19import {useSafeAreaInsets} from 'react-native-safe-area-context'
20import {
21 FontAwesomeIcon,
22 Props as FontAwesomeProps,
23} from '@fortawesome/react-native-fontawesome'
24
25import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
26import {atoms as a, useTheme} from '#/alf'
27import {Text} from '#/components/Typography'
28
29const TIMEOUT = 2e3
30
31export function show(
32 message: string,
33 icon: FontAwesomeProps['icon'] = 'check',
34) {
35 if (process.env.NODE_ENV === 'test') {
36 return
37 }
38 AccessibilityInfo.announceForAccessibility(message)
39 const item = new RootSiblings(
40 <Toast message={message} icon={icon} destroy={() => item.destroy()} />,
41 )
42}
43
44function Toast({
45 message,
46 icon,
47 destroy,
48}: {
49 message: string
50 icon: FontAwesomeProps['icon']
51 destroy: () => void
52}) {
53 const t = useTheme()
54 const {top} = useSafeAreaInsets()
55 const isPanning = useSharedValue(false)
56 const dismissSwipeTranslateY = useSharedValue(0)
57 const [cardHeight, setCardHeight] = useState(0)
58
59 // for the exit animation to work on iOS the animated component
60 // must not be the root component
61 // so we need to wrap it in a view and unmount the toast ahead of time
62 const [alive, setAlive] = useState(true)
63
64 const hideAndDestroyImmediately = () => {
65 setAlive(false)
66 setTimeout(() => {
67 destroy()
68 }, 1e3)
69 }
70
71 const destroyTimeoutRef = useRef<ReturnType<typeof setTimeout>>()
72 const hideAndDestroyAfterTimeout = useNonReactiveCallback(() => {
73 clearTimeout(destroyTimeoutRef.current)
74 destroyTimeoutRef.current = setTimeout(hideAndDestroyImmediately, TIMEOUT)
75 })
76 const pauseDestroy = useNonReactiveCallback(() => {
77 clearTimeout(destroyTimeoutRef.current)
78 })
79
80 useEffect(() => {
81 hideAndDestroyAfterTimeout()
82 }, [hideAndDestroyAfterTimeout])
83
84 const panGesture = useMemo(() => {
85 return Gesture.Pan()
86 .activeOffsetY([-10, 10])
87 .failOffsetX([-10, 10])
88 .maxPointers(1)
89 .onStart(() => {
90 'worklet'
91 if (!alive) return
92 isPanning.set(true)
93 runOnJS(pauseDestroy)()
94 })
95 .onUpdate(e => {
96 'worklet'
97 if (!alive) return
98 dismissSwipeTranslateY.value = e.translationY
99 })
100 .onEnd(e => {
101 'worklet'
102 if (!alive) return
103 runOnJS(hideAndDestroyAfterTimeout)()
104 isPanning.set(false)
105 if (e.velocityY < -100) {
106 if (dismissSwipeTranslateY.value === 0) {
107 // HACK: If the initial value is 0, withDecay() animation doesn't start.
108 // This is a bug in Reanimated, but for now we'll work around it like this.
109 dismissSwipeTranslateY.value = 1
110 }
111 dismissSwipeTranslateY.value = withDecay({
112 velocity: e.velocityY,
113 velocityFactor: Math.max(3500 / Math.abs(e.velocityY), 1),
114 deceleration: 1,
115 })
116 } else {
117 dismissSwipeTranslateY.value = withSpring(0, {
118 stiffness: 500,
119 damping: 50,
120 })
121 }
122 })
123 }, [
124 dismissSwipeTranslateY,
125 isPanning,
126 alive,
127 hideAndDestroyAfterTimeout,
128 pauseDestroy,
129 ])
130
131 const topOffset = top + 10
132
133 useAnimatedReaction(
134 () =>
135 !isPanning.get() &&
136 dismissSwipeTranslateY.get() < -topOffset - cardHeight,
137 (isSwipedAway, prevIsSwipedAway) => {
138 'worklet'
139 if (isSwipedAway && !prevIsSwipedAway) {
140 runOnJS(destroy)()
141 }
142 },
143 )
144
145 const animatedStyle = useAnimatedStyle(() => {
146 const translation = dismissSwipeTranslateY.get()
147 return {
148 transform: [
149 {
150 translateY: translation > 0 ? translation ** 0.7 : translation,
151 },
152 ],
153 }
154 })
155
156 return (
157 <GestureHandlerRootView
158 style={[a.absolute, {top: topOffset, left: 16, right: 16}]}
159 pointerEvents="box-none">
160 {alive && (
161 <Animated.View
162 entering={FadeInUp}
163 exiting={FadeOutUp}
164 style={[a.flex_1]}>
165 <Animated.View
166 onLayout={evt => setCardHeight(evt.nativeEvent.layout.height)}
167 accessibilityRole="alert"
168 accessible={true}
169 accessibilityLabel={message}
170 accessibilityHint=""
171 onAccessibilityEscape={hideAndDestroyImmediately}
172 style={[
173 a.flex_1,
174 t.atoms.bg,
175 a.shadow_lg,
176 t.atoms.border_contrast_medium,
177 a.rounded_sm,
178 a.border,
179 animatedStyle,
180 ]}>
181 <GestureDetector gesture={panGesture}>
182 <View style={[a.flex_1, a.px_md, a.py_lg, a.flex_row, a.gap_md]}>
183 <View
184 style={[
185 a.flex_shrink_0,
186 a.rounded_full,
187 {width: 32, height: 32},
188 {backgroundColor: t.palette.primary_50},
189 a.align_center,
190 a.justify_center,
191 ]}>
192 <FontAwesomeIcon
193 icon={icon}
194 size={16}
195 style={t.atoms.text_contrast_medium}
196 />
197 </View>
198 <View style={[a.h_full, a.justify_center, a.flex_1]}>
199 <Text style={a.text_md} emoji>
200 {message}
201 </Text>
202 </View>
203 </View>
204 </GestureDetector>
205 </Animated.View>
206 </Animated.View>
207 )}
208 </GestureHandlerRootView>
209 )
210}