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