Bluesky app fork with some witchin' additions 馃挮
at main 421 lines 12 kB view raw
1import React, {useImperativeHandle} from 'react' 2import { 3 type LayoutChangeEvent, 4 type NativeScrollEvent, 5 type NativeSyntheticEvent, 6 Pressable, 7 ScrollView, 8 type StyleProp, 9 TextInput, 10 View, 11 type ViewStyle, 12} from 'react-native' 13import {useReanimatedKeyboardAnimation} from 'react-native-keyboard-controller' 14import Animated, { 15 runOnJS, 16 type ScrollEvent, 17 useAnimatedStyle, 18} from 'react-native-reanimated' 19import {useSafeAreaInsets} from 'react-native-safe-area-context' 20import {msg} from '@lingui/core/macro' 21import {useLingui} from '@lingui/react' 22 23import {ScrollProvider} from '#/lib/ScrollContext' 24import {logger} from '#/logger' 25import {useA11y} from '#/state/a11y' 26import {useDialogStateControlContext} from '#/state/dialogs' 27import {List, type ListMethods, type ListProps} from '#/view/com/util/List' 28import {android, atoms as a, ios, platform, tokens, useTheme} from '#/alf' 29import {useThemeName} from '#/alf/util/useColorModeTheme' 30import {Context, useDialogContext} from '#/components/Dialog/context' 31import { 32 type DialogControlProps, 33 type DialogInnerProps, 34 type DialogOuterProps, 35} from '#/components/Dialog/types' 36import {createInput} from '#/components/forms/TextField' 37import {IS_ANDROID, IS_IOS, IS_LIQUID_GLASS} from '#/env' 38import {BottomSheet, BottomSheetSnapPoint} from '../../../modules/bottom-sheet' 39import { 40 type BottomSheetSnapPointChangeEvent, 41 type BottomSheetStateChangeEvent, 42} from '../../../modules/bottom-sheet/src/BottomSheet.types' 43import {type BottomSheetNativeComponent} from '../../../modules/bottom-sheet/src/BottomSheetNativeComponent' 44 45export {useDialogContext, useDialogControl} from '#/components/Dialog/context' 46export * from '#/components/Dialog/shared' 47export * from '#/components/Dialog/types' 48export * from '#/components/Dialog/utils' 49 50export const Input = createInput(TextInput) 51 52export function Outer({ 53 children, 54 control, 55 onClose, 56 nativeOptions, 57 testID, 58}: React.PropsWithChildren<DialogOuterProps>) { 59 const themeName = useThemeName() 60 const t = useTheme(themeName) 61 const ref = React.useRef<BottomSheetNativeComponent>(null) 62 const closeCallbacks = React.useRef<(() => void)[]>([]) 63 const {setDialogIsOpen, setFullyExpandedCount} = 64 useDialogStateControlContext() 65 66 const prevSnapPoint = React.useRef<BottomSheetSnapPoint>( 67 BottomSheetSnapPoint.Hidden, 68 ) 69 70 const [disableDrag, setDisableDrag] = React.useState(false) 71 const [snapPoint, setSnapPoint] = React.useState<BottomSheetSnapPoint>( 72 BottomSheetSnapPoint.Partial, 73 ) 74 75 const callQueuedCallbacks = React.useCallback(() => { 76 for (const cb of closeCallbacks.current) { 77 try { 78 cb() 79 } catch (e: any) { 80 logger.error(e || 'Error running close callback') 81 } 82 } 83 84 closeCallbacks.current = [] 85 }, []) 86 87 const open = React.useCallback<DialogControlProps['open']>(() => { 88 // Run any leftover callbacks that might have been queued up before calling `.open()` 89 callQueuedCallbacks() 90 setDialogIsOpen(control.id, true) 91 ref.current?.present() 92 }, [setDialogIsOpen, control.id, callQueuedCallbacks]) 93 94 // This is the function that we call when we want to dismiss the dialog. 95 const close = React.useCallback<DialogControlProps['close']>(cb => { 96 if (typeof cb === 'function') { 97 closeCallbacks.current.push(cb) 98 } 99 ref.current?.dismiss() 100 }, []) 101 102 // This is the actual thing we are doing once we "confirm" the dialog. We want the dialog's close animation to 103 // happen before we run this. It is passed to the `BottomSheet` component. 104 const onCloseAnimationComplete = React.useCallback(() => { 105 // This removes the dialog from our list of stored dialogs. Not super necessary on iOS, but on Android this 106 // tells us that we need to toggle the accessibility overlay setting 107 setDialogIsOpen(control.id, false) 108 callQueuedCallbacks() 109 onClose?.() 110 }, [callQueuedCallbacks, control.id, onClose, setDialogIsOpen]) 111 112 const onSnapPointChange = (e: BottomSheetSnapPointChangeEvent) => { 113 const {snapPoint} = e.nativeEvent 114 setSnapPoint(snapPoint) 115 116 if ( 117 snapPoint === BottomSheetSnapPoint.Full && 118 prevSnapPoint.current !== BottomSheetSnapPoint.Full 119 ) { 120 setFullyExpandedCount(c => c + 1) 121 } else if ( 122 snapPoint !== BottomSheetSnapPoint.Full && 123 prevSnapPoint.current === BottomSheetSnapPoint.Full 124 ) { 125 setFullyExpandedCount(c => c - 1) 126 } 127 prevSnapPoint.current = snapPoint 128 } 129 130 const onStateChange = (e: BottomSheetStateChangeEvent) => { 131 if (e.nativeEvent.state === 'closed') { 132 onCloseAnimationComplete() 133 134 if (prevSnapPoint.current === BottomSheetSnapPoint.Full) { 135 setFullyExpandedCount(c => c - 1) 136 } 137 prevSnapPoint.current = BottomSheetSnapPoint.Hidden 138 } 139 } 140 141 useImperativeHandle( 142 control.ref, 143 () => ({ 144 open, 145 close, 146 }), 147 [open, close], 148 ) 149 150 const context = React.useMemo( 151 () => ({ 152 close, 153 isNativeDialog: true, 154 nativeSnapPoint: snapPoint, 155 disableDrag, 156 setDisableDrag, 157 isWithinDialog: true, 158 }), 159 [close, snapPoint, disableDrag, setDisableDrag], 160 ) 161 162 return ( 163 <BottomSheet 164 ref={ref} 165 // device-bezel radius when undefined 166 cornerRadius={IS_LIQUID_GLASS ? undefined : 20} 167 backgroundColor={t.atoms.bg.backgroundColor} 168 {...nativeOptions} 169 onSnapPointChange={onSnapPointChange} 170 onStateChange={onStateChange} 171 disableDrag={disableDrag}> 172 <Context.Provider value={context}> 173 <View testID={testID} style={[a.relative]}> 174 {children} 175 </View> 176 </Context.Provider> 177 </BottomSheet> 178 ) 179} 180 181/** 182 * @deprecated use `Dialog.ScrollableInner` instead 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 IS_LIQUID_GLASS 194 ? a.pb_2xl 195 : {paddingBottom: insets.bottom + insets.top}, 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 const isAtMaxSnapPoint = nativeSnapPoint === BottomSheetSnapPoint.Full 212 213 let paddingBottom = 0 214 if (IS_IOS) { 215 paddingBottom = tokens.space._2xl 216 } else { 217 paddingBottom = 218 Math.max(insets.bottom, tokens.space._5xl) + tokens.space._2xl 219 if (isAtMaxSnapPoint) { 220 paddingBottom += insets.top 221 } 222 } 223 224 const onScroll = (e: NativeSyntheticEvent<NativeScrollEvent>) => { 225 if (!IS_ANDROID) { 226 return 227 } 228 const {contentOffset} = e.nativeEvent 229 if (contentOffset.y > 0 && !disableDrag) { 230 setDisableDrag(true) 231 } else if (contentOffset.y <= 1 && disableDrag) { 232 setDisableDrag(false) 233 } 234 } 235 236 return ( 237 <ScrollView 238 contentContainerStyle={[ 239 a.pt_2xl, 240 IS_LIQUID_GLASS ? a.px_2xl : a.px_xl, 241 {paddingBottom}, 242 contentContainerStyle, 243 ]} 244 ref={ref} 245 showsVerticalScrollIndicator={IS_ANDROID ? false : undefined} 246 contentInsetAdjustmentBehavior={ 247 isAtMaxSnapPoint ? 'automatic' : 'never' 248 } 249 automaticallyAdjustKeyboardInsets={isAtMaxSnapPoint} 250 {...props} 251 bounces={isAtMaxSnapPoint} 252 scrollEventThrottle={50} 253 onScroll={IS_ANDROID ? onScroll : undefined} 254 keyboardShouldPersistTaps="handled" 255 // TODO: figure out why this positions the header absolutely (rather than stickily) 256 // on Android. fine to disable for now, because we don't have any 257 // dialogs that use this that actually scroll -sfn 258 stickyHeaderIndices={ios(header ? [0] : undefined)}> 259 {header} 260 {children} 261 </ScrollView> 262 ) 263 }, 264) 265 266export const InnerFlatList = React.forwardRef< 267 ListMethods, 268 ListProps<any> & { 269 webInnerStyle?: StyleProp<ViewStyle> 270 webInnerContentContainerStyle?: StyleProp<ViewStyle> 271 footer?: React.ReactNode 272 } 273>(function InnerFlatList( 274 {headerOffset, footer, style, contentContainerStyle, ...props}, 275 ref, 276) { 277 const insets = useSafeAreaInsets() 278 const {nativeSnapPoint, disableDrag, setDisableDrag} = useDialogContext() 279 280 const isAtMaxSnapPoint = nativeSnapPoint === BottomSheetSnapPoint.Full 281 282 const onScroll = (e: ScrollEvent) => { 283 'worklet' 284 if (!IS_ANDROID) { 285 return 286 } 287 const {contentOffset} = e 288 if (contentOffset.y > 0 && !disableDrag) { 289 runOnJS(setDisableDrag)(true) 290 } else if (contentOffset.y <= 1 && disableDrag) { 291 runOnJS(setDisableDrag)(false) 292 } 293 } 294 295 return ( 296 <ScrollProvider onScroll={onScroll}> 297 <List 298 keyboardShouldPersistTaps="handled" 299 contentInsetAdjustmentBehavior={ 300 isAtMaxSnapPoint ? 'automatic' : 'never' 301 } 302 automaticallyAdjustKeyboardInsets={isAtMaxSnapPoint} 303 scrollIndicatorInsets={{top: headerOffset}} 304 bounces={isAtMaxSnapPoint} 305 ref={ref} 306 showsVerticalScrollIndicator={IS_ANDROID ? false : undefined} 307 {...props} 308 style={[a.h_full, style]} 309 contentContainerStyle={[ 310 {paddingTop: headerOffset}, 311 android({ 312 paddingBottom: insets.top + insets.bottom + tokens.space.xl, 313 }), 314 contentContainerStyle, 315 ]} 316 /> 317 {footer} 318 </ScrollProvider> 319 ) 320}) 321 322export function FlatListFooter({ 323 children, 324 onLayout, 325}: { 326 children: React.ReactNode 327 onLayout?: (event: LayoutChangeEvent) => void 328}) { 329 const t = useTheme() 330 const {top, bottom} = useSafeAreaInsets() 331 const {height} = useReanimatedKeyboardAnimation() 332 333 const animatedStyle = useAnimatedStyle(() => { 334 if (!IS_IOS) return {} 335 return { 336 transform: [{translateY: Math.min(0, height.get() + bottom - 10)}], 337 } 338 }) 339 340 return ( 341 <Animated.View 342 onLayout={onLayout} 343 style={[ 344 a.absolute, 345 a.bottom_0, 346 a.w_full, 347 a.z_10, 348 a.border_t, 349 t.atoms.bg, 350 t.atoms.border_contrast_low, 351 a.px_lg, 352 a.pt_md, 353 { 354 paddingBottom: platform({ 355 ios: tokens.space.md + bottom + (IS_LIQUID_GLASS ? top : 0), 356 android: tokens.space.md + bottom + top, 357 }), 358 }, 359 // TODO: had to admit defeat here, but we should 360 // try and get this to work for Android as well -sfn 361 ios(animatedStyle), 362 ]}> 363 {children} 364 </Animated.View> 365 ) 366} 367 368export function Handle({ 369 difference = false, 370 fill, 371}: { 372 difference?: boolean 373 fill?: string 374}) { 375 const t = useTheme() 376 const {_} = useLingui() 377 const {screenReaderEnabled} = useA11y() 378 const {close} = useDialogContext() 379 380 return ( 381 <View style={[a.absolute, a.w_full, a.align_center, a.z_10, {height: 20}]}> 382 <Pressable 383 accessible={screenReaderEnabled} 384 onPress={() => close()} 385 accessibilityLabel={_(msg`Dismiss`)} 386 accessibilityHint={_(msg`Double tap to close the dialog`)}> 387 <View 388 style={[ 389 a.rounded_sm, 390 { 391 top: tokens.space._2xl / 2 - 2.5, 392 width: 35, 393 height: 5, 394 alignSelf: 'center', 395 }, 396 difference 397 ? { 398 // TODO: mixBlendMode is only available on the new architecture -sfn 399 // backgroundColor: t.palette.white, 400 // mixBlendMode: 'difference', 401 backgroundColor: t.palette.white, 402 opacity: 0.75, 403 } 404 : { 405 backgroundColor: fill || t.palette.contrast_975, 406 opacity: 0.5, 407 }, 408 ]} 409 /> 410 </Pressable> 411 </View> 412 ) 413} 414 415export function Close() { 416 return null 417} 418 419export function Backdrop() { 420 return null 421}