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