forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
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 // TODO: figure out why this positions the header absolutely (rather than stickily)
271 // on Android. fine to disable for now, because we don't have any
272 // dialogs that use this that actually scroll -sfn
273 stickyHeaderIndices={ios(header ? [0] : undefined)}>
274 {header}
275 {children}
276 </KeyboardAwareScrollView>
277 )
278 },
279)
280
281export const InnerFlatList = React.forwardRef<
282 ListMethods,
283 ListProps<any> & {
284 webInnerStyle?: StyleProp<ViewStyle>
285 webInnerContentContainerStyle?: StyleProp<ViewStyle>
286 footer?: React.ReactNode
287 }
288>(function InnerFlatList({footer, style, ...props}, ref) {
289 const insets = useSafeAreaInsets()
290 const {nativeSnapPoint, disableDrag, setDisableDrag} = useDialogContext()
291
292 useEnableKeyboardController(isIOS)
293
294 const onScroll = (e: ScrollEvent) => {
295 'worklet'
296 if (!isAndroid) {
297 return
298 }
299 const {contentOffset} = e
300 if (contentOffset.y > 0 && !disableDrag) {
301 runOnJS(setDisableDrag)(true)
302 } else if (contentOffset.y <= 1 && disableDrag) {
303 runOnJS(setDisableDrag)(false)
304 }
305 }
306
307 return (
308 <ScrollProvider onScroll={onScroll}>
309 <List
310 keyboardShouldPersistTaps="handled"
311 bounces={nativeSnapPoint === BottomSheetSnapPoint.Full}
312 ListFooterComponent={<View style={{height: insets.bottom + 100}} />}
313 ref={ref}
314 showsVerticalScrollIndicator={isAndroid ? false : undefined}
315 {...props}
316 style={[a.h_full, style]}
317 />
318 {footer}
319 </ScrollProvider>
320 )
321})
322
323export function FlatListFooter({children}: {children: React.ReactNode}) {
324 const t = useTheme()
325 const {top, bottom} = useSafeAreaInsets()
326 const {height} = useReanimatedKeyboardAnimation()
327
328 const animatedStyle = useAnimatedStyle(() => {
329 if (!isIOS) return {}
330 return {
331 transform: [{translateY: Math.min(0, height.get() + bottom - 10)}],
332 }
333 })
334
335 return (
336 <Animated.View
337 style={[
338 a.absolute,
339 a.bottom_0,
340 a.w_full,
341 a.z_10,
342 a.border_t,
343 t.atoms.bg,
344 t.atoms.border_contrast_low,
345 a.px_lg,
346 a.pt_md,
347 {
348 paddingBottom: platform({
349 ios: tokens.space.md + bottom,
350 android: tokens.space.md + bottom + top,
351 }),
352 },
353 // TODO: had to admit defeat here, but we should
354 // try and get this to work for Android as well -sfn
355 ios(animatedStyle),
356 ]}>
357 {children}
358 </Animated.View>
359 )
360}
361
362export function Handle({difference = false}: {difference?: boolean}) {
363 const t = useTheme()
364 const {_} = useLingui()
365 const {screenReaderEnabled} = useA11y()
366 const {close} = useDialogContext()
367
368 return (
369 <View style={[a.absolute, a.w_full, a.align_center, a.z_10, {height: 20}]}>
370 <Pressable
371 accessible={screenReaderEnabled}
372 onPress={() => close()}
373 accessibilityLabel={_(msg`Dismiss`)}
374 accessibilityHint={_(msg`Double tap to close the dialog`)}>
375 <View
376 style={[
377 a.rounded_sm,
378 {
379 top: tokens.space._2xl / 2 - 2.5,
380 width: 35,
381 height: 5,
382 alignSelf: 'center',
383 },
384 difference
385 ? {
386 // TODO: mixBlendMode is only available on the new architecture -sfn
387 // backgroundColor: t.palette.white,
388 // mixBlendMode: 'difference',
389 backgroundColor: t.palette.white,
390 opacity: 0.75,
391 }
392 : {
393 backgroundColor: t.palette.contrast_975,
394 opacity: 0.5,
395 },
396 ]}
397 />
398 </Pressable>
399 </View>
400 )
401}
402
403export function Close() {
404 return null
405}