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