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.
This commit is contained in:
@@ -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() {
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Mobile bottom nav - only on small screens, slides out on scroll */}
|
||||
<MobileBottomNav />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<MobileSearchSheet open={searchOpen} onClose={() => setSearchOpen(false)} />
|
||||
|
||||
<nav
|
||||
className={cn(
|
||||
'fixed bottom-0 left-0 right-0 z-40 bg-background/80 backdrop-blur-md border-t border-border sidebar:hidden safe-area-bottom',
|
||||
'transition-transform duration-300 ease-in-out',
|
||||
isHidden && 'translate-y-full',
|
||||
)}
|
||||
>
|
||||
<div className="h-14 flex items-center">
|
||||
|
||||
<Link
|
||||
to="/"
|
||||
onClick={handleHomeClick}
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center gap-0.5 flex-1 py-2 transition-colors',
|
||||
location.pathname === '/' ? 'text-primary' : 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
<HomeIcon className="size-5" />
|
||||
<span className="text-[10px] font-medium">{homeLabel}</span>
|
||||
</Link>
|
||||
|
||||
{user && showNotifications && (
|
||||
<Link
|
||||
to="/notifications"
|
||||
onClick={handleNotificationsClick}
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center gap-0.5 flex-1 py-2 transition-colors',
|
||||
location.pathname === '/notifications' ? 'text-primary' : 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
<span className="relative">
|
||||
<Bell className="size-5" />
|
||||
{hasUnread && (
|
||||
<span className="absolute -top-1 right-0 size-2 bg-primary rounded-full" />
|
||||
)}
|
||||
</span>
|
||||
<span className="text-[10px] font-medium">Notifications</span>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{showSearch && (
|
||||
<button
|
||||
onClick={handleSearchClick}
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center gap-0.5 flex-1 py-2 transition-colors',
|
||||
searchOpen ? 'text-primary' : 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
<Search className="size-5" />
|
||||
<span className="text-[10px] font-medium">Search</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</nav>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -122,16 +122,15 @@ export function MobileSearchSheet({ open, onClose }: MobileSearchSheetProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop — doesn't cover the bottom nav (z-30) */}
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-black/60 sidebar:hidden animate-in fade-in-0 duration-150"
|
||||
onClick={handleClose}
|
||||
/>
|
||||
|
||||
{/* Bottom sheet — sits above the bottom nav bar */}
|
||||
<div className="fixed left-0 right-0 z-[49] sidebar:hidden animate-in slide-in-from-bottom-4 duration-200"
|
||||
style={{ bottom: 'calc(3.5rem + env(safe-area-inset-bottom, 0px))' }}
|
||||
>
|
||||
<div className="fixed left-0 right-0 z-[49] sidebar:hidden animate-in slide-in-from-bottom-4 duration-200 bottom-mobile-nav">
|
||||
|
||||
{/* Results list — reversed so closest to input = most relevant */}
|
||||
{hasResults && (
|
||||
<div className="flex flex-col-reverse bg-popover/95 rounded-2xl mx-6 mb-0.5 overflow-hidden max-h-[55vh] overflow-y-auto shadow-lg">
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
+14
-9
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user