Bluesky app fork with some witchin' additions 馃挮
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

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