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