my fork of the bluesky client
at main 333 lines 9.6 kB view raw
1import React, {useImperativeHandle} from 'react' 2import { 3 NativeScrollEvent, 4 NativeSyntheticEvent, 5 Pressable, 6 ScrollView, 7 StyleProp, 8 TextInput, 9 View, 10 ViewStyle, 11} from 'react-native' 12import { 13 KeyboardAwareScrollView, 14 useKeyboardHandler, 15} from 'react-native-keyboard-controller' 16import {runOnJS} from 'react-native-reanimated' 17import {ReanimatedScrollEvent} from 'react-native-reanimated/lib/typescript/hook/commonTypes' 18import {useSafeAreaInsets} from 'react-native-safe-area-context' 19import {msg} from '@lingui/macro' 20import {useLingui} from '@lingui/react' 21 22import {useEnableKeyboardController} from '#/lib/hooks/useEnableKeyboardController' 23import {ScrollProvider} from '#/lib/ScrollContext' 24import {logger} from '#/logger' 25import {isAndroid, isIOS} from '#/platform/detection' 26import {useA11y} from '#/state/a11y' 27import {useDialogStateControlContext} from '#/state/dialogs' 28import {List, ListMethods, ListProps} from '#/view/com/util/List' 29import {atoms as a, useTheme} from '#/alf' 30import {Context, useDialogContext} from '#/components/Dialog/context' 31import { 32 DialogControlProps, 33 DialogInnerProps, 34 DialogOuterProps, 35} from '#/components/Dialog/types' 36import {createInput} from '#/components/forms/TextField' 37import {BottomSheet, BottomSheetSnapPoint} from '../../../modules/bottom-sheet' 38import { 39 BottomSheetSnapPointChangeEvent, 40 BottomSheetStateChangeEvent, 41} from '../../../modules/bottom-sheet/src/BottomSheet.types' 42import {BottomSheetNativeComponent} from '../../../modules/bottom-sheet/src/BottomSheetNativeComponent' 43 44export {useDialogContext, useDialogControl} from '#/components/Dialog/context' 45export * from '#/components/Dialog/shared' 46export * from '#/components/Dialog/types' 47export * from '#/components/Dialog/utils' 48// @ts-ignore 49export const Input = createInput(TextInput) 50 51export function Outer({ 52 children, 53 control, 54 onClose, 55 nativeOptions, 56 testID, 57}: React.PropsWithChildren<DialogOuterProps>) { 58 const t = useTheme() 59 const ref = React.useRef<BottomSheetNativeComponent>(null) 60 const closeCallbacks = React.useRef<(() => void)[]>([]) 61 const {setDialogIsOpen, setFullyExpandedCount} = 62 useDialogStateControlContext() 63 64 const prevSnapPoint = React.useRef<BottomSheetSnapPoint>( 65 BottomSheetSnapPoint.Hidden, 66 ) 67 68 const [disableDrag, setDisableDrag] = React.useState(false) 69 const [snapPoint, setSnapPoint] = React.useState<BottomSheetSnapPoint>( 70 BottomSheetSnapPoint.Partial, 71 ) 72 73 const callQueuedCallbacks = React.useCallback(() => { 74 for (const cb of closeCallbacks.current) { 75 try { 76 cb() 77 } catch (e: any) { 78 logger.error(e || 'Error running close callback') 79 } 80 } 81 82 closeCallbacks.current = [] 83 }, []) 84 85 const open = React.useCallback<DialogControlProps['open']>(() => { 86 // Run any leftover callbacks that might have been queued up before calling `.open()` 87 callQueuedCallbacks() 88 setDialogIsOpen(control.id, true) 89 ref.current?.present() 90 }, [setDialogIsOpen, control.id, callQueuedCallbacks]) 91 92 // This is the function that we call when we want to dismiss the dialog. 93 const close = React.useCallback<DialogControlProps['close']>(cb => { 94 if (typeof cb === 'function') { 95 closeCallbacks.current.push(cb) 96 } 97 ref.current?.dismiss() 98 }, []) 99 100 // This is the actual thing we are doing once we "confirm" the dialog. We want the dialog's close animation to 101 // happen before we run this. It is passed to the `BottomSheet` component. 102 const onCloseAnimationComplete = React.useCallback(() => { 103 // This removes the dialog from our list of stored dialogs. Not super necessary on iOS, but on Android this 104 // tells us that we need to toggle the accessibility overlay setting 105 setDialogIsOpen(control.id, false) 106 callQueuedCallbacks() 107 onClose?.() 108 }, [callQueuedCallbacks, control.id, onClose, setDialogIsOpen]) 109 110 const onSnapPointChange = (e: BottomSheetSnapPointChangeEvent) => { 111 const {snapPoint} = e.nativeEvent 112 setSnapPoint(snapPoint) 113 114 if ( 115 snapPoint === BottomSheetSnapPoint.Full && 116 prevSnapPoint.current !== BottomSheetSnapPoint.Full 117 ) { 118 setFullyExpandedCount(c => c + 1) 119 } else if ( 120 snapPoint !== BottomSheetSnapPoint.Full && 121 prevSnapPoint.current === BottomSheetSnapPoint.Full 122 ) { 123 setFullyExpandedCount(c => c - 1) 124 } 125 prevSnapPoint.current = snapPoint 126 } 127 128 const onStateChange = (e: BottomSheetStateChangeEvent) => { 129 if (e.nativeEvent.state === 'closed') { 130 onCloseAnimationComplete() 131 132 if (prevSnapPoint.current === BottomSheetSnapPoint.Full) { 133 setFullyExpandedCount(c => c - 1) 134 } 135 prevSnapPoint.current = BottomSheetSnapPoint.Hidden 136 } 137 } 138 139 useImperativeHandle( 140 control.ref, 141 () => ({ 142 open, 143 close, 144 }), 145 [open, close], 146 ) 147 148 const context = React.useMemo( 149 () => ({ 150 close, 151 isNativeDialog: true, 152 nativeSnapPoint: snapPoint, 153 disableDrag, 154 setDisableDrag, 155 }), 156 [close, snapPoint, disableDrag, setDisableDrag], 157 ) 158 159 return ( 160 <BottomSheet 161 ref={ref} 162 cornerRadius={20} 163 backgroundColor={t.atoms.bg.backgroundColor} 164 {...nativeOptions} 165 onSnapPointChange={onSnapPointChange} 166 onStateChange={onStateChange} 167 disableDrag={disableDrag}> 168 <Context.Provider value={context}> 169 <View testID={testID}>{children}</View> 170 </Context.Provider> 171 </BottomSheet> 172 ) 173} 174 175export function Inner({children, style, header}: DialogInnerProps) { 176 const insets = useSafeAreaInsets() 177 return ( 178 <> 179 {header} 180 <View 181 style={[ 182 a.pt_2xl, 183 a.px_xl, 184 { 185 paddingBottom: insets.bottom + insets.top, 186 }, 187 style, 188 ]}> 189 {children} 190 </View> 191 </> 192 ) 193} 194 195export const ScrollableInner = React.forwardRef<ScrollView, DialogInnerProps>( 196 function ScrollableInner( 197 {children, style, contentContainerStyle, header, ...props}, 198 ref, 199 ) { 200 const {nativeSnapPoint, disableDrag, setDisableDrag} = useDialogContext() 201 const insets = useSafeAreaInsets() 202 203 useEnableKeyboardController(isIOS) 204 205 const [keyboardHeight, setKeyboardHeight] = React.useState(0) 206 207 useKeyboardHandler( 208 { 209 onEnd: e => { 210 'worklet' 211 runOnJS(setKeyboardHeight)(e.height) 212 }, 213 }, 214 [], 215 ) 216 217 const basePading = 218 (isIOS ? 30 : 50) + (isIOS ? keyboardHeight / 4 : keyboardHeight) 219 const fullPaddingBase = insets.bottom + insets.top + basePading 220 const fullPadding = isIOS ? fullPaddingBase : fullPaddingBase + 50 221 222 const paddingBottom = 223 nativeSnapPoint === BottomSheetSnapPoint.Full ? fullPadding : basePading 224 225 const onScroll = (e: NativeSyntheticEvent<NativeScrollEvent>) => { 226 if (!isAndroid) { 227 return 228 } 229 const {contentOffset} = e.nativeEvent 230 if (contentOffset.y > 0 && !disableDrag) { 231 setDisableDrag(true) 232 } else if (contentOffset.y <= 1 && disableDrag) { 233 setDisableDrag(false) 234 } 235 } 236 237 return ( 238 <KeyboardAwareScrollView 239 style={[style]} 240 contentContainerStyle={[ 241 a.pt_2xl, 242 a.px_xl, 243 {paddingBottom}, 244 contentContainerStyle, 245 ]} 246 ref={ref} 247 {...props} 248 bounces={nativeSnapPoint === BottomSheetSnapPoint.Full} 249 bottomOffset={30} 250 scrollEventThrottle={50} 251 onScroll={isAndroid ? onScroll : undefined} 252 keyboardShouldPersistTaps="handled" 253 stickyHeaderIndices={header ? [0] : undefined}> 254 {header} 255 {children} 256 </KeyboardAwareScrollView> 257 ) 258 }, 259) 260 261export const InnerFlatList = React.forwardRef< 262 ListMethods, 263 ListProps<any> & { 264 webInnerStyle?: StyleProp<ViewStyle> 265 webInnerContentContainerStyle?: StyleProp<ViewStyle> 266 } 267>(function InnerFlatList({style, ...props}, ref) { 268 const insets = useSafeAreaInsets() 269 const {nativeSnapPoint, disableDrag, setDisableDrag} = useDialogContext() 270 271 const onScroll = (e: ReanimatedScrollEvent) => { 272 'worklet' 273 if (!isAndroid) { 274 return 275 } 276 const {contentOffset} = e 277 if (contentOffset.y > 0 && !disableDrag) { 278 runOnJS(setDisableDrag)(true) 279 } else if (contentOffset.y <= 1 && disableDrag) { 280 runOnJS(setDisableDrag)(false) 281 } 282 } 283 284 return ( 285 <ScrollProvider onScroll={onScroll}> 286 <List 287 keyboardShouldPersistTaps="handled" 288 bounces={nativeSnapPoint === BottomSheetSnapPoint.Full} 289 ListFooterComponent={ 290 <View style={{height: insets.bottom + a.pt_5xl.paddingTop}} /> 291 } 292 ref={ref} 293 {...props} 294 style={[style]} 295 /> 296 </ScrollProvider> 297 ) 298}) 299 300export function Handle() { 301 const t = useTheme() 302 const {_} = useLingui() 303 const {screenReaderEnabled} = useA11y() 304 const {close} = useDialogContext() 305 306 return ( 307 <View style={[a.absolute, a.w_full, a.align_center, a.z_10, {height: 20}]}> 308 <Pressable 309 accessible={screenReaderEnabled} 310 onPress={() => close()} 311 accessibilityLabel={_(msg`Dismiss`)} 312 accessibilityHint={_(msg`Double tap to close the dialog`)}> 313 <View 314 style={[ 315 a.rounded_sm, 316 { 317 top: 10, 318 width: 35, 319 height: 5, 320 alignSelf: 'center', 321 backgroundColor: t.palette.contrast_975, 322 opacity: 0.5, 323 }, 324 ]} 325 /> 326 </Pressable> 327 </View> 328 ) 329} 330 331export function Close() { 332 return null 333}