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