diff options
Diffstat (limited to 'src/screens/Profile')
-rw-r--r-- | src/screens/Profile/Header/GrowableBanner.tsx | 212 | ||||
-rw-r--r-- | src/screens/Profile/Header/Shell.tsx | 84 |
2 files changed, 262 insertions, 34 deletions
diff --git a/src/screens/Profile/Header/GrowableBanner.tsx b/src/screens/Profile/Header/GrowableBanner.tsx new file mode 100644 index 000000000..bccc1e57e --- /dev/null +++ b/src/screens/Profile/Header/GrowableBanner.tsx @@ -0,0 +1,212 @@ +import React, {useEffect, useState} from 'react' +import {View} from 'react-native' +import {ActivityIndicator} from 'react-native' +import Animated, { + Extrapolation, + interpolate, + runOnJS, + SharedValue, + useAnimatedProps, + useAnimatedReaction, + useAnimatedStyle, +} from 'react-native-reanimated' +import {BlurView} from 'expo-blur' +import {useIsFetching} from '@tanstack/react-query' + +import {isIOS} from '#/platform/detection' +import {RQKEY_ROOT as STARTERPACK_RQKEY_ROOT} from '#/state/queries/actor-starter-packs' +import {RQKEY_ROOT as FEED_RQKEY_ROOT} from '#/state/queries/post-feed' +import {RQKEY_ROOT as FEEDGEN_RQKEY_ROOT} from '#/state/queries/profile-feedgens' +import {RQKEY_ROOT as LIST_RQKEY_ROOT} from '#/state/queries/profile-lists' +import {usePagerHeaderContext} from '#/view/com/pager/PagerHeaderContext' +import {atoms as a} from '#/alf' + +const AnimatedBlurView = Animated.createAnimatedComponent(BlurView) + +export function GrowableBanner({ + backButton, + children, +}: { + backButton?: React.ReactNode + children: React.ReactNode +}) { + const pagerContext = usePagerHeaderContext() + + // pagerContext should only be present on iOS, but better safe than sorry + if (!pagerContext || !isIOS) { + return ( + <View style={[a.w_full, a.h_full]}> + {backButton} + {children} + </View> + ) + } + + const {scrollY} = pagerContext + + return ( + <GrowableBannerInner scrollY={scrollY} backButton={backButton}> + {children} + </GrowableBannerInner> + ) +} + +function GrowableBannerInner({ + scrollY, + backButton, + children, +}: { + scrollY: SharedValue<number> + backButton?: React.ReactNode + children: React.ReactNode +}) { + const isFetching = useIsProfileFetching() + const animateSpinner = useShouldAnimateSpinner({isFetching, scrollY}) + + const animatedStyle = useAnimatedStyle(() => ({ + transform: [ + { + scale: interpolate(scrollY.value, [-150, 0], [2, 1], { + extrapolateRight: Extrapolation.CLAMP, + }), + }, + ], + })) + + const animatedBlurViewProps = useAnimatedProps(() => { + return { + intensity: interpolate( + scrollY.value, + [-400, -100, -15], + [70, 60, 0], + Extrapolation.CLAMP, + ), + } + }) + + const animatedSpinnerStyle = useAnimatedStyle(() => { + return { + display: scrollY.value < 0 ? 'flex' : 'none', + opacity: interpolate( + scrollY.value, + [-60, -15], + [1, 0], + Extrapolation.CLAMP, + ), + transform: [ + {translateY: interpolate(scrollY.value, [-150, 0], [-75, 0])}, + {rotate: '90deg'}, + ], + } + }) + + const animatedBackButtonStyle = useAnimatedStyle(() => ({ + transform: [ + { + translateY: interpolate(scrollY.value, [-150, 60], [-150, 60], { + extrapolateRight: Extrapolation.CLAMP, + }), + }, + ], + })) + + return ( + <> + <Animated.View + style={[ + a.absolute, + {left: 0, right: 0, bottom: 0}, + {height: 150}, + {transformOrigin: 'bottom'}, + animatedStyle, + ]}> + {children} + <AnimatedBlurView + style={[a.absolute, a.inset_0]} + tint="dark" + animatedProps={animatedBlurViewProps} + /> + </Animated.View> + <View style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}> + <Animated.View style={[animatedSpinnerStyle]}> + <ActivityIndicator + key={animateSpinner ? 'spin' : 'stop'} + size="large" + color="white" + animating={animateSpinner} + hidesWhenStopped={false} + /> + </Animated.View> + </View> + <Animated.View style={[animatedBackButtonStyle]}> + {backButton} + </Animated.View> + </> + ) +} + +function useIsProfileFetching() { + // are any of the profile-related queries fetching? + return [ + useIsFetching({queryKey: [FEED_RQKEY_ROOT]}), + useIsFetching({queryKey: [FEEDGEN_RQKEY_ROOT]}), + useIsFetching({queryKey: [LIST_RQKEY_ROOT]}), + useIsFetching({queryKey: [STARTERPACK_RQKEY_ROOT]}), + ].some(isFetching => isFetching) +} + +function useShouldAnimateSpinner({ + isFetching, + scrollY, +}: { + isFetching: boolean + scrollY: SharedValue<number> +}) { + const [isOverscrolled, setIsOverscrolled] = useState(false) + // HACK: it reports a scroll pos of 0 for a tick when fetching finishes + // so paper over that by keeping it true for a bit -sfn + const stickyIsOverscrolled = useStickyToggle(isOverscrolled, 10) + + useAnimatedReaction( + () => scrollY.value < -5, + (value, prevValue) => { + if (value !== prevValue) { + runOnJS(setIsOverscrolled)(value) + } + }, + [scrollY], + ) + + const [isAnimating, setIsAnimating] = useState(isFetching) + + if (isFetching && !isAnimating) { + setIsAnimating(true) + } + + if (!isFetching && isAnimating && !stickyIsOverscrolled) { + setIsAnimating(false) + } + + return isAnimating +} + +// stayed true for at least `delay` ms before returning to false +function useStickyToggle(value: boolean, delay: number) { + const [prevValue, setPrevValue] = useState(value) + const [isSticking, setIsSticking] = useState(false) + + useEffect(() => { + if (isSticking) { + const timeout = setTimeout(() => setIsSticking(false), delay) + return () => clearTimeout(timeout) + } + }, [isSticking, delay]) + + if (value !== prevValue) { + setIsSticking(prevValue) // Going true -> false should stick. + setPrevValue(value) + return prevValue ? true : value + } + + return isSticking ? true : value +} diff --git a/src/screens/Profile/Header/Shell.tsx b/src/screens/Profile/Header/Shell.tsx index 90c283090..d31912dda 100644 --- a/src/screens/Profile/Header/Shell.tsx +++ b/src/screens/Profile/Header/Shell.tsx @@ -6,19 +6,20 @@ import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useNavigation} from '@react-navigation/native' +import {BACK_HITSLOP} from '#/lib/constants' +import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' +import {NavigationProp} from '#/lib/routes/types' +import {isIOS} from '#/platform/detection' import {Shadow} from '#/state/cache/types' import {ProfileImageLightbox, useLightboxControls} from '#/state/lightbox' import {useSession} from '#/state/session' -import {BACK_HITSLOP} from 'lib/constants' -import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -import {NavigationProp} from 'lib/routes/types' -import {isIOS} from 'platform/detection' -import {LoadingPlaceholder} from 'view/com/util/LoadingPlaceholder' -import {UserAvatar} from 'view/com/util/UserAvatar' -import {UserBanner} from 'view/com/util/UserBanner' +import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' +import {UserAvatar} from '#/view/com/util/UserAvatar' +import {UserBanner} from '#/view/com/util/UserBanner' import {atoms as a, useTheme} from '#/alf' import {LabelsOnMe} from '#/components/moderation/LabelsOnMe' import {ProfileHeaderAlerts} from '#/components/moderation/ProfileHeaderAlerts' +import {GrowableBanner} from './GrowableBanner' interface Props { profile: Shadow<AppBskyActorDefs.ProfileViewDetailed> @@ -63,20 +64,45 @@ let ProfileHeaderShell = ({ return ( <View style={t.atoms.bg} pointerEvents={isIOS ? 'auto' : 'box-none'}> - <View pointerEvents={isIOS ? 'auto' : 'none'}> - {isPlaceholderProfile ? ( - <LoadingPlaceholder - width="100%" - height={150} - style={{borderRadius: 0}} - /> - ) : ( - <UserBanner - type={profile.associated?.labeler ? 'labeler' : 'default'} - banner={profile.banner} - moderation={moderation.ui('banner')} - /> - )} + <View + pointerEvents={isIOS ? 'auto' : 'none'} + style={[a.relative, {height: 150}]}> + <GrowableBanner + backButton={ + <> + {!isDesktop && !hideBackButton && ( + <TouchableWithoutFeedback + testID="profileHeaderBackBtn" + onPress={onPressBack} + hitSlop={BACK_HITSLOP} + accessibilityRole="button" + accessibilityLabel={_(msg`Back`)} + accessibilityHint=""> + <View style={styles.backBtnWrapper}> + <FontAwesomeIcon + size={18} + icon="angle-left" + color="white" + /> + </View> + </TouchableWithoutFeedback> + )} + </> + }> + {isPlaceholderProfile ? ( + <LoadingPlaceholder + width="100%" + height="100%" + style={{borderRadius: 0}} + /> + ) : ( + <UserBanner + type={profile.associated?.labeler ? 'labeler' : 'default'} + banner={profile.banner} + moderation={moderation.ui('banner')} + /> + )} + </GrowableBanner> </View> {children} @@ -93,19 +119,6 @@ let ProfileHeaderShell = ({ </View> )} - {!isDesktop && !hideBackButton && ( - <TouchableWithoutFeedback - testID="profileHeaderBackBtn" - onPress={onPressBack} - hitSlop={BACK_HITSLOP} - accessibilityRole="button" - accessibilityLabel={_(msg`Back`)} - accessibilityHint=""> - <View style={styles.backBtnWrapper}> - <FontAwesomeIcon size={18} icon="angle-left" color="white" /> - </View> - </TouchableWithoutFeedback> - )} <TouchableWithoutFeedback testID="profileHeaderAviButton" onPress={onPressAvi} @@ -144,6 +157,9 @@ const styles = StyleSheet.create({ borderRadius: 15, // @ts-ignore web only cursor: 'pointer', + backgroundColor: 'rgba(0, 0, 0, 0.5)', + alignItems: 'center', + justifyContent: 'center', }, backBtn: { width: 30, |