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 type KeyboardAwareScrollViewRef,
15 useKeyboardHandler,
16 useReanimatedKeyboardAnimation,
17} from 'react-native-keyboard-controller'
18import Animated, {
19 runOnJS,
20 type ScrollEvent,
21 useAnimatedStyle,
22} from 'react-native-reanimated'
23import {useSafeAreaInsets} from 'react-native-safe-area-context'
24import {msg} from '@lingui/macro'
25import {useLingui} from '@lingui/react'
26
27import {ScrollProvider} from '#/lib/ScrollContext'
28import {logger} from '#/logger'
29import {useA11y} from '#/state/a11y'
30import {useDialogStateControlContext} from '#/state/dialogs'
31import {List, type ListMethods, type ListProps} from '#/view/com/util/List'
32import {atoms as a, ios, platform, tokens, useTheme} from '#/alf'
33import {useThemeName} from '#/alf/util/useColorModeTheme'
34import {Context, useDialogContext} from '#/components/Dialog/context'
35import {
36 type DialogControlProps,
37 type DialogInnerProps,
38 type DialogOuterProps,
39} from '#/components/Dialog/types'
40import {createInput} from '#/components/forms/TextField'
41import {IS_ANDROID, IS_IOS} from '#/env'
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 IS_NATIVEDialog: 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 const [keyboardHeight, setKeyboardHeight] = React.useState(0)
213
214 // note: iOS-only. keyboard-controller doesn't seem to work inside the sheets on Android
215 useKeyboardHandler(
216 {
217 onEnd: e => {
218 'worklet'
219 runOnJS(setKeyboardHeight)(e.height)
220 },
221 },
222 [],
223 )
224
225 let paddingBottom = 0
226 if (IS_IOS) {
227 paddingBottom += keyboardHeight / 4
228 if (nativeSnapPoint === BottomSheetSnapPoint.Full) {
229 paddingBottom += insets.bottom + tokens.space.md
230 }
231 paddingBottom = Math.max(paddingBottom, tokens.space._2xl)
232 } else {
233 if (nativeSnapPoint === BottomSheetSnapPoint.Full) {
234 paddingBottom += insets.top
235 }
236 paddingBottom +=
237 Math.max(insets.bottom, tokens.space._5xl) + tokens.space._2xl
238 }
239
240 const onScroll = (e: NativeSyntheticEvent<NativeScrollEvent>) => {
241 if (!IS_ANDROID) {
242 return
243 }
244 const {contentOffset} = e.nativeEvent
245 if (contentOffset.y > 0 && !disableDrag) {
246 setDisableDrag(true)
247 } else if (contentOffset.y <= 1 && disableDrag) {
248 setDisableDrag(false)
249 }
250 }
251
252 return (
253 <KeyboardAwareScrollView
254 contentContainerStyle={[
255 a.pt_2xl,
256 a.px_xl,
257 {paddingBottom},
258 contentContainerStyle,
259 ]}
260 ref={ref as React.Ref<KeyboardAwareScrollViewRef>}
261 showsVerticalScrollIndicator={IS_ANDROID ? false : undefined}
262 {...props}
263 bounces={nativeSnapPoint === BottomSheetSnapPoint.Full}
264 bottomOffset={30}
265 scrollEventThrottle={50}
266 onScroll={IS_ANDROID ? onScroll : undefined}
267 keyboardShouldPersistTaps="handled"
268 // TODO: figure out why this positions the header absolutely (rather than stickily)
269 // on Android. fine to disable for now, because we don't have any
270 // dialogs that use this that actually scroll -sfn
271 stickyHeaderIndices={ios(header ? [0] : undefined)}>
272 {header}
273 {children}
274 </KeyboardAwareScrollView>
275 )
276 },
277)
278
279export const InnerFlatList = React.forwardRef<
280 ListMethods,
281 ListProps<any> & {
282 webInnerStyle?: StyleProp<ViewStyle>
283 webInnerContentContainerStyle?: StyleProp<ViewStyle>
284 footer?: React.ReactNode
285 }
286>(function InnerFlatList({footer, style, ...props}, ref) {
287 const insets = useSafeAreaInsets()
288 const {nativeSnapPoint, disableDrag, setDisableDrag} = useDialogContext()
289
290 const onScroll = (e: ScrollEvent) => {
291 'worklet'
292 if (!IS_ANDROID) {
293 return
294 }
295 const {contentOffset} = e
296 if (contentOffset.y > 0 && !disableDrag) {
297 runOnJS(setDisableDrag)(true)
298 } else if (contentOffset.y <= 1 && disableDrag) {
299 runOnJS(setDisableDrag)(false)
300 }
301 }
302
303 return (
304 <ScrollProvider onScroll={onScroll}>
305 <List
306 keyboardShouldPersistTaps="handled"
307 bounces={nativeSnapPoint === BottomSheetSnapPoint.Full}
308 ListFooterComponent={<View style={{height: insets.bottom + 100}} />}
309 ref={ref}
310 showsVerticalScrollIndicator={IS_ANDROID ? false : undefined}
311 {...props}
312 style={[a.h_full, style]}
313 />
314 {footer}
315 </ScrollProvider>
316 )
317})
318
319export function FlatListFooter({children}: {children: React.ReactNode}) {
320 const t = useTheme()
321 const {top, bottom} = useSafeAreaInsets()
322 const {height} = useReanimatedKeyboardAnimation()
323
324 const animatedStyle = useAnimatedStyle(() => {
325 if (!IS_IOS) return {}
326 return {
327 transform: [{translateY: Math.min(0, height.get() + bottom - 10)}],
328 }
329 })
330
331 return (
332 <Animated.View
333 style={[
334 a.absolute,
335 a.bottom_0,
336 a.w_full,
337 a.z_10,
338 a.border_t,
339 t.atoms.bg,
340 t.atoms.border_contrast_low,
341 a.px_lg,
342 a.pt_md,
343 {
344 paddingBottom: platform({
345 ios: tokens.space.md + bottom,
346 android: tokens.space.md + bottom + top,
347 }),
348 },
349 // TODO: had to admit defeat here, but we should
350 // try and get this to work for Android as well -sfn
351 ios(animatedStyle),
352 ]}>
353 {children}
354 </Animated.View>
355 )
356}
357
358export function Handle({
359 difference = false,
360 fill,
361}: {
362 difference?: boolean
363 fill?: string
364}) {
365 const t = useTheme()
366 const {_} = useLingui()
367 const {screenReaderEnabled} = useA11y()
368 const {close} = useDialogContext()
369
370 return (
371 <View style={[a.absolute, a.w_full, a.align_center, a.z_10, {height: 20}]}>
372 <Pressable
373 accessible={screenReaderEnabled}
374 onPress={() => close()}
375 accessibilityLabel={_(msg`Dismiss`)}
376 accessibilityHint={_(msg`Double tap to close the dialog`)}>
377 <View
378 style={[
379 a.rounded_sm,
380 {
381 top: tokens.space._2xl / 2 - 2.5,
382 width: 35,
383 height: 5,
384 alignSelf: 'center',
385 },
386 difference
387 ? {
388 // TODO: mixBlendMode is only available on the new architecture -sfn
389 // backgroundColor: t.palette.white,
390 // mixBlendMode: 'difference',
391 backgroundColor: t.palette.white,
392 opacity: 0.75,
393 }
394 : {
395 backgroundColor: fill || t.palette.contrast_975,
396 opacity: 0.5,
397 },
398 ]}
399 />
400 </Pressable>
401 </View>
402 )
403}
404
405export function Close() {
406 return null
407}
408
409export function Backdrop() {
410 return null
411}