mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
1import React, {useImperativeHandle} from 'react'
2import {
3 type NativeScrollEvent,
4 type NativeSyntheticEvent,
5 Pressable,
6 type ScrollView,
7 type StyleProp,
8 TextInput,
9 View,
10 type ViewStyle,
11} from 'react-native'
12import {
13 KeyboardAwareScrollView,
14 useKeyboardHandler,
15 useReanimatedKeyboardAnimation,
16} from 'react-native-keyboard-controller'
17import Animated, {
18 runOnJS,
19 type ScrollEvent,
20 useAnimatedStyle,
21} from 'react-native-reanimated'
22import {useSafeAreaInsets} from 'react-native-safe-area-context'
23import {msg} from '@lingui/macro'
24import {useLingui} from '@lingui/react'
25
26import {useEnableKeyboardController} from '#/lib/hooks/useEnableKeyboardController'
27import {ScrollProvider} from '#/lib/ScrollContext'
28import {logger} from '#/logger'
29import {isAndroid, isIOS} from '#/platform/detection'
30import {useA11y} from '#/state/a11y'
31import {useDialogStateControlContext} from '#/state/dialogs'
32import {List, type ListMethods, type ListProps} from '#/view/com/util/List'
33import {atoms as a, ios, platform, tokens, useTheme} from '#/alf'
34import {useThemeName} from '#/alf/util/useColorModeTheme'
35import {Context, useDialogContext} from '#/components/Dialog/context'
36import {
37 type DialogControlProps,
38 type DialogInnerProps,
39 type DialogOuterProps,
40} from '#/components/Dialog/types'
41import {createInput} from '#/components/forms/TextField'
42import {BottomSheet, BottomSheetSnapPoint} from '../../../modules/bottom-sheet'
43import {
44 type BottomSheetSnapPointChangeEvent,
45 type BottomSheetStateChangeEvent,
46} from '../../../modules/bottom-sheet/src/BottomSheet.types'
47import {type BottomSheetNativeComponent} from '../../../modules/bottom-sheet/src/BottomSheetNativeComponent'
48
49export {useDialogContext, useDialogControl} from '#/components/Dialog/context'
50export * from '#/components/Dialog/shared'
51export * from '#/components/Dialog/types'
52export * from '#/components/Dialog/utils'
53
54export const Input = createInput(TextInput)
55
56export function Outer({
57 children,
58 control,
59 onClose,
60 nativeOptions,
61 testID,
62}: React.PropsWithChildren<DialogOuterProps>) {
63 const themeName = useThemeName()
64 const t = useTheme(themeName)
65 const ref = React.useRef<BottomSheetNativeComponent>(null)
66 const closeCallbacks = React.useRef<(() => void)[]>([])
67 const {setDialogIsOpen, setFullyExpandedCount} =
68 useDialogStateControlContext()
69
70 const prevSnapPoint = React.useRef<BottomSheetSnapPoint>(
71 BottomSheetSnapPoint.Hidden,
72 )
73
74 const [disableDrag, setDisableDrag] = React.useState(false)
75 const [snapPoint, setSnapPoint] = React.useState<BottomSheetSnapPoint>(
76 BottomSheetSnapPoint.Partial,
77 )
78
79 const callQueuedCallbacks = React.useCallback(() => {
80 for (const cb of closeCallbacks.current) {
81 try {
82 cb()
83 } catch (e: any) {
84 logger.error(e || 'Error running close callback')
85 }
86 }
87
88 closeCallbacks.current = []
89 }, [])
90
91 const open = React.useCallback<DialogControlProps['open']>(() => {
92 // Run any leftover callbacks that might have been queued up before calling `.open()`
93 callQueuedCallbacks()
94 setDialogIsOpen(control.id, true)
95 ref.current?.present()
96 }, [setDialogIsOpen, control.id, callQueuedCallbacks])
97
98 // This is the function that we call when we want to dismiss the dialog.
99 const close = React.useCallback<DialogControlProps['close']>(cb => {
100 if (typeof cb === 'function') {
101 closeCallbacks.current.push(cb)
102 }
103 ref.current?.dismiss()
104 }, [])
105
106 // This is the actual thing we are doing once we "confirm" the dialog. We want the dialog's close animation to
107 // happen before we run this. It is passed to the `BottomSheet` component.
108 const onCloseAnimationComplete = React.useCallback(() => {
109 // This removes the dialog from our list of stored dialogs. Not super necessary on iOS, but on Android this
110 // tells us that we need to toggle the accessibility overlay setting
111 setDialogIsOpen(control.id, false)
112 callQueuedCallbacks()
113 onClose?.()
114 }, [callQueuedCallbacks, control.id, onClose, setDialogIsOpen])
115
116 const onSnapPointChange = (e: BottomSheetSnapPointChangeEvent) => {
117 const {snapPoint} = e.nativeEvent
118 setSnapPoint(snapPoint)
119
120 if (
121 snapPoint === BottomSheetSnapPoint.Full &&
122 prevSnapPoint.current !== BottomSheetSnapPoint.Full
123 ) {
124 setFullyExpandedCount(c => c + 1)
125 } else if (
126 snapPoint !== BottomSheetSnapPoint.Full &&
127 prevSnapPoint.current === BottomSheetSnapPoint.Full
128 ) {
129 setFullyExpandedCount(c => c - 1)
130 }
131 prevSnapPoint.current = snapPoint
132 }
133
134 const onStateChange = (e: BottomSheetStateChangeEvent) => {
135 if (e.nativeEvent.state === 'closed') {
136 onCloseAnimationComplete()
137
138 if (prevSnapPoint.current === BottomSheetSnapPoint.Full) {
139 setFullyExpandedCount(c => c - 1)
140 }
141 prevSnapPoint.current = BottomSheetSnapPoint.Hidden
142 }
143 }
144
145 useImperativeHandle(
146 control.ref,
147 () => ({
148 open,
149 close,
150 }),
151 [open, close],
152 )
153
154 const context = React.useMemo(
155 () => ({
156 close,
157 isNativeDialog: true,
158 nativeSnapPoint: snapPoint,
159 disableDrag,
160 setDisableDrag,
161 isWithinDialog: true,
162 }),
163 [close, snapPoint, disableDrag, setDisableDrag],
164 )
165
166 return (
167 <BottomSheet
168 ref={ref}
169 cornerRadius={20}
170 backgroundColor={t.atoms.bg.backgroundColor}
171 {...nativeOptions}
172 onSnapPointChange={onSnapPointChange}
173 onStateChange={onStateChange}
174 disableDrag={disableDrag}>
175 <Context.Provider value={context}>
176 <View testID={testID} style={[a.relative]}>
177 {children}
178 </View>
179 </Context.Provider>
180 </BottomSheet>
181 )
182}
183
184export function Inner({children, style, header}: DialogInnerProps) {
185 const insets = useSafeAreaInsets()
186 return (
187 <>
188 {header}
189 <View
190 style={[
191 a.pt_2xl,
192 a.px_xl,
193 {
194 paddingBottom: insets.bottom + insets.top,
195 },
196 style,
197 ]}>
198 {children}
199 </View>
200 </>
201 )
202}
203
204export const ScrollableInner = React.forwardRef<ScrollView, DialogInnerProps>(
205 function ScrollableInner(
206 {children, contentContainerStyle, header, ...props},
207 ref,
208 ) {
209 const {nativeSnapPoint, disableDrag, setDisableDrag} = useDialogContext()
210 const insets = useSafeAreaInsets()
211
212 useEnableKeyboardController(isIOS)
213
214 const [keyboardHeight, setKeyboardHeight] = React.useState(0)
215
216 useKeyboardHandler(
217 {
218 onEnd: e => {
219 'worklet'
220 runOnJS(setKeyboardHeight)(e.height)
221 },
222 },
223 [],
224 )
225
226 let paddingBottom = 0
227 if (isIOS) {
228 paddingBottom += keyboardHeight / 4
229 if (nativeSnapPoint === BottomSheetSnapPoint.Full) {
230 paddingBottom += insets.bottom + tokens.space.md
231 }
232 paddingBottom = Math.max(paddingBottom, tokens.space._2xl)
233 } else {
234 paddingBottom += keyboardHeight
235 if (nativeSnapPoint === BottomSheetSnapPoint.Full) {
236 paddingBottom += insets.top
237 }
238 paddingBottom +=
239 Math.max(insets.bottom, tokens.space._5xl) + tokens.space._2xl
240 }
241
242 const onScroll = (e: NativeSyntheticEvent<NativeScrollEvent>) => {
243 if (!isAndroid) {
244 return
245 }
246 const {contentOffset} = e.nativeEvent
247 if (contentOffset.y > 0 && !disableDrag) {
248 setDisableDrag(true)
249 } else if (contentOffset.y <= 1 && disableDrag) {
250 setDisableDrag(false)
251 }
252 }
253
254 return (
255 <KeyboardAwareScrollView
256 contentContainerStyle={[
257 a.pt_2xl,
258 a.px_xl,
259 {paddingBottom},
260 contentContainerStyle,
261 ]}
262 ref={ref}
263 showsVerticalScrollIndicator={isAndroid ? false : undefined}
264 {...props}
265 bounces={nativeSnapPoint === BottomSheetSnapPoint.Full}
266 bottomOffset={30}
267 scrollEventThrottle={50}
268 onScroll={isAndroid ? onScroll : undefined}
269 keyboardShouldPersistTaps="handled"
270 stickyHeaderIndices={header ? [0] : undefined}>
271 {header}
272 {children}
273 </KeyboardAwareScrollView>
274 )
275 },
276)
277
278export const InnerFlatList = React.forwardRef<
279 ListMethods,
280 ListProps<any> & {
281 webInnerStyle?: StyleProp<ViewStyle>
282 webInnerContentContainerStyle?: StyleProp<ViewStyle>
283 footer?: React.ReactNode
284 }
285>(function InnerFlatList({footer, style, ...props}, ref) {
286 const insets = useSafeAreaInsets()
287 const {nativeSnapPoint, disableDrag, setDisableDrag} = useDialogContext()
288
289 useEnableKeyboardController(isIOS)
290
291 const onScroll = (e: ScrollEvent) => {
292 'worklet'
293 if (!isAndroid) {
294 return
295 }
296 const {contentOffset} = e
297 if (contentOffset.y > 0 && !disableDrag) {
298 runOnJS(setDisableDrag)(true)
299 } else if (contentOffset.y <= 1 && disableDrag) {
300 runOnJS(setDisableDrag)(false)
301 }
302 }
303
304 return (
305 <ScrollProvider onScroll={onScroll}>
306 <List
307 keyboardShouldPersistTaps="handled"
308 bounces={nativeSnapPoint === BottomSheetSnapPoint.Full}
309 ListFooterComponent={<View style={{height: insets.bottom + 100}} />}
310 ref={ref}
311 showsVerticalScrollIndicator={isAndroid ? false : undefined}
312 {...props}
313 style={[a.h_full, style]}
314 />
315 {footer}
316 </ScrollProvider>
317 )
318})
319
320export function FlatListFooter({children}: {children: React.ReactNode}) {
321 const t = useTheme()
322 const {top, bottom} = useSafeAreaInsets()
323 const {height} = useReanimatedKeyboardAnimation()
324
325 const animatedStyle = useAnimatedStyle(() => {
326 if (!isIOS) return {}
327 return {
328 transform: [{translateY: Math.min(0, height.get() + bottom - 10)}],
329 }
330 })
331
332 return (
333 <Animated.View
334 style={[
335 a.absolute,
336 a.bottom_0,
337 a.w_full,
338 a.z_10,
339 a.border_t,
340 t.atoms.bg,
341 t.atoms.border_contrast_low,
342 a.px_lg,
343 a.pt_md,
344 {
345 paddingBottom: platform({
346 ios: tokens.space.md + bottom,
347 android: tokens.space.md + bottom + top,
348 }),
349 },
350 // TODO: had to admit defeat here, but we should
351 // try and get this to work for Android as well -sfn
352 ios(animatedStyle),
353 ]}>
354 {children}
355 </Animated.View>
356 )
357}
358
359export function Handle({difference = false}: {difference?: boolean}) {
360 const t = useTheme()
361 const {_} = useLingui()
362 const {screenReaderEnabled} = useA11y()
363 const {close} = useDialogContext()
364
365 return (
366 <View style={[a.absolute, a.w_full, a.align_center, a.z_10, {height: 20}]}>
367 <Pressable
368 accessible={screenReaderEnabled}
369 onPress={() => close()}
370 accessibilityLabel={_(msg`Dismiss`)}
371 accessibilityHint={_(msg`Double tap to close the dialog`)}>
372 <View
373 style={[
374 a.rounded_sm,
375 {
376 top: tokens.space._2xl / 2 - 2.5,
377 width: 35,
378 height: 5,
379 alignSelf: 'center',
380 },
381 difference
382 ? {
383 // TODO: mixBlendMode is only available on the new architecture -sfn
384 // backgroundColor: t.palette.white,
385 // mixBlendMode: 'difference',
386 backgroundColor: t.palette.white,
387 opacity: 0.75,
388 }
389 : {
390 backgroundColor: t.palette.contrast_975,
391 opacity: 0.5,
392 },
393 ]}
394 />
395 </Pressable>
396 </View>
397 )
398}
399
400export function Close() {
401 return null
402}