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 268 lines 6.9 kB view raw
1import {useEffect, useState} from 'react' 2import {ActivityIndicator, Pressable, View} from 'react-native' 3import Animated, { 4 type AnimatedRef, 5 Extrapolation, 6 interpolate, 7 runOnJS, 8 type SharedValue, 9 useAnimatedProps, 10 useAnimatedReaction, 11 useAnimatedStyle, 12} from 'react-native-reanimated' 13import {useSafeAreaInsets} from 'react-native-safe-area-context' 14import {BlurView} from 'expo-blur' 15import {useIsFetching} from '@tanstack/react-query' 16import type React from 'react' 17 18import {RQKEY_ROOT as STARTERPACK_RQKEY_ROOT} from '#/state/queries/actor-starter-packs' 19import {RQKEY_ROOT as FEED_RQKEY_ROOT} from '#/state/queries/post-feed' 20import {RQKEY_ROOT as FEEDGEN_RQKEY_ROOT} from '#/state/queries/profile-feedgens' 21import {RQKEY_ROOT as LIST_RQKEY_ROOT} from '#/state/queries/profile-lists' 22import {usePagerHeaderContext} from '#/view/com/pager/PagerHeaderContext' 23import {atoms as a} from '#/alf' 24import {IS_IOS} from '#/env' 25 26const AnimatedBlurView = Animated.createAnimatedComponent(BlurView) 27 28export function GrowableBanner({ 29 backButton, 30 children, 31 onPress, 32 bannerRef, 33 testID, 34 label, 35}: { 36 backButton?: React.ReactNode 37 children: React.ReactNode 38 onPress?: () => void 39 bannerRef?: AnimatedRef<Animated.View> 40 testID?: string 41 label?: string 42}) { 43 const pagerContext = usePagerHeaderContext() 44 45 // plain non-growable mode for Android/Web 46 if (!pagerContext || !IS_IOS) { 47 return ( 48 <Pressable 49 testID={testID} 50 onPress={onPress} 51 accessibilityRole="image" 52 accessibilityLabel={label} 53 accessibilityHint="" 54 style={[a.w_full, a.h_full]}> 55 <Animated.View ref={bannerRef} style={[a.w_full, a.h_full]}> 56 {children} 57 </Animated.View> 58 {backButton} 59 </Pressable> 60 ) 61 } 62 63 const {scrollY} = pagerContext 64 65 return ( 66 <GrowableBannerInner 67 scrollY={scrollY} 68 backButton={backButton} 69 onPress={onPress} 70 bannerRef={bannerRef} 71 testID={testID} 72 label={label}> 73 {children} 74 </GrowableBannerInner> 75 ) 76} 77 78function GrowableBannerInner({ 79 scrollY, 80 backButton, 81 children, 82 onPress, 83 bannerRef, 84 testID, 85 label, 86}: { 87 scrollY: SharedValue<number> 88 backButton?: React.ReactNode 89 children: React.ReactNode 90 onPress?: () => void 91 bannerRef?: AnimatedRef<Animated.View> 92 testID?: string 93 label?: string 94}) { 95 const {top: topInset} = useSafeAreaInsets() 96 const isFetching = useIsProfileFetching() 97 const animateSpinner = useShouldAnimateSpinner({isFetching, scrollY}) 98 99 const animatedStyle = useAnimatedStyle(() => ({ 100 transform: [ 101 { 102 scale: interpolate(scrollY.get(), [-150, 0], [2, 1], { 103 extrapolateRight: Extrapolation.CLAMP, 104 }), 105 }, 106 ], 107 })) 108 109 const animatedBlurViewProps = useAnimatedProps(() => { 110 return { 111 intensity: interpolate( 112 scrollY.get(), 113 [-300, -65, -15], 114 [50, 40, 0], 115 Extrapolation.CLAMP, 116 ), 117 } 118 }) 119 120 const animatedSpinnerStyle = useAnimatedStyle(() => { 121 const scrollYValue = scrollY.get() 122 return { 123 display: scrollYValue < 0 ? 'flex' : 'none', 124 opacity: interpolate( 125 scrollYValue, 126 [-60, -15], 127 [1, 0], 128 Extrapolation.CLAMP, 129 ), 130 transform: [ 131 {translateY: interpolate(scrollYValue, [-150, 0], [-75, 0])}, 132 {rotate: '90deg'}, 133 ], 134 } 135 }) 136 137 const animatedBackButtonStyle = useAnimatedStyle(() => ({ 138 transform: [ 139 { 140 translateY: interpolate(scrollY.get(), [-150, 10], [-150, 10], { 141 extrapolateRight: Extrapolation.CLAMP, 142 }), 143 }, 144 ], 145 })) 146 147 return ( 148 <> 149 <Animated.View 150 style={[ 151 a.absolute, 152 {left: 0, right: 0, bottom: 0}, 153 {height: 150}, 154 {transformOrigin: 'bottom'}, 155 animatedStyle, 156 ]}> 157 <Pressable 158 testID={testID} 159 onPress={onPress} 160 accessibilityRole="image" 161 accessibilityLabel={label} 162 accessibilityHint="" 163 style={[a.w_full, a.h_full]}> 164 <Animated.View 165 ref={bannerRef} 166 collapsable={false} 167 style={[a.w_full, a.h_full]}> 168 {children} 169 </Animated.View> 170 </Pressable> 171 <AnimatedBlurView 172 pointerEvents="none" 173 style={[a.absolute, a.inset_0]} 174 tint="dark" 175 animatedProps={animatedBlurViewProps} 176 /> 177 </Animated.View> 178 <View 179 pointerEvents="none" 180 style={[ 181 a.absolute, 182 a.inset_0, 183 {top: topInset - (IS_IOS ? 15 : 0)}, 184 a.justify_center, 185 a.align_center, 186 ]}> 187 <Animated.View style={[animatedSpinnerStyle]}> 188 <ActivityIndicator 189 key={animateSpinner ? 'spin' : 'stop'} 190 size="large" 191 color="white" 192 animating={animateSpinner} 193 hidesWhenStopped={false} 194 /> 195 </Animated.View> 196 </View> 197 <Animated.View style={[animatedBackButtonStyle]}> 198 {backButton} 199 </Animated.View> 200 </> 201 ) 202} 203 204function useIsProfileFetching() { 205 // are any of the profile-related queries fetching? 206 return [ 207 useIsFetching({queryKey: [FEED_RQKEY_ROOT]}), 208 useIsFetching({queryKey: [FEEDGEN_RQKEY_ROOT]}), 209 useIsFetching({queryKey: [LIST_RQKEY_ROOT]}), 210 useIsFetching({queryKey: [STARTERPACK_RQKEY_ROOT]}), 211 ].some(isFetching => isFetching) 212} 213 214function useShouldAnimateSpinner({ 215 isFetching, 216 scrollY, 217}: { 218 isFetching: boolean 219 scrollY: SharedValue<number> 220}) { 221 const [isOverscrolled, setIsOverscrolled] = useState(false) 222 // HACK: it reports a scroll pos of 0 for a tick when fetching finishes 223 // so paper over that by keeping it true for a bit -sfn 224 const stickyIsOverscrolled = useStickyToggle(isOverscrolled, 10) 225 226 useAnimatedReaction( 227 () => scrollY.get() < -5, 228 (value, prevValue) => { 229 if (value !== prevValue) { 230 runOnJS(setIsOverscrolled)(value) 231 } 232 }, 233 [scrollY], 234 ) 235 236 const [isAnimating, setIsAnimating] = useState(isFetching) 237 238 if (isFetching && !isAnimating) { 239 setIsAnimating(true) 240 } 241 242 if (!isFetching && isAnimating && !stickyIsOverscrolled) { 243 setIsAnimating(false) 244 } 245 246 return isAnimating 247} 248 249// stayed true for at least `delay` ms before returning to false 250function useStickyToggle(value: boolean, delay: number) { 251 const [prevValue, setPrevValue] = useState(value) 252 const [isSticking, setIsSticking] = useState(false) 253 254 useEffect(() => { 255 if (isSticking) { 256 const timeout = setTimeout(() => setIsSticking(false), delay) 257 return () => clearTimeout(timeout) 258 } 259 }, [isSticking, delay]) 260 261 if (value !== prevValue) { 262 setIsSticking(prevValue) // Going true -> false should stick. 263 setPrevValue(value) 264 return prevValue ? true : value 265 } 266 267 return isSticking ? true : value 268}