forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
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}