Bluesky app fork with some witchin' additions 馃挮
at main 411 lines 12 kB view raw
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}