about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
authorSamuel Newman <mozzius@protonmail.com>2024-12-12 17:46:19 +0000
committerGitHub <noreply@github.com>2024-12-12 17:46:19 +0000
commitffc63dc85fc191a51c3dc12c1afcd250f95036d5 (patch)
treed48124c74c24662abf9ee28ff6fbbdd4b1d8ee99 /src
parent4b32b0a71be4669fa0741efc46d646093c3114f5 (diff)
downloadvoidsky-ffc63dc85fc191a51c3dc12c1afcd250f95036d5.tar.zst
[Layout] Bleed profile banner into safe area (#6967)
* bleed profile banner into safe area

(cherry picked from commit 50b3a4d0c6fd94b583ffe4efa65de35c81ae7f4e)

* pointer events none when hidden

(cherry picked from commit bae2c7b2dd6d7f858a98812196628308c0877755)

* fix web

(cherry picked from commit e3f9597170375f2903b6e567b963f008ec95aed1)

* add status bar shadow

* rm log

* rm mini header

* speed up animation

* pass bool rather than int in light status bar
Diffstat (limited to 'src')
-rw-r--r--src/App.native.tsx5
-rw-r--r--src/App.web.tsx5
-rw-r--r--src/components/Layout/Header/index.tsx3
-rw-r--r--src/screens/Profile/Header/GrowableBanner.tsx15
-rw-r--r--src/screens/Profile/Header/Shell.tsx31
-rw-r--r--src/screens/Profile/Header/StatusBarShadow.tsx56
-rw-r--r--src/screens/Profile/Header/StatusBarShadow.web.tsx3
-rw-r--r--src/screens/Profile/Header/index.tsx119
-rw-r--r--src/state/shell/light-status-bar.tsx45
-rw-r--r--src/view/com/pager/PagerHeaderContext.tsx30
-rw-r--r--src/view/com/pager/PagerWithHeader.tsx25
-rw-r--r--src/view/com/pager/PagerWithHeader.web.tsx16
-rw-r--r--src/view/screens/Profile.tsx10
-rw-r--r--src/view/shell/index.tsx6
14 files changed, 322 insertions, 47 deletions
diff --git a/src/App.native.tsx b/src/App.native.tsx
index bc38eec79..c22a66e82 100644
--- a/src/App.native.tsx
+++ b/src/App.native.tsx
@@ -54,6 +54,7 @@ import {
 import {readLastActiveAccount} from '#/state/session/util'
 import {Provider as ShellStateProvider} from '#/state/shell'
 import {Provider as ComposerProvider} from '#/state/shell/composer'
+import {Provider as LightStatusBarProvider} from '#/state/shell/light-status-bar'
 import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
 import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
 import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
@@ -209,7 +210,9 @@ function App() {
                                   <SafeAreaProvider
                                     initialMetrics={initialWindowMetrics}>
                                     <IntentDialogProvider>
-                                      <InnerApp />
+                                      <LightStatusBarProvider>
+                                        <InnerApp />
+                                      </LightStatusBarProvider>
                                     </IntentDialogProvider>
                                   </SafeAreaProvider>
                                 </StarterPackProvider>
diff --git a/src/App.web.tsx b/src/App.web.tsx
index 808b0fc27..b7c5a5633 100644
--- a/src/App.web.tsx
+++ b/src/App.web.tsx
@@ -40,6 +40,7 @@ import {
 import {readLastActiveAccount} from '#/state/session/util'
 import {Provider as ShellStateProvider} from '#/state/shell'
 import {Provider as ComposerProvider} from '#/state/shell/composer'
+import {Provider as LightStatusBarProvider} from '#/state/shell/light-status-bar'
 import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
 import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
 import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
@@ -181,7 +182,9 @@ function App() {
                         <PortalProvider>
                           <StarterPackProvider>
                             <IntentDialogProvider>
-                              <InnerApp />
+                              <LightStatusBarProvider>
+                                <InnerApp />
+                              </LightStatusBarProvider>
                             </IntentDialogProvider>
                           </StarterPackProvider>
                         </PortalProvider>
diff --git a/src/components/Layout/Header/index.tsx b/src/components/Layout/Header/index.tsx
index f05350dca..16b484cea 100644
--- a/src/components/Layout/Header/index.tsx
+++ b/src/components/Layout/Header/index.tsx
@@ -175,7 +175,8 @@ export function TitleText({
         gtMobile && a.text_xl,
         style,
       ]}
-      numberOfLines={2}>
+      numberOfLines={2}
+      emoji>
       {children}
     </Text>
   )
diff --git a/src/screens/Profile/Header/GrowableBanner.tsx b/src/screens/Profile/Header/GrowableBanner.tsx
index 7f5a3cd6e..3d2830439 100644
--- a/src/screens/Profile/Header/GrowableBanner.tsx
+++ b/src/screens/Profile/Header/GrowableBanner.tsx
@@ -10,6 +10,7 @@ import Animated, {
   useAnimatedReaction,
   useAnimatedStyle,
 } from 'react-native-reanimated'
+import {useSafeAreaInsets} from 'react-native-safe-area-context'
 import {BlurView} from 'expo-blur'
 import {useIsFetching} from '@tanstack/react-query'
 
@@ -32,7 +33,7 @@ export function GrowableBanner({
 }) {
   const pagerContext = usePagerHeaderContext()
 
-  // pagerContext should only be present on iOS, but better safe than sorry
+  // plain non-growable mode for Android/Web
   if (!pagerContext || !isIOS) {
     return (
       <View style={[a.w_full, a.h_full]}>
@@ -60,6 +61,7 @@ function GrowableBannerInner({
   backButton?: React.ReactNode
   children: React.ReactNode
 }) {
+  const {top: topInset} = useSafeAreaInsets()
   const isFetching = useIsProfileFetching()
   const animateSpinner = useShouldAnimateSpinner({isFetching, scrollY})
 
@@ -104,7 +106,7 @@ function GrowableBannerInner({
   const animatedBackButtonStyle = useAnimatedStyle(() => ({
     transform: [
       {
-        translateY: interpolate(scrollY.get(), [-150, 60], [-150, 60], {
+        translateY: interpolate(scrollY.get(), [-150, 10], [-150, 10], {
           extrapolateRight: Extrapolation.CLAMP,
         }),
       },
@@ -128,7 +130,14 @@ function GrowableBannerInner({
           animatedProps={animatedBlurViewProps}
         />
       </Animated.View>
-      <View style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
+      <View
+        style={[
+          a.absolute,
+          a.inset_0,
+          {top: topInset - (isIOS ? 15 : 0)},
+          a.justify_center,
+          a.align_center,
+        ]}>
         <Animated.View style={[animatedSpinnerStyle]}>
           <ActivityIndicator
             key={animateSpinner ? 'spin' : 'stop'}
diff --git a/src/screens/Profile/Header/Shell.tsx b/src/screens/Profile/Header/Shell.tsx
index 573d38145..dedbfd201 100644
--- a/src/screens/Profile/Header/Shell.tsx
+++ b/src/screens/Profile/Header/Shell.tsx
@@ -1,15 +1,14 @@
 import React, {memo} from 'react'
 import {StyleSheet, TouchableWithoutFeedback, View} from 'react-native'
 import {MeasuredDimensions, runOnJS, runOnUI} from 'react-native-reanimated'
+import {useSafeAreaInsets} from 'react-native-safe-area-context'
 import {AppBskyActorDefs, ModerationDecision} from '@atproto/api'
-import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {useNavigation} from '@react-navigation/native'
 
 import {BACK_HITSLOP} from '#/lib/constants'
 import {measureHandle, useHandleRef} from '#/lib/hooks/useHandleRef'
-import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
 import {NavigationProp} from '#/lib/routes/types'
 import {isIOS} from '#/platform/detection'
 import {Shadow} from '#/state/cache/types'
@@ -18,11 +17,13 @@ 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, platform, useTheme} from '#/alf'
+import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeftIcon} from '#/components/icons/Arrow'
 import {LabelsOnMe} from '#/components/moderation/LabelsOnMe'
 import {ProfileHeaderAlerts} from '#/components/moderation/ProfileHeaderAlerts'
 import {GrowableAvatar} from './GrowableAvatar'
 import {GrowableBanner} from './GrowableBanner'
+import {StatusBarShadow} from './StatusBarShadow'
 
 interface Props {
   profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>
@@ -43,7 +44,8 @@ let ProfileHeaderShell = ({
   const {_} = useLingui()
   const {openLightbox} = useLightboxControls()
   const navigation = useNavigation<NavigationProp>()
-  const {isDesktop} = useWebMediaQueries()
+  const {top: topInset} = useSafeAreaInsets()
+
   const aviRef = useHandleRef()
 
   const onPressBack = React.useCallback(() => {
@@ -100,10 +102,11 @@ let ProfileHeaderShell = ({
       <View
         pointerEvents={isIOS ? 'auto' : 'box-none'}
         style={[a.relative, {height: 150}]}>
+        <StatusBarShadow />
         <GrowableBanner
           backButton={
             <>
-              {!isDesktop && !hideBackButton && (
+              {!hideBackButton && (
                 <TouchableWithoutFeedback
                   testID="profileHeaderBackBtn"
                   onPress={onPressBack}
@@ -111,12 +114,17 @@ let ProfileHeaderShell = ({
                   accessibilityRole="button"
                   accessibilityLabel={_(msg`Back`)}
                   accessibilityHint="">
-                  <View style={styles.backBtnWrapper}>
-                    <FontAwesomeIcon
-                      size={18}
-                      icon="angle-left"
-                      color="white"
-                    />
+                  <View
+                    style={[
+                      styles.backBtnWrapper,
+                      {
+                        top: platform({
+                          web: 10,
+                          default: topInset,
+                        }),
+                      },
+                    ]}>
+                    <ArrowLeftIcon size="lg" fill="white" />
                   </View>
                 </TouchableWithoutFeedback>
               )}
@@ -186,7 +194,6 @@ export {ProfileHeaderShell}
 const styles = StyleSheet.create({
   backBtnWrapper: {
     position: 'absolute',
-    top: 10,
     left: 10,
     width: 30,
     height: 30,
diff --git a/src/screens/Profile/Header/StatusBarShadow.tsx b/src/screens/Profile/Header/StatusBarShadow.tsx
new file mode 100644
index 000000000..587b41051
--- /dev/null
+++ b/src/screens/Profile/Header/StatusBarShadow.tsx
@@ -0,0 +1,56 @@
+import Animated, {SharedValue, useAnimatedStyle} from 'react-native-reanimated'
+import {useSafeAreaInsets} from 'react-native-safe-area-context'
+import {LinearGradient} from 'expo-linear-gradient'
+
+import {isIOS} from '#/platform/detection'
+import {usePagerHeaderContext} from '#/view/com/pager/PagerHeaderContext'
+import {atoms as a} from '#/alf'
+
+const AnimatedLinearGradient = Animated.createAnimatedComponent(LinearGradient)
+
+export function StatusBarShadow() {
+  const {top: topInset} = useSafeAreaInsets()
+  const pagerContext = usePagerHeaderContext()
+
+  if (isIOS && pagerContext) {
+    const {scrollY} = pagerContext
+    return <StatusBarShadowInnner scrollY={scrollY} />
+  }
+
+  return (
+    <LinearGradient
+      colors={['rgba(0,0,0,0.5)', 'rgba(0,0,0,0)']}
+      style={[
+        a.absolute,
+        a.z_10,
+        {height: topInset, top: 0, left: 0, right: 0},
+      ]}
+    />
+  )
+}
+
+function StatusBarShadowInnner({scrollY}: {scrollY: SharedValue<number>}) {
+  const {top: topInset} = useSafeAreaInsets()
+
+  const animatedStyle = useAnimatedStyle(() => {
+    return {
+      transform: [
+        {
+          translateY: Math.min(0, scrollY.get()),
+        },
+      ],
+    }
+  })
+
+  return (
+    <AnimatedLinearGradient
+      colors={['rgba(0,0,0,0.5)', 'rgba(0,0,0,0)']}
+      style={[
+        animatedStyle,
+        a.absolute,
+        a.z_10,
+        {height: topInset, top: 0, left: 0, right: 0},
+      ]}
+    />
+  )
+}
diff --git a/src/screens/Profile/Header/StatusBarShadow.web.tsx b/src/screens/Profile/Header/StatusBarShadow.web.tsx
new file mode 100644
index 000000000..cd79871ea
--- /dev/null
+++ b/src/screens/Profile/Header/StatusBarShadow.web.tsx
@@ -0,0 +1,3 @@
+export function StatusBarShadow() {
+  return null
+}
diff --git a/src/screens/Profile/Header/index.tsx b/src/screens/Profile/Header/index.tsx
index deb8063d9..7e4b9bb31 100644
--- a/src/screens/Profile/Header/index.tsx
+++ b/src/screens/Profile/Header/index.tsx
@@ -1,14 +1,25 @@
-import React, {memo} from 'react'
-import {StyleSheet, View} from 'react-native'
+import React, {memo, useState} from 'react'
+import {LayoutChangeEvent, StyleSheet, View} from 'react-native'
+import Animated, {
+  runOnJS,
+  useAnimatedReaction,
+  useAnimatedStyle,
+  withTiming,
+} from 'react-native-reanimated'
+import {useSafeAreaInsets} from 'react-native-safe-area-context'
 import {
   AppBskyActorDefs,
   AppBskyLabelerDefs,
   ModerationOpts,
   RichText as RichTextAPI,
 } from '@atproto/api'
+import {useIsFocused} from '@react-navigation/native'
 
+import {isNative} from '#/platform/detection'
+import {useSetLightStatusBar} from '#/state/shell/light-status-bar'
+import {usePagerHeaderContext} from '#/view/com/pager/PagerHeaderContext'
 import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
-import {useTheme} from '#/alf'
+import {atoms as a, useTheme} from '#/alf'
 import {ProfileHeaderLabeler} from './ProfileHeaderLabeler'
 import {ProfileHeaderStandard} from './ProfileHeaderStandard'
 
@@ -43,20 +54,114 @@ interface Props {
   moderationOpts: ModerationOpts
   hideBackButton?: boolean
   isPlaceholderProfile?: boolean
+  setMinimumHeight: (height: number) => void
 }
 
-let ProfileHeader = (props: Props): React.ReactNode => {
+let ProfileHeader = ({setMinimumHeight, ...props}: Props): React.ReactNode => {
+  let content
   if (props.profile.associated?.labeler) {
     if (!props.labeler) {
-      return <ProfileHeaderLoading />
+      content = <ProfileHeaderLoading />
+    } else {
+      content = <ProfileHeaderLabeler {...props} labeler={props.labeler} />
     }
-    return <ProfileHeaderLabeler {...props} labeler={props.labeler} />
+  } else {
+    content = <ProfileHeaderStandard {...props} />
   }
-  return <ProfileHeaderStandard {...props} />
+
+  return (
+    <>
+      {isNative && (
+        <MinimalHeader
+          onLayout={evt => setMinimumHeight(evt.nativeEvent.layout.height)}
+          profile={props.profile}
+          hideBackButton={props.hideBackButton}
+        />
+      )}
+      {content}
+    </>
+  )
 }
 ProfileHeader = memo(ProfileHeader)
 export {ProfileHeader}
 
+const MinimalHeader = React.memo(function MinimalHeader({
+  onLayout,
+}: {
+  onLayout: (e: LayoutChangeEvent) => void
+  profile: AppBskyActorDefs.ProfileViewDetailed
+  hideBackButton?: boolean
+}) {
+  const t = useTheme()
+  const insets = useSafeAreaInsets()
+  const ctx = usePagerHeaderContext()
+  const [visible, setVisible] = useState(false)
+  const [minimalHeaderHeight, setMinimalHeaderHeight] = React.useState(0)
+  const isScreenFocused = useIsFocused()
+  if (!ctx) throw new Error('MinimalHeader cannot be used on web')
+  const {scrollY, headerHeight} = ctx
+
+  const animatedStyle = useAnimatedStyle(() => {
+    // if we don't yet have the min header height in JS, hide
+    if (!_WORKLET || minimalHeaderHeight === 0) {
+      return {
+        opacity: 0,
+      }
+    }
+    const pastThreshold = scrollY.get() > 100
+    return {
+      opacity: pastThreshold
+        ? withTiming(1, {duration: 75})
+        : withTiming(0, {duration: 75}),
+      transform: [
+        {
+          translateY: Math.min(
+            scrollY.get(),
+            headerHeight - minimalHeaderHeight,
+          ),
+        },
+      ],
+    }
+  })
+
+  useAnimatedReaction(
+    () => scrollY.get() > 100,
+    (value, prev) => {
+      if (prev !== value) {
+        runOnJS(setVisible)(value)
+      }
+    },
+  )
+
+  useSetLightStatusBar(isScreenFocused && !visible)
+
+  return (
+    <Animated.View
+      pointerEvents={visible ? 'auto' : 'none'}
+      aria-hidden={!visible}
+      accessibilityElementsHidden={!visible}
+      importantForAccessibility={visible ? 'auto' : 'no-hide-descendants'}
+      onLayout={evt => {
+        setMinimalHeaderHeight(evt.nativeEvent.layout.height)
+        onLayout(evt)
+      }}
+      style={[
+        a.absolute,
+        a.z_50,
+        t.atoms.bg,
+        {
+          top: 0,
+          left: 0,
+          right: 0,
+          paddingTop: insets.top,
+        },
+        animatedStyle,
+      ]}
+    />
+  )
+})
+MinimalHeader.displayName = 'MinimalHeader'
+
 const styles = StyleSheet.create({
   avi: {
     position: 'absolute',
diff --git a/src/state/shell/light-status-bar.tsx b/src/state/shell/light-status-bar.tsx
new file mode 100644
index 000000000..eb213adb9
--- /dev/null
+++ b/src/state/shell/light-status-bar.tsx
@@ -0,0 +1,45 @@
+import {createContext, useContext, useEffect, useState} from 'react'
+
+import {isWeb} from '#/platform/detection'
+import {IS_DEV} from '#/env'
+
+const LightStatusBarRefCountContext = createContext<boolean>(false)
+const SetLightStatusBarRefCountContext = createContext<React.Dispatch<
+  React.SetStateAction<number>
+> | null>(null)
+
+export function useLightStatusBar() {
+  return useContext(LightStatusBarRefCountContext)
+}
+
+export function useSetLightStatusBar(enabled: boolean) {
+  const setRefCount = useContext(SetLightStatusBarRefCountContext)
+  useEffect(() => {
+    // noop on web -sfn
+    if (isWeb) return
+
+    if (!setRefCount) {
+      if (IS_DEV)
+        console.error(
+          'useLightStatusBar was used without a SetLightStatusBarRefCountContext provider',
+        )
+      return
+    }
+    if (enabled) {
+      setRefCount(prev => prev + 1)
+      return () => setRefCount(prev => prev - 1)
+    }
+  }, [enabled, setRefCount])
+}
+
+export function Provider({children}: React.PropsWithChildren<{}>) {
+  const [refCount, setRefCount] = useState(0)
+
+  return (
+    <SetLightStatusBarRefCountContext.Provider value={setRefCount}>
+      <LightStatusBarRefCountContext.Provider value={refCount > 0}>
+        {children}
+      </LightStatusBarRefCountContext.Provider>
+    </SetLightStatusBarRefCountContext.Provider>
+  )
+}
diff --git a/src/view/com/pager/PagerHeaderContext.tsx b/src/view/com/pager/PagerHeaderContext.tsx
index fd4cc7463..c979f7a6d 100644
--- a/src/view/com/pager/PagerHeaderContext.tsx
+++ b/src/view/com/pager/PagerHeaderContext.tsx
@@ -1,40 +1,48 @@
 import React, {useContext} from 'react'
 import {SharedValue} from 'react-native-reanimated'
 
-import {isIOS} from '#/platform/detection'
+import {isNative} from '#/platform/detection'
 
-export const PagerHeaderContext =
-  React.createContext<SharedValue<number> | null>(null)
+export const PagerHeaderContext = React.createContext<{
+  scrollY: SharedValue<number>
+  headerHeight: 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.
+ * Passes information about the scroll position and header height down via
+ * context for the pager header to consume.
  *
- * @platform ios
+ * @platform ios, android
  */
 export function PagerHeaderProvider({
   scrollY,
+  headerHeight,
   children,
 }: {
   scrollY: SharedValue<number>
+  headerHeight: number
   children: React.ReactNode
 }) {
+  const value = React.useMemo(
+    () => ({scrollY, headerHeight}),
+    [scrollY, headerHeight],
+  )
   return (
-    <PagerHeaderContext.Provider value={scrollY}>
+    <PagerHeaderContext.Provider value={value}>
       {children}
     </PagerHeaderContext.Provider>
   )
 }
 
 export function usePagerHeaderContext() {
-  const scrollY = useContext(PagerHeaderContext)
-  if (isIOS) {
-    if (!scrollY) {
+  const ctx = useContext(PagerHeaderContext)
+  if (isNative) {
+    if (!ctx) {
       throw new Error(
         'usePagerHeaderContext must be used within a HeaderProvider',
       )
     }
-    return {scrollY}
+    return ctx
   } else {
     return null
   }
diff --git a/src/view/com/pager/PagerWithHeader.tsx b/src/view/com/pager/PagerWithHeader.tsx
index 617445964..dcf141f84 100644
--- a/src/view/com/pager/PagerWithHeader.tsx
+++ b/src/view/com/pager/PagerWithHeader.tsx
@@ -38,7 +38,11 @@ export interface PagerWithHeaderProps {
     | ((props: PagerWithHeaderChildParams) => JSX.Element)
   items: string[]
   isHeaderReady: boolean
-  renderHeader?: () => JSX.Element
+  renderHeader?: ({
+    setMinimumHeight,
+  }: {
+    setMinimumHeight: (height: number) => void
+  }) => JSX.Element
   initialPage?: number
   onPageSelected?: (index: number) => void
   onCurrentPageSelected?: (index: number) => void
@@ -83,7 +87,9 @@ export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>(
     const renderTabBar = React.useCallback(
       (props: RenderTabBarFnProps) => {
         return (
-          <PagerHeaderProvider scrollY={scrollY}>
+          <PagerHeaderProvider
+            scrollY={scrollY}
+            headerHeight={headerOnlyHeight}>
             <PagerTabBar
               headerOnlyHeight={headerOnlyHeight}
               items={items}
@@ -237,7 +243,11 @@ let PagerTabBar = ({
   items: string[]
   testID?: string
   scrollY: SharedValue<number>
-  renderHeader?: () => JSX.Element
+  renderHeader?: ({
+    setMinimumHeight,
+  }: {
+    setMinimumHeight: (height: number) => void
+  }) => JSX.Element
   onHeaderOnlyLayout: (height: number) => void
   onTabBarLayout: (e: LayoutChangeEvent) => void
   onCurrentPageSelected?: (index: number) => void
@@ -246,8 +256,13 @@ let PagerTabBar = ({
   dragProgress: SharedValue<number>
   dragState: SharedValue<'idle' | 'dragging' | 'settling'>
 }): React.ReactNode => {
+  const [minimumHeaderHeight, setMinimumHeaderHeight] = React.useState(0)
   const headerTransform = useAnimatedStyle(() => {
-    const translateY = Math.min(scrollY.get(), headerOnlyHeight) * -1
+    const translateY =
+      Math.min(
+        scrollY.get(),
+        Math.max(headerOnlyHeight - minimumHeaderHeight, 0),
+      ) * -1
     return {
       transform: [
         {
@@ -267,7 +282,7 @@ let PagerTabBar = ({
         ref={headerRef}
         pointerEvents={isIOS ? 'auto' : 'box-none'}
         collapsable={false}>
-        {renderHeader?.()}
+        {renderHeader?.({setMinimumHeight: setMinimumHeaderHeight})}
         {
           // It wouldn't be enough to place `onLayout` on the parent node because
           // this would risk measuring before `isHeaderReady` has turned `true`.
diff --git a/src/view/com/pager/PagerWithHeader.web.tsx b/src/view/com/pager/PagerWithHeader.web.tsx
index 3335532b3..98b32b347 100644
--- a/src/view/com/pager/PagerWithHeader.web.tsx
+++ b/src/view/com/pager/PagerWithHeader.web.tsx
@@ -21,7 +21,11 @@ export interface PagerWithHeaderProps {
     | ((props: PagerWithHeaderChildParams) => JSX.Element)
   items: string[]
   isHeaderReady: boolean
-  renderHeader?: () => JSX.Element
+  renderHeader?: ({
+    setMinimumHeight,
+  }: {
+    setMinimumHeight: () => void
+  }) => JSX.Element
   initialPage?: number
   onPageSelected?: (index: number) => void
   onCurrentPageSelected?: (index: number) => void
@@ -115,7 +119,11 @@ let PagerTabBar = ({
   currentPage: number
   items: string[]
   testID?: string
-  renderHeader?: () => JSX.Element
+  renderHeader?: ({
+    setMinimumHeight,
+  }: {
+    setMinimumHeight: () => void
+  }) => JSX.Element
   isHeaderReady: boolean
   onCurrentPageSelected?: (index: number) => void
   onSelect?: (index: number) => void
@@ -123,7 +131,7 @@ let PagerTabBar = ({
 }): React.ReactNode => {
   return (
     <>
-      <Layout.Center>{renderHeader?.()}</Layout.Center>
+      <Layout.Center>{renderHeader?.({setMinimumHeight: noop})}</Layout.Center>
       {tabBarAnchor}
       <Layout.Center
         style={web([
@@ -175,3 +183,5 @@ function toArray<T>(v: T | T[]): T[] {
   }
   return [v]
 }
+
+function noop() {}
diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx
index 782e9b9c8..ebf1d955d 100644
--- a/src/view/screens/Profile.tsx
+++ b/src/view/screens/Profile.tsx
@@ -43,6 +43,7 @@ import {ListRef} from '#/view/com/util/List'
 import {ProfileHeader, ProfileHeaderLoading} from '#/screens/Profile/Header'
 import {ProfileFeedSection} from '#/screens/Profile/Sections/Feed'
 import {ProfileLabelsSection} from '#/screens/Profile/Sections/Labels'
+import {atoms as a} from '#/alf'
 import * as Layout from '#/components/Layout'
 import {ScreenHider} from '#/components/moderation/ScreenHider'
 import {ProfileStarterPacks} from '#/components/StarterPack/ProfileStarterPacks'
@@ -56,7 +57,7 @@ interface SectionRef {
 type Props = NativeStackScreenProps<CommonNavigatorParams, 'Profile'>
 export function ProfileScreen(props: Props) {
   return (
-    <Layout.Screen testID="profileScreen">
+    <Layout.Screen testID="profileScreen" style={[a.pt_0]}>
       <ProfileScreenInner {...props} />
     </Layout.Screen>
   )
@@ -329,7 +330,11 @@ function ProfileScreenLoaded({
   // rendering
   // =
 
-  const renderHeader = () => {
+  const renderHeader = ({
+    setMinimumHeight,
+  }: {
+    setMinimumHeight: (height: number) => void
+  }) => {
     return (
       <ExpoScrollForwarderView scrollViewTag={scrollViewTag}>
         <ProfileHeader
@@ -339,6 +344,7 @@ function ProfileScreenLoaded({
           moderationOpts={moderationOpts}
           hideBackButton={hideBackButton}
           isPlaceholderProfile={showPlaceholder}
+          setMinimumHeight={setMinimumHeight}
         />
       </ExpoScrollForwarderView>
     )
diff --git a/src/view/shell/index.tsx b/src/view/shell/index.tsx
index 179e8858e..a5e97610d 100644
--- a/src/view/shell/index.tsx
+++ b/src/view/shell/index.tsx
@@ -18,6 +18,7 @@ import {
   useIsDrawerSwipeDisabled,
   useSetDrawerOpen,
 } from '#/state/shell'
+import {useLightStatusBar} from '#/state/shell/light-status-bar'
 import {useCloseAnyActiveElement} from '#/state/util'
 import {Lightbox} from '#/view/com/lightbox/Lightbox'
 import {ModalsContainer} from '#/view/com/modals/Modal'
@@ -154,6 +155,7 @@ function ShellInner() {
 
 export const Shell: React.FC = function ShellImpl() {
   const {fullyExpandedCount} = useDialogStateControlContext()
+  const lightStatusBar = useLightStatusBar()
   const t = useTheme()
   useIntentHandler()
 
@@ -165,7 +167,9 @@ export const Shell: React.FC = function ShellImpl() {
     <View testID="mobileShellView" style={[a.h_full, t.atoms.bg]}>
       <StatusBar
         style={
-          t.name !== 'light' || (isIOS && fullyExpandedCount > 0)
+          t.name !== 'light' ||
+          (isIOS && fullyExpandedCount > 0) ||
+          lightStatusBar
             ? 'light'
             : 'dark'
         }