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}: {
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}