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}) => (