Bluesky app fork with some witchin' additions 馃挮
witchsky.app
bluesky
fork
client
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 isHeightConstrained = nativeOptions?.maxHeight != null
161
162 const context = useMemo(
163 () => ({
164 close,
165 isNativeDialog: true,
166 nativeSnapPoint: snapPoint,
167 disableDrag,
168 setDisableDrag,
169 isWithinDialog: true,
170 isHeightConstrained,
171 }),
172 [close, snapPoint, disableDrag, setDisableDrag, isHeightConstrained],
173 )
174
175 return (
176 <BottomSheet
177 ref={ref}
178 // device-bezel radius when undefined
179 cornerRadius={IS_LIQUID_GLASS ? undefined : 20}
180 backgroundColor={t.atoms.bg.backgroundColor}
181 {...nativeOptions}
182 onSnapPointChange={onSnapPointChange}
183 onStateChange={onStateChange}
184 disableDrag={disableDrag}>
185 <Context.Provider value={context}>
186 <View
187 testID={testID}
188 style={[a.relative, isHeightConstrained && a.flex_1]}>
189 {children}
190 </View>
191 </Context.Provider>
192 </BottomSheet>
193 )
194}
195
196/**
197 * @deprecated use `Dialog.ScrollableInner` instead
198 */
199export function Inner({children, style, header}: DialogInnerProps) {
200 const insets = useSafeAreaInsets()
201 return (
202 <>
203 {header}
204 <View
205 style={[
206 a.pt_2xl,
207 a.px_xl,
208 IS_LIQUID_GLASS
209 ? a.pb_2xl
210 : {paddingBottom: insets.bottom + insets.top},
211 style,
212 ]}>
213 {children}
214 </View>
215 </>
216 )
217}
218
219export const ScrollableInner = forwardRef<ScrollView, DialogInnerProps>(
220 function ScrollableInner(
221 {children, contentContainerStyle, header, style, ...props},
222 ref,
223 ) {
224 const {nativeSnapPoint, disableDrag, setDisableDrag, isHeightConstrained} =
225 useDialogContext()
226 const isAtMaxSnapPoint = nativeSnapPoint === BottomSheetSnapPoint.Full
227 const insets = useSafeAreaInsets()
228 const [keyboardHeight, setKeyboardHeight] = useState(() =>
229 IS_ANDROID ? (Keyboard.metrics()?.height ?? 0) : 0,
230 )
231
232 const keyboardEventHandler = useCallback<KeyboardEventListener>(e => {
233 setKeyboardHeight(e.endCoordinates.height)
234 }, [])
235 useOnKeyboard('keyboardDidShow', keyboardEventHandler)
236 useOnKeyboard('keyboardDidHide', keyboardEventHandler)
237
238 const onScroll = (e: NativeSyntheticEvent<NativeScrollEvent>) => {
239 if (!IS_ANDROID) {
240 return
241 }
242 const {contentOffset} = e.nativeEvent
243 if (contentOffset.y > 0 && !disableDrag) {
244 setDisableDrag(true)
245 } else if (contentOffset.y <= 1 && disableDrag) {
246 setDisableDrag(false)
247 }
248 }
249
250 return (
251 <ScrollView
252 style={[isHeightConstrained && a.flex_1, style]}
253 contentContainerStyle={[
254 a.pt_2xl,
255 IS_LIQUID_GLASS ? a.px_2xl : a.px_xl,
256 platform({
257 ios: a.pb_2xl,
258 android: {
259 paddingBottom: keyboardHeight + insets.bottom + tokens.space.xl,
260 },
261 }),
262 contentContainerStyle,
263 ]}
264 ref={ref}
265 showsVerticalScrollIndicator={IS_ANDROID ? false : undefined}
266 contentInsetAdjustmentBehavior={
267 isAtMaxSnapPoint ? 'automatic' : 'never'
268 }
269 automaticallyAdjustKeyboardInsets={isAtMaxSnapPoint}
270 {...props}
271 bounces={isAtMaxSnapPoint}
272 scrollEventThrottle={50}
273 // set drag state based on scroll on android.
274 // we want to detect if it's at the top or not, so watch
275 // scrollEndDrag and momentumScrollEnd as well
276 onScroll={android(onScroll)}
277 onScrollEndDrag={android(onScroll)}
278 onMomentumScrollEnd={android(onScroll)}
279 keyboardShouldPersistTaps="handled"
280 // TODO: figure out why this positions the header absolutely (rather than stickily)
281 // on Android. fine to disable for now, because we don't have any
282 // dialogs that use this that actually scroll -sfn
283 stickyHeaderIndices={ios(header ? [0] : undefined)}>
284 {header}
285 {children}
286 </ScrollView>
287 )
288 },
289)
290
291export const InnerFlatList = forwardRef<
292 ListMethods,
293 ListProps<any> & {
294 webInnerStyle?: StyleProp<ViewStyle>
295 webInnerContentContainerStyle?: StyleProp<ViewStyle>
296 footer?: React.ReactNode
297 }
298>(function InnerFlatList(
299 {headerOffset, footer, style, contentContainerStyle, ...props},
300 ref,
301) {
302 const insets = useSafeAreaInsets()
303 const {nativeSnapPoint, disableDrag, setDisableDrag} = useDialogContext()
304
305 const isAtMaxSnapPoint = nativeSnapPoint === BottomSheetSnapPoint.Full
306
307 const onScroll = (e: ScrollEvent) => {
308 'worklet'
309 if (!IS_ANDROID) {
310 return
311 }
312 const {contentOffset} = e
313 if (contentOffset.y > 0 && !disableDrag) {
314 runOnJS(setDisableDrag)(true)
315 } else if (contentOffset.y <= 1 && disableDrag) {
316 runOnJS(setDisableDrag)(false)
317 }
318 }
319
320 return (
321 <ScrollProvider
322 onScroll={onScroll}
323 onEndDrag={onScroll}
324 onMomentumEnd={onScroll}>
325 <List
326 keyboardShouldPersistTaps="handled"
327 contentInsetAdjustmentBehavior={
328 isAtMaxSnapPoint ? 'automatic' : 'never'
329 }
330 automaticallyAdjustKeyboardInsets={isAtMaxSnapPoint}
331 scrollIndicatorInsets={{top: headerOffset}}
332 bounces={isAtMaxSnapPoint}
333 ref={ref}
334 showsVerticalScrollIndicator={IS_ANDROID ? false : undefined}
335 {...props}
336 style={[a.h_full, style]}
337 contentContainerStyle={[
338 {paddingTop: headerOffset},
339 android({
340 paddingBottom: insets.top + insets.bottom + tokens.space.xl,
341 }),
342 contentContainerStyle,
343 ]}
344 />
345 {footer}
346 </ScrollProvider>
347 )
348})
349
350export function FlatListFooter({
351 children,
352 onLayout,
353}: {
354 children: React.ReactNode
355 onLayout?: (event: LayoutChangeEvent) => void
356}) {
357 const t = useTheme()
358 const {bottom} = useSafeAreaInsets()
359 const {height} = useReanimatedKeyboardAnimation()
360
361 const animatedStyle = useAnimatedStyle(() => {
362 if (!IS_IOS) return {}
363 return {
364 transform: [{translateY: Math.min(0, height.get() + bottom - 10)}],
365 }
366 })
367
368 return (
369 <Animated.View
370 onLayout={onLayout}
371 style={[
372 a.absolute,
373 a.bottom_0,
374 a.w_full,
375 a.z_10,
376 a.border_t,
377 t.atoms.bg,
378 t.atoms.border_contrast_low,
379 a.px_lg,
380 a.pt_md,
381 {paddingBottom: bottom + tokens.space.md},
382 // TODO: had to admit defeat here, but we should
383 // try and get this to work for Android as well -sfn
384 ios(animatedStyle),
385 ]}>
386 {children}
387 </Animated.View>
388 )
389}
390
391export function Handle({
392 difference = false,
393 fill,
394}: {
395 difference?: boolean
396 fill?: string
397}) {
398 const t = useTheme()
399 const {_} = useLingui()
400 const {screenReaderEnabled} = useA11y()
401 const {close} = useDialogContext()
402
403 return (
404 <View style={[a.absolute, a.w_full, a.align_center, a.z_10, {height: 20}]}>
405 <Pressable
406 accessible={screenReaderEnabled}
407 onPress={() => close()}
408 accessibilityLabel={_(msg`Dismiss`)}
409 accessibilityHint={_(msg`Double tap to close the dialog`)}>
410 <View
411 style={[
412 a.rounded_sm,
413 {
414 top: tokens.space._2xl / 2 - 2.5,
415 width: 35,
416 height: 5,
417 alignSelf: 'center',
418 },
419 difference
420 ? {
421 // TODO: mixBlendMode is only available on the new architecture -sfn
422 // backgroundColor: t.palette.white,
423 // mixBlendMode: 'difference',
424 backgroundColor: t.palette.white,
425 opacity: 0.75,
426 }
427 : {
428 backgroundColor: fill || t.palette.contrast_975,
429 opacity: 0.5,
430 },
431 ]}
432 />
433 </Pressable>
434 </View>
435 )
436}
437
438export function Close() {
439 return null
440}
441
442export function Backdrop() {
443 return null
444}