From 59311e6b2c648f27ba767df2e49a15a292cf61d7 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 13 Mar 2026 22:47:17 -0500 Subject: [PATCH] Restore mobile bottom nav with scroll-to-hide behavior Bring back the mobile bottom navigation bar with Home, Notifications, and Search buttons. The nav slides out of view when scrolling down and reappears on upward scroll, keeping the viewport uncluttered while reading. Search retains the popover account search sheet for fast profile lookup without navigating away. Updated CSS height calculations for full-screen pages (AI chat, vines, livestream) and FAB positioning to account for the nav. --- src/components/MainLayout.tsx | 4 +- src/components/MinimizedAudioBar.tsx | 4 +- src/components/MobileBottomNav.tsx | 112 +++++++++++++++++++++++++ src/components/MobileSearchSheet.tsx | 7 +- src/hooks/useHasUnreadNotifications.ts | 2 +- src/hooks/useScrollDirection.ts | 56 +++++++++++++ src/index.css | 23 +++-- 7 files changed, 191 insertions(+), 17 deletions(-) create mode 100644 src/components/MobileBottomNav.tsx create mode 100644 src/hooks/useScrollDirection.ts diff --git a/src/components/MainLayout.tsx b/src/components/MainLayout.tsx index f4cb8580..a81b37f3 100644 --- a/src/components/MainLayout.tsx +++ b/src/components/MainLayout.tsx @@ -4,6 +4,7 @@ import { LeftSidebar } from '@/components/LeftSidebar'; import { RightSidebar } from '@/components/RightSidebar'; import { MobileTopBar } from '@/components/MobileTopBar'; import { MobileDrawer } from '@/components/MobileDrawer'; +import { MobileBottomNav } from '@/components/MobileBottomNav'; import { FloatingComposeButton } from '@/components/FloatingComposeButton'; import { CursorFireEffect } from '@/components/CursorFireEffect'; import { Skeleton } from '@/components/ui/skeleton'; @@ -101,7 +102,8 @@ function MainLayoutInner() { - + {/* Mobile bottom nav - only on small screens, slides out on scroll */} + ); } diff --git a/src/components/MinimizedAudioBar.tsx b/src/components/MinimizedAudioBar.tsx index 724bee72..e5c25549 100644 --- a/src/components/MinimizedAudioBar.tsx +++ b/src/components/MinimizedAudioBar.tsx @@ -16,8 +16,8 @@ function getStoredPosition(): { x: number; y: number } | null { } function getBottomOffset() { - // Reserve space for mobile bottom nav (56px) below the sidebar breakpoint - const hasSidebar = window.matchMedia('(min-width: 600px)').matches; + // On mobile (below sidebar breakpoint), reserve space for the bottom nav (56px) + const hasSidebar = window.matchMedia('(min-width: 900px)').matches; return hasSidebar ? 0 : 56; } diff --git a/src/components/MobileBottomNav.tsx b/src/components/MobileBottomNav.tsx new file mode 100644 index 00000000..ea133557 --- /dev/null +++ b/src/components/MobileBottomNav.tsx @@ -0,0 +1,112 @@ +import { useCallback, useMemo, useState } from 'react'; +import { Link, useLocation } from 'react-router-dom'; +import { Bell, Search } from 'lucide-react'; +import { PlanetIcon } from '@/components/icons/PlanetIcon'; +import { cn } from '@/lib/utils'; +import { useHasUnreadNotifications } from '@/hooks/useHasUnreadNotifications'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useAppContext } from '@/hooks/useAppContext'; +import { useScrollDirection } from '@/hooks/useScrollDirection'; +import { MobileSearchSheet } from '@/components/MobileSearchSheet'; +import { getSidebarItem } from '@/lib/sidebarItems'; + +export function MobileBottomNav() { + const location = useLocation(); + const { user } = useCurrentUser(); + const { config } = useAppContext(); + const homePage = config.homePage; + const hasUnread = useHasUnreadNotifications(); + const { hidden } = useScrollDirection(); + + const [searchOpen, setSearchOpen] = useState(false); + + const homeItem = useMemo(() => getSidebarItem(homePage), [homePage]); + const HomeIcon = homeItem?.icon ?? PlanetIcon; + const homeLabel = homeItem?.label ?? 'Feed'; + + const handleHomeClick = useCallback((e: React.MouseEvent) => { + setSearchOpen(false); + if (location.pathname === '/') { + e.preventDefault(); + window.scrollTo({ top: 0, behavior: 'smooth' }); + } + }, [location.pathname]); + + const handleNotificationsClick = useCallback(() => { + setSearchOpen(false); + }, []); + + const handleSearchClick = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + setSearchOpen((v) => !v); + }, []); + + // Don't show notifications/search in bottom nav if they are the homepage + const showNotifications = homePage !== 'notifications'; + const showSearch = homePage !== 'search'; + + // Keep the nav visible while search is open regardless of scroll + const isHidden = hidden && !searchOpen; + + return ( + <> + setSearchOpen(false)} /> + + + + ); +} diff --git a/src/components/MobileSearchSheet.tsx b/src/components/MobileSearchSheet.tsx index 7b8e024d..7df1dca8 100644 --- a/src/components/MobileSearchSheet.tsx +++ b/src/components/MobileSearchSheet.tsx @@ -122,16 +122,15 @@ export function MobileSearchSheet({ open, onClose }: MobileSearchSheetProps) { return ( <> - {/* Backdrop — doesn't cover the bottom nav (z-30) */} + {/* Backdrop */}
{/* Bottom sheet — sits above the bottom nav bar */} -
+
+ {/* Results list — reversed so closest to input = most relevant */} {hasResults && (
diff --git a/src/hooks/useHasUnreadNotifications.ts b/src/hooks/useHasUnreadNotifications.ts index 82ba2ec6..c652f5f6 100644 --- a/src/hooks/useHasUnreadNotifications.ts +++ b/src/hooks/useHasUnreadNotifications.ts @@ -10,7 +10,7 @@ import { useEncryptedSettings } from './useEncryptedSettings'; * Fetches at most 1 event (using `since` to filter at the relay level), * making it much cheaper than loading the full notification list. * - * Use this in navigation components (sidebar, bottom nav) for the dot indicator. + * Use this in navigation components (sidebar, mobile bottom nav) for the dot indicator. * Use `useNotifications` on the actual notifications page where the full list is needed. */ export function useHasUnreadNotifications(): boolean { diff --git a/src/hooks/useScrollDirection.ts b/src/hooks/useScrollDirection.ts new file mode 100644 index 00000000..446d2090 --- /dev/null +++ b/src/hooks/useScrollDirection.ts @@ -0,0 +1,56 @@ +import { useEffect, useRef, useState } from 'react'; + +/** + * Tracks the user's scroll direction and returns whether the mobile chrome + * (bottom nav) should be hidden. + * + * Rules: + * - Hidden when the user scrolls DOWN past a small threshold. + * - Revealed when the user scrolls UP by the same threshold. + * - Always revealed when the page is scrolled near the very top. + */ +export function useScrollDirection(): { hidden: boolean } { + const [hidden, setHidden] = useState(false); + const lastScrollY = useRef(0); + const accumulated = useRef(0); + + useEffect(() => { + const THRESHOLD = 8; // px of continuous movement before toggling + const NEAR_TOP = 60; // px from top where chrome is always visible + + const onScroll = () => { + const currentY = window.scrollY; + + if (currentY <= NEAR_TOP) { + // Always show chrome near the top of the page + accumulated.current = 0; + setHidden(false); + lastScrollY.current = currentY; + return; + } + + const delta = currentY - lastScrollY.current; + lastScrollY.current = currentY; + + if (Math.sign(delta) !== Math.sign(accumulated.current)) { + // Direction changed — reset accumulator + accumulated.current = delta; + } else { + accumulated.current += delta; + } + + if (accumulated.current > THRESHOLD) { + setHidden(true); + accumulated.current = 0; + } else if (accumulated.current < -THRESHOLD) { + setHidden(false); + accumulated.current = 0; + } + }; + + window.addEventListener('scroll', onScroll, { passive: true }); + return () => window.removeEventListener('scroll', onScroll); + }, []); + + return { hidden }; +} diff --git a/src/index.css b/src/index.css index 3e5076d4..30655575 100644 --- a/src/index.css +++ b/src/index.css @@ -40,9 +40,14 @@ bottom: env(safe-area-inset-bottom, 0px); } - /* FAB bottom offset: clears safe area inset */ + /* FAB bottom offset: clears bottom nav + safe area inset on mobile */ .bottom-fab { - bottom: calc(1.5rem + env(safe-area-inset-bottom, 0px)); + bottom: calc(1.5rem + 3.5rem + env(safe-area-inset-bottom, 0px)); + } + + /* Position above mobile bottom nav (h-14 = 3.5rem) + safe area */ + .bottom-mobile-nav { + bottom: calc(3.5rem + env(safe-area-inset-bottom, 0px)); } /* Mobile top bar height (48px) + safe area inset for sticky elements */ @@ -50,20 +55,20 @@ top: calc(3rem + env(safe-area-inset-top, 0px)); } - /* AI chat height on mobile: full viewport minus top bar and safe-area insets */ + /* AI chat height on mobile: full viewport minus top bar, bottom nav (3.5rem), and safe-area insets */ .ai-chat-height { - height: calc(100dvh - 3rem - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px)); + height: calc(100dvh - 3rem - 3.5rem - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px)); } - /* Live stream page height on mobile: full viewport minus top bar and safe-area insets */ + /* Live stream page height on mobile: full viewport minus top bar, bottom nav, and safe-area insets */ .livestream-height { - height: calc(100dvh - 3rem - env(safe-area-inset-bottom, 0px)); - max-height: calc(100dvh - 3rem - env(safe-area-inset-bottom, 0px)); + height: calc(100dvh - 3rem - 3.5rem - env(safe-area-inset-bottom, 0px)); + max-height: calc(100dvh - 3rem - 3.5rem - env(safe-area-inset-bottom, 0px)); } - /* Vine feed slide height: full viewport minus top bar, tab bar, and safe-area insets */ + /* Vine feed slide height: full viewport minus top bar, tab bar, bottom nav, and safe-area insets */ .vine-slide-height { - height: calc(100dvh - env(safe-area-inset-top, 0px) - 3rem - 2.5rem - env(safe-area-inset-bottom, 0px)); + height: calc(100dvh - env(safe-area-inset-top, 0px) - 3rem - 2.5rem - 3.5rem - env(safe-area-inset-bottom, 0px)); } }