From ffc63dc85fc191a51c3dc12c1afcd250f95036d5 Mon Sep 17 00:00:00 2001
From: Samuel Newman <mozzius@protonmail.com>
Date: Thu, 12 Dec 2024 17:46:19 +0000
Subject: [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
---
 src/screens/Profile/Header/index.tsx | 119 ++++++++++++++++++++++++++++++++---
 1 file changed, 112 insertions(+), 7 deletions(-)

(limited to 'src/screens/Profile/Header/index.tsx')

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',
-- 
cgit 1.4.1

