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