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