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

Configure Feed

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

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