diff --git a/src/App.native.tsx b/src/App.native.tsx
index bc38eec799..c22a66e824 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() {
-
+
+
+
diff --git a/src/App.web.tsx b/src/App.web.tsx
index 808b0fc278..b7c5a56334 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() {
-
+
+
+
diff --git a/src/components/Layout/Header/index.tsx b/src/components/Layout/Header/index.tsx
index f05350dca9..16b484cea9 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}
)
diff --git a/src/screens/Profile/Header/GrowableBanner.tsx b/src/screens/Profile/Header/GrowableBanner.tsx
index 7f5a3cd6e4..3d2830439a 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 (
@@ -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}
/>
-
+
@@ -43,7 +44,8 @@ let ProfileHeaderShell = ({
const {_} = useLingui()
const {openLightbox} = useLightboxControls()
const navigation = useNavigation()
- const {isDesktop} = useWebMediaQueries()
+ const {top: topInset} = useSafeAreaInsets()
+
const aviRef = useHandleRef()
const onPressBack = React.useCallback(() => {
@@ -100,10 +102,11 @@ let ProfileHeaderShell = ({
+
- {!isDesktop && !hideBackButton && (
+ {!hideBackButton && (
-
-
+
+
)}
@@ -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 0000000000..587b410510
--- /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
+ }
+
+ return (
+
+ )
+}
+
+function StatusBarShadowInnner({scrollY}: {scrollY: SharedValue}) {
+ const {top: topInset} = useSafeAreaInsets()
+
+ const animatedStyle = useAnimatedStyle(() => {
+ return {
+ transform: [
+ {
+ translateY: Math.min(0, scrollY.get()),
+ },
+ ],
+ }
+ })
+
+ return (
+
+ )
+}
diff --git a/src/screens/Profile/Header/StatusBarShadow.web.tsx b/src/screens/Profile/Header/StatusBarShadow.web.tsx
new file mode 100644
index 0000000000..cd79871ea7
--- /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 deb8063d99..7e4b9bb313 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
+ content =
+ } else {
+ content =
}
- return
+ } else {
+ content =
}
- return
+
+ return (
+ <>
+ {isNative && (
+ 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 (
+ {
+ 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 0000000000..eb213adb93
--- /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(false)
+const SetLightStatusBarRefCountContext = createContext
+> | 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 (
+
+ 0}>
+ {children}
+
+
+ )
+}
diff --git a/src/view/com/pager/PagerHeaderContext.tsx b/src/view/com/pager/PagerHeaderContext.tsx
index fd4cc74632..c979f7a6dd 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 | null>(null)
+export const PagerHeaderContext = React.createContext<{
+ scrollY: SharedValue
+ 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
+ headerHeight: number
children: React.ReactNode
}) {
+ const value = React.useMemo(
+ () => ({scrollY, headerHeight}),
+ [scrollY, headerHeight],
+ )
return (
-
+
{children}
)
}
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 6174459647..dcf141f84d 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(
const renderTabBar = React.useCallback(
(props: RenderTabBarFnProps) => {
return (
-
+
- 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
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 3335532b3d..98b32b3476 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 (
<>
- {renderHeader?.()}
+ {renderHeader?.({setMinimumHeight: noop})}
{tabBarAnchor}
(v: T | T[]): T[] {
}
return [v]
}
+
+function noop() {}
diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx
index 782e9b9c84..ebf1d955d8 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
export function ProfileScreen(props: Props) {
return (
-
+
)
@@ -329,7 +330,11 @@ function ProfileScreenLoaded({
// rendering
// =
- const renderHeader = () => {
+ const renderHeader = ({
+ setMinimumHeight,
+ }: {
+ setMinimumHeight: (height: number) => void
+ }) => {
return (
)
diff --git a/src/view/shell/index.tsx b/src/view/shell/index.tsx
index 179e8858e0..a5e97610d0 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() {
0)
+ t.name !== 'light' ||
+ (isIOS && fullyExpandedCount > 0) ||
+ lightStatusBar
? 'light'
: 'dark'
}