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