diff --git a/src/Navigation.tsx b/src/Navigation.tsx
index 86295f3795..d0ef13f15b 100644
--- a/src/Navigation.tsx
+++ b/src/Navigation.tsx
@@ -28,6 +28,7 @@ import {
notificationToURL,
storePayloadForAccountSwitch,
} from '#/lib/hooks/useNotificationHandler'
+import {ScreenTransitionProvider} from '#/lib/hooks/useScreenPresence'
import {useWebScrollRestoration} from '#/lib/hooks/useWebScrollRestoration'
import {useCallOnce} from '#/lib/once'
import {buildStateObject, getCurrentRoute} from '#/lib/routes/helpers'
@@ -1052,30 +1053,32 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
})
return (
- {
- const currentScreen = getCurrentRouteName()
- // do this before metric
- setNavigationMetadata({
- previousScreen: previousScreen.current,
- currentScreen,
- })
- ax.metric('router:navigate', {from: previousScreen.current})
- previousScreen.current = currentScreen
- }}
- onReady={onNavigationReady}
- // WARNING: Implicit navigation to nested navigators is depreciated in React Navigation 7.x
- // However, there's a fair amount of places we do that, especially in when popping to the top of stacks.
- // See BottomBar.tsx for an example of how to handle nested navigators in the tabs correctly.
- // I'm scared of missing a spot (esp. with push notifications etc) so let's enable this legacy behaviour for now.
- // We will need to confirm we handle nested navigators correctly by the time we migrate to React Navigation 8.x
- // -sfn
- navigationInChildEnabled>
- {children}
-
+
+ {
+ const currentScreen = getCurrentRouteName()
+ // do this before metric
+ setNavigationMetadata({
+ previousScreen: previousScreen.current,
+ currentScreen,
+ })
+ ax.metric('router:navigate', {from: previousScreen.current})
+ previousScreen.current = currentScreen
+ }}
+ onReady={onNavigationReady}
+ // WARNING: Implicit navigation to nested navigators is depreciated in React Navigation 7.x
+ // However, there's a fair amount of places we do that, especially in when popping to the top of stacks.
+ // See BottomBar.tsx for an example of how to handle nested navigators in the tabs correctly.
+ // I'm scared of missing a spot (esp. with push notifications etc) so let's enable this legacy behaviour for now.
+ // We will need to confirm we handle nested navigators correctly by the time we migrate to React Navigation 8.x
+ // -sfn
+ navigationInChildEnabled>
+ {children}
+
+
)
}
diff --git a/src/components/Layout/index.tsx b/src/components/Layout/index.tsx
index 2564325935..8ea2e716e7 100644
--- a/src/components/Layout/index.tsx
+++ b/src/components/Layout/index.tsx
@@ -12,6 +12,7 @@ import Animated, {
} from 'react-native-reanimated'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
+import {ScreenPresenceProvider} from '#/lib/hooks/useScreenPresence'
import {useEnableMinimalShellModeForScreen} from '#/state/shell'
import {useShellLayout} from '#/state/shell/shell-layout'
import {useIsWithinSplitView} from '#/screens/Messages/components/splitView/context'
@@ -52,10 +53,9 @@ export const Screen = memo(function Screen({
const {top} = useSafeAreaInsets()
const {isWithinSplitView} = useIsWithinSplitView()
- useEnableMinimalShellModeForScreen({enabled: minimalShell})
-
return (
- <>
+
+
{IS_WEB && !isWithinSplitView && }
- >
+
)
})
+/**
+ * Rendered inside the presence provider so the shell follows this screen's
+ * transition rather than its focus state.
+ */
+function MinimalShell({enabled}: {enabled: boolean}) {
+ useEnableMinimalShellModeForScreen({enabled})
+ return null
+}
+
export type ContentProps = AnimatedScrollViewProps & {
style?: StyleProp
contentContainerStyle?: StyleProp
diff --git a/src/lib/hooks/useContributionRegistry.ts b/src/lib/hooks/useContributionRegistry.ts
new file mode 100644
index 0000000000..a52cf746ae
--- /dev/null
+++ b/src/lib/hooks/useContributionRegistry.ts
@@ -0,0 +1,71 @@
+import {useCallback, useState} from 'react'
+import {
+ clamp,
+ Reanimated3DefaultSpringConfig,
+ type SharedValue,
+ useDerivedValue,
+ withSpring,
+} from 'react-native-reanimated'
+import {scheduleOnRN} from 'react-native-worklets'
+
+type Contribution = {
+ id: number
+ value: SharedValue
+}
+
+let nextId = 0
+
+/**
+ * Sums any number of 0..1 shared values (plus an optional base value) into a
+ * single derived value clamped to 0..1, entirely on the UI thread.
+ *
+ * This replaces boolean refcounting for shell state: each contributor owns a
+ * shared value it can drive however it likes (a spring, a scroll gesture, a
+ * screen transition), and the sum is what the shell renders. Contributions
+ * are summed rather than maxed so that two overlapping contributors (e.g. one
+ * screen transitioning out while another transitions in) hold the total at 1
+ * instead of dipping in the middle.
+ */
+export function useContributionRegistry(base?: SharedValue) {
+ const [contributions, setContributions] = useState([])
+
+ const total = useDerivedValue(() => {
+ let sum = base ? base.get() : 0
+ for (const contribution of contributions) {
+ sum += contribution.value.get()
+ }
+ return clamp(sum, 0, 1)
+ }, [contributions, base])
+
+ const remove = useCallback((id: number) => {
+ setContributions(prev => prev.filter(c => c.id !== id))
+ }, [])
+
+ /**
+ * Adds a contribution and returns a function to remove it. Removal animates
+ * the value to 0 first so that a contributor unmounting while still active
+ * (e.g. a screen removed without a transition) does not snap the total.
+ */
+ const register = useCallback(
+ (value: SharedValue) => {
+ const id = nextId++
+ setContributions(prev => [...prev, {id, value}])
+ return () => {
+ value.set(
+ withSpring(
+ 0,
+ {...Reanimated3DefaultSpringConfig, overshootClamping: true},
+ finished => {
+ if (finished) {
+ scheduleOnRN(remove, id)
+ }
+ },
+ ),
+ )
+ }
+ },
+ [remove],
+ )
+
+ return {total, register}
+}
diff --git a/src/lib/hooks/useHideBottomBarBorder.tsx b/src/lib/hooks/useHideBottomBarBorder.tsx
index 6cbe9ad3df..00559bd0db 100644
--- a/src/lib/hooks/useHideBottomBarBorder.tsx
+++ b/src/lib/hooks/useHideBottomBarBorder.tsx
@@ -1,51 +1,95 @@
-import {createContext, useCallback, useContext, useState} from 'react'
-import {useFocusEffect} from '@react-navigation/native'
+import {createContext, useContext, useEffect} from 'react'
+import {
+ type DerivedValue,
+ interpolateColor,
+ type SharedValue,
+ useAnimatedReaction,
+ useAnimatedStyle,
+ useSharedValue,
+} from 'react-native-reanimated'
-type HideBottomBarBorderSetter = () => () => void
+import {useContributionRegistry} from '#/lib/hooks/useContributionRegistry'
+import {useScreenPresence} from '#/lib/hooks/useScreenPresence'
+import {useTheme} from '#/alf'
-const HideBottomBarBorderContext = createContext(false)
+type Register = (contribution: SharedValue) => () => void
+
+const HideBottomBarBorderContext = createContext | null>(
+ null,
+)
HideBottomBarBorderContext.displayName = 'HideBottomBarBorderContext'
-const HideBottomBarBorderSetterContext =
- createContext(null)
+const HideBottomBarBorderSetterContext = createContext(null)
HideBottomBarBorderSetterContext.displayName =
'HideBottomBarBorderSetterContext'
-export function useHideBottomBarBorderSetter() {
- const hideBottomBarBorder = useContext(HideBottomBarBorderSetterContext)
- if (!hideBottomBarBorder) {
+function useHideBottomBarBorderSetter() {
+ const register = useContext(HideBottomBarBorderSetterContext)
+ if (!register) {
throw new Error(
'useHideBottomBarBorderSetter must be used within a HideBottomBarBorderProvider',
)
}
- return hideBottomBarBorder
+ return register
}
+/**
+ * Hides the bottom bar's top border while the surrounding screen is present,
+ * fading it with the screen transition.
+ */
export function useHideBottomBarBorderForScreen() {
- const hideBorder = useHideBottomBarBorderSetter()
+ const register = useHideBottomBarBorderSetter()
+ const {presence} = useScreenPresence()
+ const contribution = useSharedValue(0)
- useFocusEffect(
- useCallback(() => {
- const cleanup = hideBorder()
- return () => cleanup()
- }, [hideBorder]),
+ useAnimatedReaction(
+ () => presence.get(),
+ (current, previous) => {
+ if (current !== previous) {
+ contribution.set(current)
+ }
+ },
)
+
+ useEffect(() => register(contribution), [register, contribution])
}
+/**
+ * How hidden the bottom bar border is, 0 (visible) to 1 (hidden).
+ */
export function useHideBottomBarBorder() {
- return useContext(HideBottomBarBorderContext)
+ const value = useContext(HideBottomBarBorderContext)
+ if (!value) {
+ throw new Error(
+ 'useHideBottomBarBorder must be used within a HideBottomBarBorderProvider',
+ )
+ }
+ return value
+}
+
+/**
+ * Animated border color for the bottom bar, blending the border into the
+ * background as screens that hide it come and go.
+ */
+export function useBottomBarBorderStyle() {
+ const t = useTheme()
+ const hideBorder = useHideBottomBarBorder()
+ const visibleColor = t.atoms.border_contrast_low.borderColor
+ const hiddenColor = t.atoms.bg.backgroundColor
+ return useAnimatedStyle(() => ({
+ borderColor: interpolateColor(
+ hideBorder.get(),
+ [0, 1],
+ [visibleColor, hiddenColor],
+ ),
+ }))
}
export function Provider({children}: {children: React.ReactNode}) {
- const [refCount, setRefCount] = useState(0)
-
- const setter = useCallback(() => {
- setRefCount(prev => prev + 1)
- return () => setRefCount(prev => prev - 1)
- }, [])
+ const {total, register} = useContributionRegistry()
return (
-
- 0}>
+
+
{children}
diff --git a/src/lib/hooks/useScreenPresence/context.tsx b/src/lib/hooks/useScreenPresence/context.tsx
new file mode 100644
index 0000000000..a7368bc074
--- /dev/null
+++ b/src/lib/hooks/useScreenPresence/context.tsx
@@ -0,0 +1,30 @@
+import {createContext, useContext} from 'react'
+import {type SharedValue, useSharedValue} from 'react-native-reanimated'
+
+export type ScreenPresence = {
+ /**
+ * How visible this screen is on screen, 0..1, tracking the native stack
+ * transition frame by frame (including interactive swipe-back). Always 1 on
+ * web and for screens that are not inside a native stack.
+ */
+ visibility: SharedValue
+ /**
+ * `visibility`, but also 0 when the screen's tab (or any other ancestor
+ * navigator) is not focused. This is the value shell UI should follow: it is
+ * 1 exactly when the screen is what the user is looking at.
+ */
+ presence: SharedValue
+}
+
+export const ScreenPresenceContext = createContext(null)
+ScreenPresenceContext.displayName = 'ScreenPresenceContext'
+
+/**
+ * Reads the presence of the nearest `Layout.Screen`. Outside of one, both
+ * values are a constant 1.
+ */
+export function useScreenPresence(): ScreenPresence {
+ const context = useContext(ScreenPresenceContext)
+ const fallback = useSharedValue(1)
+ return context ?? {visibility: fallback, presence: fallback}
+}
diff --git a/src/lib/hooks/useScreenPresence/index.tsx b/src/lib/hooks/useScreenPresence/index.tsx
new file mode 100644
index 0000000000..fc4b125d6b
--- /dev/null
+++ b/src/lib/hooks/useScreenPresence/index.tsx
@@ -0,0 +1,151 @@
+import {useContext, useEffect} from 'react'
+import {
+ Reanimated3DefaultSpringConfig,
+ type SharedValue,
+ useDerivedValue,
+ useSharedValue,
+ withSpring,
+} from 'react-native-reanimated'
+import {
+ ReanimatedScreenProvider,
+ useReanimatedTransitionProgress,
+} from 'react-native-screens/reanimated'
+import {
+ NavigationContext,
+ type NavigationProp,
+ type ParamListBase,
+} from '@react-navigation/native'
+
+import {ScreenPresenceContext} from './context'
+
+export {type ScreenPresence, useScreenPresence} from './context'
+
+/**
+ * Swaps react-native-screens' screen implementation for one that pipes the
+ * native transition progress event into Reanimated shared values on the UI
+ * thread. Must wrap the navigation tree.
+ */
+export function ScreenTransitionProvider({
+ children,
+}: {
+ children: React.ReactNode
+}) {
+ return {children}
+}
+
+/**
+ * Provides `useScreenPresence()` to a screen's subtree. Rendered by
+ * `Layout.Screen`.
+ */
+export function ScreenPresenceProvider({
+ children,
+}: {
+ children: React.ReactNode
+}) {
+ const navigation = useContext(NavigationContext)
+ /*
+ * The transition progress context only exists for routes rendered by a
+ * native stack navigator, and the hook that reads it throws otherwise. Every
+ * stack navigator in the app is a native stack, so the navigator type tells
+ * us whether it is safe to read.
+ */
+ const isNativeStackRoute = navigation?.getState().type === 'stack'
+
+ if (isNativeStackRoute) {
+ return (
+
+ {children}
+
+ )
+ }
+ return {children}
+}
+
+function NativeStackScreenPresenceProvider({
+ navigation,
+ children,
+}: {
+ navigation: NavigationProp
+ children: React.ReactNode
+}) {
+ /*
+ * react-native-screens types these with `Animated.SharedValue`, a Reanimated
+ * 3 alias that no longer exists in Reanimated 4, so the values come through
+ * untyped.
+ */
+ const {progress, closing} = useReanimatedTransitionProgress() as {
+ progress: SharedValue
+ closing: SharedValue
+ }
+ const ancestorsFocused = useAncestorsFocused(navigation)
+
+ const visibility = useDerivedValue(() => {
+ const value = progress.get()
+ return closing.get() ? 1 - value : value
+ })
+ const presence = useDerivedValue(() =>
+ Math.min(visibility.get(), ancestorsFocused.get()),
+ )
+
+ return (
+
+ {children}
+
+ )
+}
+
+function StaticScreenPresenceProvider({children}: {children: React.ReactNode}) {
+ const one = useSharedValue(1)
+ return (
+
+ {children}
+
+ )
+}
+
+/**
+ * Whether every navigator above this screen has it in focus, as a 0/1 shared
+ * value that springs between the two. Stack transitions are covered by the
+ * transition progress, so this only needs to react to changes that do not
+ * animate a native stack, i.e. switching tabs.
+ */
+function useAncestorsFocused(navigation: NavigationProp) {
+ const focused = useSharedValue(areAncestorsFocused(navigation) ? 1 : 0)
+
+ useEffect(() => {
+ const ancestors = getAncestors(navigation)
+ if (ancestors.length === 0) return
+ const update = () => {
+ focused.set(
+ withSpring(areAncestorsFocused(navigation) ? 1 : 0, {
+ ...Reanimated3DefaultSpringConfig,
+ overshootClamping: true,
+ }),
+ )
+ }
+ update()
+ const unsubscribes = ancestors.flatMap(ancestor => [
+ ancestor.addListener('focus', update),
+ ancestor.addListener('blur', update),
+ ])
+ return () => unsubscribes.forEach(unsubscribe => unsubscribe())
+ }, [navigation, focused])
+
+ return focused
+}
+
+function getAncestors(navigation: NavigationProp) {
+ const ancestors: NavigationProp[] = []
+ let parent = navigation.getParent()
+ while (parent) {
+ ancestors.push(parent)
+ parent = parent.getParent()
+ }
+ return ancestors
+}
+
+function areAncestorsFocused(navigation: NavigationProp) {
+ // isFocused() already walks up the tree, so the nearest ancestor is enough
+ const parent = navigation.getParent()
+ return parent ? parent.isFocused() : true
+}
diff --git a/src/lib/hooks/useScreenPresence/index.web.tsx b/src/lib/hooks/useScreenPresence/index.web.tsx
new file mode 100644
index 0000000000..eac97dc4a7
--- /dev/null
+++ b/src/lib/hooks/useScreenPresence/index.web.tsx
@@ -0,0 +1,53 @@
+import {useCallback} from 'react'
+import {
+ Reanimated3DefaultSpringConfig,
+ useSharedValue,
+ withSpring,
+} from 'react-native-reanimated'
+import {useFocusEffect} from '@react-navigation/native'
+
+import {ScreenPresenceContext} from './context'
+
+export {type ScreenPresence, useScreenPresence} from './context'
+
+/**
+ * No-op on web: there are no native stack transitions to track.
+ */
+export function ScreenTransitionProvider({
+ children,
+}: {
+ children: React.ReactNode
+}) {
+ return children
+}
+
+/**
+ * On web, screens do not animate in or out, so presence is simply focus, with
+ * a spring so that dependent shell UI still animates.
+ */
+export function ScreenPresenceProvider({
+ children,
+}: {
+ children: React.ReactNode
+}) {
+ const visibility = useSharedValue(1)
+ const presence = useSharedValue(0)
+
+ useFocusEffect(
+ useCallback(() => {
+ const spring = (to: number) =>
+ withSpring(to, {
+ ...Reanimated3DefaultSpringConfig,
+ overshootClamping: true,
+ })
+ presence.set(spring(1))
+ return () => presence.set(spring(0))
+ }, [presence]),
+ )
+
+ return (
+
+ {children}
+
+ )
+}
diff --git a/src/state/shell/minimal-mode.tsx b/src/state/shell/minimal-mode.tsx
index a592b47767..286c443228 100644
--- a/src/state/shell/minimal-mode.tsx
+++ b/src/state/shell/minimal-mode.tsx
@@ -1,25 +1,29 @@
+import {createContext, useContext, useEffect, useMemo} from 'react'
import {
- createContext,
- useCallback,
- useContext,
- useEffect,
- useMemo,
- useRef,
-} from 'react'
-import {
+ type DerivedValue,
Reanimated3DefaultSpringConfig,
type SharedValue,
+ useAnimatedReaction,
useSharedValue,
withSpring,
} from 'react-native-reanimated'
-import {useFocusEffect} from '@react-navigation/native'
+
+import {useContributionRegistry} from '#/lib/hooks/useContributionRegistry'
+import {useScreenPresence} from '#/lib/hooks/useScreenPresence'
type StateContext = {
- footerMode: SharedValue
+ /**
+ * How hidden the bottom bar is, 0 (fully visible) to 1 (fully hidden). The
+ * sum of `scrollMode` and every active screen contribution, clamped.
+ */
+ footerMode: DerivedValue
+ /**
+ * The scroll-linked part of `footerMode`, driven by `MainScrollProvider`.
+ */
+ scrollMode: SharedValue
}
type SetContext = {
- add: () => void
- subtract: () => void
+ register: (contribution: SharedValue) => () => void
}
const stateContext = createContext(null)
@@ -28,50 +32,13 @@ const setContext = createContext(null)
setContext.displayName = 'MinimalModeSetContext'
export function Provider({children}: React.PropsWithChildren<{}>) {
- const footerMode = useSharedValue(0)
-
- const setModeWorklet = useCallback(
- (v: boolean) => {
- 'worklet'
- footerMode.set(
- withSpring(v ? 1 : 0, {
- ...Reanimated3DefaultSpringConfig,
- overshootClamping: true,
- }),
- )
- },
- [footerMode],
- )
-
- // defaults to "visible", if the count is >0 it gets hidden
- const countRef = useRef(0)
- const add = useCallback(() => {
- // 0 -> 1 = hide
- if (countRef.current === 0) setModeWorklet(true)
-
- countRef.current += 1
- }, [setModeWorklet])
- const subtract = useCallback(() => {
- // 1 -> 0 = show
- if (countRef.current === 1) setModeWorklet(false)
-
- // count must never go below 0
- if (countRef.current > 0) countRef.current -= 1
- }, [setModeWorklet])
-
- const setters = useMemo(
- () => ({
- add,
- subtract,
- }),
- [add, subtract],
- )
+ const scrollMode = useSharedValue(0)
+ const {total: footerMode, register} = useContributionRegistry(scrollMode)
+ const setters = useMemo(() => ({register}), [register])
const value = useMemo(
- () => ({
- footerMode,
- }),
- [footerMode],
+ () => ({footerMode, scrollMode}),
+ [footerMode, scrollMode],
)
return (
@@ -89,6 +56,14 @@ export function useMinimalShellMode() {
return context
}
+/**
+ * The scroll-linked part of the bottom bar's hidden state, for scroll
+ * providers to drive directly. Reset to 0 to reveal the bar.
+ */
+export function useMinimalShellScrollMode() {
+ return useMinimalShellMode().scrollMode
+}
+
export function useMinimalShellModeSetters() {
const context = useContext(setContext)
if (!context)
@@ -98,26 +73,49 @@ export function useMinimalShellModeSetters() {
return context
}
+/**
+ * Hides the bottom bar for as long as the calling component is mounted and
+ * `enabled`, independent of navigation. Prefer `Layout.Screen`'s
+ * `minimalShell` prop for screens, which tracks the screen transition.
+ */
export function useEnableMinimalShellMode({enabled} = {enabled: true}) {
- const setters = useMinimalShellModeSetters()
+ const {register} = useMinimalShellModeSetters()
+ const contribution = useSharedValue(0)
useEffect(() => {
- if (enabled) {
- setters.add()
- return () => setters.subtract()
- }
- }, [enabled, setters])
+ if (!enabled) return
+ const unregister = register(contribution)
+ contribution.set(
+ withSpring(1, {
+ ...Reanimated3DefaultSpringConfig,
+ overshootClamping: true,
+ }),
+ )
+ return unregister
+ }, [enabled, register, contribution])
}
+/**
+ * Hides the bottom bar while the surrounding screen is present, following its
+ * transition in and out frame by frame. Used by `Layout.Screen`.
+ */
export function useEnableMinimalShellModeForScreen(
{enabled} = {enabled: true},
) {
- const setters = useMinimalShellModeSetters()
- useFocusEffect(
- useCallback(() => {
- if (enabled) {
- setters.add()
- return () => setters.subtract()
+ const {register} = useMinimalShellModeSetters()
+ const {presence} = useScreenPresence()
+ const contribution = useSharedValue(0)
+
+ useAnimatedReaction(
+ () => presence.get(),
+ (current, previous) => {
+ if (current !== previous) {
+ contribution.set(current)
}
- }, [enabled, setters]),
+ },
)
+
+ useEffect(() => {
+ if (!enabled) return
+ return register(contribution)
+ }, [enabled, register, contribution])
}
diff --git a/src/view/com/util/MainScrollProvider.tsx b/src/view/com/util/MainScrollProvider.tsx
index c35df1c8c7..facc170c29 100644
--- a/src/view/com/util/MainScrollProvider.tsx
+++ b/src/view/com/util/MainScrollProvider.tsx
@@ -13,6 +13,7 @@ import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {EventEmitter} from 'eventemitter3'
import {ScrollProvider} from '#/lib/ScrollContext'
+import {useMinimalShellScrollMode} from '#/state/shell/minimal-mode'
import {useShellLayout} from '#/state/shell/shell-layout'
import {IS_LIQUID_GLASS, IS_NATIVE, IS_WEB} from '#/env'
@@ -95,6 +96,7 @@ export function useHomeHeaderTransform() {
export function MainScrollProvider({children}: {children: React.ReactNode}) {
const {headerHeight} = useShellLayout()
const headerMode = useHomeHeaderMode()
+ const footerScrollMode = useMinimalShellScrollMode()
const {top: topInset} = useSafeAreaInsets()
const headerPinnedHeight = IS_LIQUID_GLASS ? topInset : 0
const startDragOffset = useSharedValue(null)
@@ -104,14 +106,14 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
const setMode = useCallback(
(v: boolean) => {
'worklet'
- headerMode.set(
- withSpring(v ? 1 : 0, {
- ...Reanimated3DefaultSpringConfig,
- overshootClamping: true,
- }),
- )
+ const target = withSpring(v ? 1 : 0, {
+ ...Reanimated3DefaultSpringConfig,
+ overshootClamping: true,
+ })
+ headerMode.set(target)
+ footerScrollMode.set(target)
},
- [headerMode],
+ [headerMode, footerScrollMode],
)
useEffect(() => {
@@ -216,6 +218,7 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
if (newValue !== headerMode.get()) {
// Manually adjust the value. This won't be (and shouldn't be) animated.
headerMode.set(newValue)
+ footerScrollMode.set(newValue)
}
} else {
if (didJustRestoreScroll.get()) {
@@ -239,6 +242,7 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
headerHeight,
headerPinnedHeight,
headerMode,
+ footerScrollMode,
setMode,
startDragOffset,
startMode,
diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx
index 5111165e6c..cdf4b3d16d 100644
--- a/src/view/screens/Home.tsx
+++ b/src/view/screens/Home.tsx
@@ -1,5 +1,5 @@
import {useCallback, useEffect, useLayoutEffect, useMemo, useRef} from 'react'
-import {ActivityIndicator, StyleSheet} from 'react-native'
+import {ActivityIndicator, AppState, StyleSheet} from 'react-native'
import {
Reanimated3DefaultSpringConfig,
withSpring,
@@ -31,6 +31,7 @@ import {usePreferencesQuery} from '#/state/queries/preferences'
import {type UsePreferencesQueryResponse} from '#/state/queries/preferences/types'
import {useSession} from '#/state/session'
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
+import {useMinimalShellScrollMode} from '#/state/shell/minimal-mode'
import {useSelectedFeed, useSetSelectedFeed} from '#/state/shell/selected-feed'
import {FeedPage} from '#/view/com/feeds/FeedPage'
import {HomeHeader} from '#/view/com/home/HomeHeader'
@@ -156,20 +157,33 @@ function HomeScreenReady({
const {hasSession} = useSession()
const headerMode = useHomeHeaderMode()
- const showHeader = useCallback(() => {
+ const footerScrollMode = useMinimalShellScrollMode()
+ const showShell = useCallback(() => {
'worklet'
- headerMode.set(
- withSpring(0, {
- ...Reanimated3DefaultSpringConfig,
- overshootClamping: true,
- }),
- )
- }, [headerMode])
+ const shown = withSpring(0, {
+ ...Reanimated3DefaultSpringConfig,
+ overshootClamping: true,
+ })
+ headerMode.set(shown)
+ footerScrollMode.set(shown)
+ }, [headerMode, footerScrollMode])
useFocusEffect(
useCallback(() => {
- return () => showHeader()
- }, [showHeader]),
+ return () => showShell()
+ }, [showShell]),
+ )
+
+ useFocusEffect(
+ useCallback(() => {
+ // Reveal the bars on foreground so you don't miss notifications or messages.
+ const listener = AppState.addEventListener('change', nextAppState => {
+ if (nextAppState === 'active') {
+ showShell()
+ }
+ })
+ return () => listener.remove()
+ }, [showShell]),
)
useFocusEffect(
@@ -187,7 +201,7 @@ function HomeScreenReady({
const onPageSelected = useCallback(
(index: number) => {
- showHeader()
+ showShell()
const maybeFeed = allFeeds[index]
// Mutate the ref before setting state to avoid the imperative syncing effect
@@ -203,7 +217,7 @@ function HomeScreenReady({
})
}
},
- [ax, setSelectedFeed, showHeader, allFeeds],
+ [ax, setSelectedFeed, showShell, allFeeds],
)
const onPressSelected = useCallback(() => {
@@ -214,10 +228,10 @@ function HomeScreenReady({
(state: 'idle' | 'dragging' | 'settling') => {
'worklet'
if (state === 'dragging') {
- showHeader()
+ showShell()
}
},
- [showHeader],
+ [showShell],
)
const [demoMode] = useDemoMode()
diff --git a/src/view/shell/bottom-bar/BottomBar.tsx b/src/view/shell/bottom-bar/BottomBar.tsx
index c33d2b7b8e..336b76b7d3 100644
--- a/src/view/shell/bottom-bar/BottomBar.tsx
+++ b/src/view/shell/bottom-bar/BottomBar.tsx
@@ -11,7 +11,7 @@ import {PressableScale} from '#/lib/custom-animations/PressableScale'
import {BOTTOM_BAR_AVI} from '#/lib/demo'
import {useHaptics} from '#/lib/haptics'
import {useDedupe} from '#/lib/hooks/useDedupe'
-import {useHideBottomBarBorder} from '#/lib/hooks/useHideBottomBarBorder'
+import {useBottomBarBorderStyle} from '#/lib/hooks/useHideBottomBarBorder'
import {useMinimalShellFooterTransform} from '#/lib/hooks/useMinimalShellTransform'
import {useNavigationTabState} from '#/lib/hooks/useNavigationTabState'
import {clamp} from '#/lib/numbers'
@@ -80,7 +80,7 @@ export function BottomBar({navigation}: BottomTabBarProps) {
const accountSwitchControl = useDialogControl()
const messagesMenuControl = Menu.useMenuControl()
const playHaptic = useHaptics()
- const hideBorder = useHideBottomBarBorder()
+ const borderStyle = useBottomBarBorderStyle()
const iconWidth = 28
const showSignIn = useCallback(() => {
@@ -165,9 +165,7 @@ export function BottomBar({navigation}: BottomTabBarProps) {
style={[
styles.bottomBar,
t.atoms.bg,
- hideBorder
- ? {borderColor: t.atoms.bg.backgroundColor}
- : t.atoms.border_contrast_low,
+ borderStyle,
{paddingBottom: clamp(safeAreaInsets.bottom, 15, 60)},
footerMinimalShellTransform,
]}
diff --git a/src/view/shell/bottom-bar/BottomBarWeb.tsx b/src/view/shell/bottom-bar/BottomBarWeb.tsx
index 3cb1984fca..9f724688cd 100644
--- a/src/view/shell/bottom-bar/BottomBarWeb.tsx
+++ b/src/view/shell/bottom-bar/BottomBarWeb.tsx
@@ -6,7 +6,7 @@ import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {useNavigationState} from '@react-navigation/native'
-import {useHideBottomBarBorder} from '#/lib/hooks/useHideBottomBarBorder'
+import {useBottomBarBorderStyle} from '#/lib/hooks/useHideBottomBarBorder'
import {useMinimalShellFooterTransform} from '#/lib/hooks/useMinimalShellTransform'
import {getCurrentRoute, isTab} from '#/lib/routes/helpers'
import {makeProfileLink} from '#/lib/routes/links'
@@ -57,7 +57,7 @@ export function BottomBarWeb() {
const {requestSwitchToAccount} = useLoggedOutViewControls()
const closeAllActiveElements = useCloseAllActiveElements()
const {footerHeight} = useShellLayout()
- const hideBorder = useHideBottomBarBorder()
+ const borderStyle = useBottomBarBorderStyle()
const accountSwitchControl = useDialogControl()
const {data: profile} = useProfileQuery({did: currentAccount?.did})
const iconWidth = 26
@@ -92,9 +92,7 @@ export function BottomBarWeb() {
styles.bottomBar,
styles.bottomBarWeb,
t.atoms.bg,
- hideBorder
- ? {borderColor: t.atoms.bg.backgroundColor}
- : t.atoms.border_contrast_low,
+ borderStyle,
footerMinimalShellTransform,
]}
onLayout={event => footerHeight.set(event.nativeEvent.layout.height)}>