Bluesky app fork with some witchin' additions 馃挮 witchsky.app
bluesky fork client
119
fork

Configure Feed

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

at a876aae44ea07494ebea9727350aa060b81f317b 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}