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