diff --git a/src/screens/Profile/Header/GrowableAvatar.tsx b/src/screens/Profile/Header/GrowableAvatar.tsx index 20ac14892d..f1e963dc68 100644 --- a/src/screens/Profile/Header/GrowableAvatar.tsx +++ b/src/screens/Profile/Header/GrowableAvatar.tsx @@ -19,7 +19,7 @@ export function GrowableAvatar({ }) { const pagerContext = usePagerHeaderContext() - // pagerContext should only be present on iOS, but better safe than sorry + // iOS only if (!pagerContext || !isIOS) { return {children} } diff --git a/src/screens/Profile/Header/GrowableBanner.tsx b/src/screens/Profile/Header/GrowableBanner.tsx index e1bb8e00ef..8fddfab46b 100644 --- a/src/screens/Profile/Header/GrowableBanner.tsx +++ b/src/screens/Profile/Header/GrowableBanner.tsx @@ -4,41 +4,55 @@ import {ActivityIndicator} from 'react-native' import Animated, { Extrapolation, interpolate, + LayoutAnimationConfig, runOnJS, SharedValue, useAnimatedProps, useAnimatedReaction, useAnimatedStyle, } from 'react-native-reanimated' +import {useSafeAreaInsets} from 'react-native-safe-area-context' import {BlurView} from 'expo-blur' +import {AppBskyActorDefs, ModerationDecision} from '@atproto/api' import {useIsFetching} from '@tanstack/react-query' import {isIOS} from '#/platform/detection' +import {Shadow} from '#/state/cache/types' 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' +import {NavHeader} from './NavHeader' const AnimatedBlurView = Animated.createAnimatedComponent(BlurView) export function GrowableBanner({ backButton, + profile, + moderation, children, }: { backButton?: React.ReactNode + profile: Shadow + moderation: ModerationDecision children: React.ReactNode }) { const pagerContext = usePagerHeaderContext() - // pagerContext should only be present on iOS, but better safe than sorry if (!pagerContext || !isIOS) { return ( - - {backButton} - {children} - + <> + + + + {children} + ) } @@ -62,6 +76,7 @@ function GrowableBannerInner({ }) { const isFetching = useIsProfileFetching() const animateSpinner = useShouldAnimateSpinner({isFetching, scrollY}) + const {top: topInset} = useSafeAreaInsets() const animatedStyle = useAnimatedStyle(() => ({ transform: [ @@ -103,7 +118,7 @@ function GrowableBannerInner({ const animatedBackButtonStyle = useAnimatedStyle(() => ({ transform: [ { - translateY: interpolate(scrollY.value, [-150, 60], [-150, 60], { + translateY: interpolate(scrollY.value, [-150, 0], [-150, 0], { extrapolateRight: Extrapolation.CLAMP, }), }, @@ -127,7 +142,14 @@ function GrowableBannerInner({ animatedProps={animatedBlurViewProps} /> - + - + {backButton} diff --git a/src/screens/Profile/Header/NavHeader.tsx b/src/screens/Profile/Header/NavHeader.tsx new file mode 100644 index 0000000000..8edadc8584 --- /dev/null +++ b/src/screens/Profile/Header/NavHeader.tsx @@ -0,0 +1,118 @@ +import React, {useState} from 'react' +import {View} from 'react-native' +import Animated, { + runOnJS, + SlideInUp, + SlideOutUp, + useAnimatedReaction, + useAnimatedStyle, +} from 'react-native-reanimated' +import {useSafeAreaInsets} from 'react-native-safe-area-context' +import {StatusBar} from 'expo-status-bar' +import {AppBskyActorDefs, ModerationDecision} from '@atproto/api' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {sanitizeDisplayName} from '#/lib/strings/display-names' +import {isInvalidHandle, sanitizeHandle} from '#/lib/strings/handles' +import {Shadow} from '#/state/cache/types' +import {usePagerHeaderContext} from '#/view/com/pager/PagerHeaderContext' +import {UserAvatar} from '#/view/com/util/UserAvatar' +import {atoms as a, useTheme} from '#/alf' +import {Text} from '#/components/Typography' + +export const PROFILE_NAV_HEADER_HEIGHT = 50 + +interface Props { + profile: Shadow + moderation: ModerationDecision + backButton?: React.ReactNode +} + +export function NavHeader({profile, moderation, backButton}: Props) { + const insets = useSafeAreaInsets() + const t = useTheme() + const {_} = useLingui() + + return ( + + + {backButton} + + + + {sanitizeDisplayName( + profile.displayName || sanitizeHandle(profile.handle), + moderation.ui('displayName'), + )} + + + {isInvalidHandle(profile.handle) + ? _(msg`⚠Invalid Handle`) + : `@${profile.handle}`} + + + + + ) +} + +export function NavHeaderScrollLinked({...props}: Props) { + const pagerContext = usePagerHeaderContext() + + if (!pagerContext) { + throw new Error( + 'NavHeaderScrollLinked must be used within a PagerHeaderProvider and cannot be used on web', + ) + } + + const {scrollY, headerHeight} = pagerContext + const [showHeader, setShowHeader] = useState(false) + const {top: topInset} = useSafeAreaInsets() + + const navHeaderHeight = topInset + PROFILE_NAV_HEADER_HEIGHT + + useAnimatedReaction( + () => scrollY.value > 60, + (value, prevValue) => { + if (value !== prevValue) { + runOnJS(setShowHeader)(value) + } + }, + ) + + const animatedStyle = useAnimatedStyle(() => ({ + transform: [ + {translateY: Math.min(scrollY.value, headerHeight - navHeaderHeight)}, + ], + })) + + return ( + <> + {showHeader ? ( + + + + ) : ( + + )} + + ) +} diff --git a/src/screens/Profile/Header/Shell.tsx b/src/screens/Profile/Header/Shell.tsx index f7011fd359..8a9f6fb084 100644 --- a/src/screens/Profile/Header/Shell.tsx +++ b/src/screens/Profile/Header/Shell.tsx @@ -16,11 +16,12 @@ import {useSession} from '#/state/session' 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 {atoms as a, native, useTheme} from '#/alf' import {LabelsOnMe} from '#/components/moderation/LabelsOnMe' import {ProfileHeaderAlerts} from '#/components/moderation/ProfileHeaderAlerts' import {GrowableAvatar} from './GrowableAvatar' import {GrowableBanner} from './GrowableBanner' +import {NavHeaderScrollLinked} from './NavHeader' interface Props { profile: Shadow @@ -69,6 +70,8 @@ let ProfileHeaderShell = ({ pointerEvents={isIOS ? 'auto' : 'none'} style={[a.relative, {height: 150}]}> {!isDesktop && !hideBackButton && ( @@ -105,9 +108,7 @@ let ProfileHeaderShell = ({ )} - {children} - {!isPlaceholderProfile && ( )} - + {native( + , + )} ) } diff --git a/src/view/com/pager/PagerHeaderContext.tsx b/src/view/com/pager/PagerHeaderContext.tsx index fd4cc74632..8c7e49fe81 100644 --- a/src/view/com/pager/PagerHeaderContext.tsx +++ b/src/view/com/pager/PagerHeaderContext.tsx @@ -1,40 +1,51 @@ -import React, {useContext} from 'react' +import React, {useContext, useMemo} from 'react' import {SharedValue} from 'react-native-reanimated' -import {isIOS} from '#/platform/detection' +import {isNative} from '#/platform/detection' -export const PagerHeaderContext = - React.createContext | null>(null) +export const PagerHeaderContext = React.createContext<{ + scrollY: SharedValue + headerHeight: number + tabBarHeight: number +} | null>(null) /** * Passes the scrollY value to the pager header's banner, so it can grow on - * overscroll on iOS. Not necessary to use this context provider on other platforms. + * overscroll on iOS, and handle the status bar. Not necessary to use this context provider on web. * - * @platform ios + * @platform ios, android */ export function PagerHeaderProvider({ scrollY, children, + headerHeight, + tabBarHeight, }: { scrollY: SharedValue + headerHeight: number + tabBarHeight: number children: React.ReactNode }) { + const value = useMemo( + () => ({scrollY, headerHeight, tabBarHeight}), + [scrollY, headerHeight, tabBarHeight], + ) return ( - + {children} ) } export function usePagerHeaderContext() { - const scrollY = useContext(PagerHeaderContext) - if (isIOS) { - if (!scrollY) { + const context = useContext(PagerHeaderContext) + if (isNative) { + if (!context) { throw new Error( 'usePagerHeaderContext must be used within a HeaderProvider', ) } - return {scrollY} + return context } else { return null } diff --git a/src/view/com/pager/PagerWithHeader.tsx b/src/view/com/pager/PagerWithHeader.tsx index 6d601c2899..2acee6a3cc 100644 --- a/src/view/com/pager/PagerWithHeader.tsx +++ b/src/view/com/pager/PagerWithHeader.tsx @@ -43,6 +43,7 @@ export interface PagerWithHeaderProps { onPageSelected?: (index: number) => void onCurrentPageSelected?: (index: number) => void allowHeaderOverScroll?: boolean + headerMinHeight?: number } export const PagerWithHeader = React.forwardRef( function PageWithHeaderImpl( @@ -56,6 +57,7 @@ export const PagerWithHeader = React.forwardRef( onPageSelected, onCurrentPageSelected, allowHeaderOverScroll, + headerMinHeight, }: PagerWithHeaderProps, ref, ) { @@ -83,7 +85,10 @@ export const PagerWithHeader = React.forwardRef( const renderTabBar = React.useCallback( (props: RenderTabBarFnProps) => { return ( - + ( scrollY={scrollY} testID={testID} allowHeaderOverScroll={allowHeaderOverScroll} + headerMinHeight={headerMinHeight} /> ) @@ -113,6 +119,8 @@ export const PagerWithHeader = React.forwardRef( scrollY, testID, allowHeaderOverScroll, + headerMinHeight, + tabBarHeight, ], ) @@ -231,6 +239,7 @@ let PagerTabBar = ({ onCurrentPageSelected, onSelect, allowHeaderOverScroll, + headerMinHeight = 0, }: { currentPage: number headerOnlyHeight: number @@ -244,9 +253,11 @@ let PagerTabBar = ({ onCurrentPageSelected?: (index: number) => void onSelect?: (index: number) => void allowHeaderOverScroll?: boolean + headerMinHeight?: number }): React.ReactNode => { const headerTransform = useAnimatedStyle(() => { - const translateY = Math.min(scrollY.value, headerOnlyHeight) * -1 + const translateY = + Math.min(scrollY.value, headerOnlyHeight - headerMinHeight) * -1 return { transform: [ { diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx index 677fe09f47..7ba8b386ef 100644 --- a/src/view/screens/Profile.tsx +++ b/src/view/screens/Profile.tsx @@ -1,5 +1,6 @@ import React, {useCallback, useMemo} from 'react' import {StyleSheet} from 'react-native' +import {useSafeAreaInsets} from 'react-native-safe-area-context' import { AppBskyActorDefs, AppBskyGraphGetActorStarterPacks, @@ -42,6 +43,7 @@ import {FAB} from '#/view/com/util/fab/FAB' import {ListRef} from '#/view/com/util/List' import {CenteredView} from '#/view/com/util/Views' import {ProfileHeader, ProfileHeaderLoading} from '#/screens/Profile/Header' +import {PROFILE_NAV_HEADER_HEIGHT} from '#/screens/Profile/Header/NavHeader' import {ProfileFeedSection} from '#/screens/Profile/Sections/Feed' import {ProfileLabelsSection} from '#/screens/Profile/Sections/Labels' import {web} from '#/alf' @@ -58,7 +60,7 @@ interface SectionRef { type Props = NativeStackScreenProps export function ProfileScreen(props: Props) { return ( - + ) @@ -186,6 +188,7 @@ function ProfileScreenLoaded({ const [currentPage, setCurrentPage] = React.useState(0) const {_} = useLingui() const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled() + const insets = useSafeAreaInsets() const [scrollViewTag, setScrollViewTag] = React.useState(null) @@ -369,7 +372,8 @@ function ProfileScreenLoaded({ onPageSelected={onPageSelected} onCurrentPageSelected={onCurrentPageSelected} renderHeader={renderHeader} - allowHeaderOverScroll> + allowHeaderOverScroll + headerMinHeight={insets.top + PROFILE_NAV_HEADER_HEIGHT}> {showFiltersTab ? ({headerHeight, isFocused, scrollElRef}) => (