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