import { Link, usePage } from '@inertiajs/react'; import { Activity, Bell, Star, GitCommitVertical, History, MessagesSquare, PanelLeftClose, PanelLeftOpen, Search, Settings2, ShieldCheck, SquarePen, UserRound, } from 'lucide-react'; import { Children, isValidElement } from 'react'; import type { ReactElement, ReactNode } from 'react'; import { useEffect, useLayoutEffect, useRef, useState } from 'react'; import ConversationController from '@/actions/App/Http/Controllers/ConversationController'; import LogController from '@/actions/App/Http/Controllers/LogController'; import ModerationController from '@/actions/App/Http/Controllers/ModerationController'; import NotificationController from '@/actions/App/Http/Controllers/NotificationController'; import OperationsController from '@/actions/App/Http/Controllers/OperationsController'; import StarredCommitController from '@/actions/App/Http/Controllers/StarredCommitController'; import AppLogo from '@/components/app-logo'; import AppLogoIcon from '@/components/app-logo-icon'; import { ChangelogDialog } from '@/components/changelog-dialog'; import { GitHubIcon } from '@/components/github-icon'; import { LogoMenu } from '@/components/logo-menu'; import { MobileMenuIcon } from '@/components/mobile-menu-icon'; import { Button } from '@/components/ui/button'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { Tooltip, TooltipContent, TooltipTrigger, } from '@/components/ui/tooltip'; import { UserAvatar } from '@/components/user-avatar'; import { UserMenuContent } from '@/components/user-menu-content'; import { useChangelog } from '@/hooks/use-changelog'; import { useUnreadMessages } from '@/hooks/use-chat'; import { useCurrentUrl } from '@/hooks/use-current-url'; import { useMediaQuery } from '@/hooks/use-media-query'; import { useNavOpen } from '@/hooks/use-nav-open'; import { useKeyLabels } from '@/lib/keyboard'; import { hideLiteMobileAppTabs, setLiteMobileAppTabs, useDocumentTheme, useIsModalOpen, } from '@/lib/lite-mobile-app'; import type { LiteMobileAppTabs as LiteMobileAppTabsState } from '@/lib/lite-mobile-app-tabs'; import { liteMobileAppTabs } from '@/lib/lite-mobile-app-tabs'; import { scrollLogToTop } from '@/lib/log-scroll'; import { browserTimeZone, cn, toUrl } from '@/lib/utils'; import { home, login } from '@/routes'; import { show } from '@/routes/user'; import { edit } from '@/routes/user-profile'; import type { Log, NavItem, Profile } from '@/types'; const navLabelClassName = 'max-w-0 overflow-hidden opacity-0 transition-[max-width,opacity] duration-300 ease-[cubic-bezier(0.33,1,0.68,1)] nav-wide:max-w-40 nav-wide:opacity-100 motion-reduce:transition-none'; const mobileNavMotionClassName = 'transition-transform duration-350 ease-[cubic-bezier(0.4,0,0.2,1)] motion-reduce:transition-none'; /** * Guests see the same items, each taking them to sign in. */ function useNavItems(): (NavItem & { isActive: boolean })[] { const { auth, hasUnmoderatedCommits } = usePage().props; const { isCurrentUrl, isCurrentOrParentUrl } = useCurrentUrl(); const { user, log } = auth; const unreadMessages = useUnreadMessages(); const hrefFor = ( href: (user: Profile, log: Log) => NavItem['href'], ): NavItem['href'] => user === null || log === null ? login() : href(user, log); const items: NavItem[] = [ { title: 'Log', href: log === null ? home() : LogController.show(log), icon: GitCommitVertical, }, { title: 'Notifications', href: hrefFor(() => NotificationController.index()), icon: Bell, badge: auth.unreadNotificationsCount, }, ...(auth.remotes !== null ? [ { title: 'Remotes', description: 'Direct messages', href: ConversationController.index(), icon: MessagesSquare, badge: unreadMessages, }, ] : []), { title: 'Profile', href: hrefFor((user) => show(user)), icon: UserRound, }, { title: 'Starred', href: hrefFor(() => StarredCommitController.index()), icon: Star, }, ...(auth.isAdmin ? [ { title: 'Moderation', href: ModerationController.show(), icon: ShieldCheck, hasIndicator: hasUnmoderatedCommits, }, { title: 'Operations', href: OperationsController.show(), icon: Activity, }, ] : []), { title: 'Settings', href: hrefFor(() => edit()), icon: Settings2, }, ]; return items.map((item) => ({ ...item, isActive: user !== null && (item.title === 'Settings' ? isCurrentOrParentUrl('/settings') : item.title === 'Log' ? isCurrentOrParentUrl('/log') : item.title === 'Remotes' ? isCurrentOrParentUrl('/remotes') : item.title === 'Operations' ? isCurrentOrParentUrl('/operations') : isCurrentUrl(item.href)), })); } /** * Signing in leaves for GitHub, which an Inertia visit cannot follow, so guests get a plain link instead. */ function NavLink({ item, className, iconsOnly = false, children, }: { item: NavItem & { isActive: boolean }; className: string; iconsOnly?: boolean; children: ReactNode; }) { const { component, props: { auth }, } = usePage(); const onClick = item.title === 'Log' && component === 'log/show' ? scrollLogToTop : undefined; const title = iconsOnly ? undefined : item.description; if (auth.user === null) { return ( {children} ); } return ( {children} ); } /** * The tooltip stays in the tree whether or not it can show, so toggling the navigation keeps each link and its label * fades instead of being replaced. */ function NavTooltip({ label, children, }: { label: string | null; children: ReactElement; }) { const [isOpen, setIsOpen] = useState(false); if (label === null && isOpen) { setIsOpen(false); } return ( setIsOpen(label !== null && open)} > {children} {label} ); } function SignInButton({ className }: { className?: string }) { return ( ); } function NavIcon({ item, indicatorClassName, }: { item: NavItem & { isActive: boolean }; indicatorClassName?: string; }) { if (!item.icon) { return null; } const count = item.isActive ? 0 : (item.badge ?? 0); return ( {item.hasIndicator && ( )} {count > 0 && ( {count > 99 ? '99+' : count} unread )} ); } export function AppNav({ onSearch, onCompose, }: { onSearch: () => void; onCompose: () => void; }) { const { auth } = usePage().props; const { isNavOpen: sidebarOpen, isLayoutOpen, setNavOpen } = useNavOpen(); const items = useNavItems(); const keys = useKeyLabels(); const iconsOnly = !(useMediaQuery('(min-width: 64rem)') && isLayoutOpen); return (
a social network for devs {auth.user === null ? ( ) : ( {auth.user.name} @{auth.user.handle} )}
); } export function MobileHeader({ onSearch }: { onSearch: () => void }) { const { auth } = usePage().props; return (
{auth.user === null ? ( ) : ( )}
); } export function MobileCommitButton({ isFloating = false, isHidden, onCompose, }: { isFloating?: boolean; isHidden: boolean; onCompose: () => void; }) { return ( ); } /** * Shares its real height, safe area included, as `--mobile-nav-height`, so full-screen pages can end right above it. */ export function MobileNav({ children }: { children?: ReactNode }) { const { auth, isLiteMobileApp } = usePage().props; const timeZone = auth.user?.timezone ?? browserTimeZone(); const { changelog, hasFailed, isLoadingOlder, hasOlderFailed, load, loadOlder, } = useChangelog(timeZone); const [isChangelogOpen, setIsChangelogOpen] = useState(false); const allItems = useNavItems(); const menuItems = allItems.filter((item) => ['Profile', 'Settings', 'Operations'].includes(item.title), ); const items = allItems.filter( (item) => item.title !== 'Starred' && !menuItems.some((menuItem) => menuItem.title === item.title), ); const renderedChildren = Children.toArray(children); const dockChild = renderedChildren.find( (child) => isValidElement(child) && child.type === MobileCommitButton, ); const auxiliaryChildren = renderedChildren.filter( (child) => child !== dockChild, ); const hasDockChild = dockChild !== undefined; const isMenuActive = menuItems.some((item) => item.isActive); const itemCount = items.length + 1 + (hasDockChild ? 1 : 0); const nav = useRef(null); const dock = useRef(null); const [isCompact, setIsCompact] = useState(false); useEffect(() => { const mobile = window.matchMedia('(max-width: 767px)'); const root = document.documentElement; let compact = false; let frame = 0; let previousY = 0; let direction = 0; let directionStart = 0; const update = () => { frame = 0; if (root.hasAttribute('data-keyboard-open')) { return; } // Clamp elastic overscroll so iOS bounce does not reverse the dock. const y = Math.max( 0, Math.min( window.scrollY, root.scrollHeight - window.innerHeight, ), ); const nextDirection = Math.sign(y - previousY); if (nextDirection !== 0 && nextDirection !== direction) { directionStart = previousY; direction = nextDirection; } previousY = y; let next = compact; if (!mobile.matches || y <= 24) { next = false; } else if (direction > 0 && y > 80 && y - directionStart >= 32) { next = true; } else if (direction < 0 && directionStart - y >= 24) { next = false; } if (next !== compact) { compact = next; setIsCompact(next); } }; const onScroll = () => { if (frame === 0) { frame = window.requestAnimationFrame(update); } }; update(); window.addEventListener('scroll', onScroll, { passive: true }); mobile.addEventListener('change', onScroll); return () => { window.cancelAnimationFrame(frame); window.removeEventListener('scroll', onScroll); mobile.removeEventListener('change', onScroll); }; }, []); useLayoutEffect(() => { const element = nav.current; const surface = dock.current; if (element === null || surface === null) { return; } const root = document.documentElement; // Hidden while the keyboard is open, it keeps its height, so toasts above it do not drop onto it on the way back. const observer = new ResizeObserver(() => { const { height } = element.getBoundingClientRect(); if (height > 0) { root.style.setProperty('--mobile-nav-height', `${height}px`); const width = surface.getBoundingClientRect().width; if (width > 0) { // Leave room for 44px targets, including compose, on narrow screens. const compactWidth = Math.min( width, Math.max( itemCount * 44 + 18, Math.min(width - 24, 296), ), ); surface.style.setProperty( '--dock-scale', `${compactWidth / width}`, ); surface.style.setProperty( '--dock-inset', `${(width - compactWidth) / 2}px`, ); } } else if (window.matchMedia('(min-width: 48rem)').matches) { root.style.removeProperty('--mobile-nav-height'); } }); observer.observe(element, { box: 'border-box' }); return () => { observer.disconnect(); root.style.removeProperty('--mobile-nav-height'); }; }, [itemCount]); // Only the surface scales. Fixed-size controls translate between evenly spaced slots. const itemPosition = (index: number, offset = 0) => ({ transform: isCompact ? `translate(calc(var(--dock-inset, 0px) * ${(itemCount - 1 - 2 * index) / itemCount} + ${offset}px), 2px)` : `translate(${offset}px, 0px)`, }); // The lite mobile app draws the tabs itself. The nav keeps the height of its tab bar, which the app adds to the safe // area, so the commit button floats above the bar and full-screen pages end right above it. if (isLiteMobileApp) { return ( ); } return ( ); } /** * Hands the main items of the navigation to the lite mobile app's tab bar. */ function LiteMobileAppNavTabs({ items, }: { items: (NavItem & { isActive: boolean })[]; }) { const { url } = usePage(); const [appearance, tint] = useDocumentTheme().split(' '); const isModalOpen = useIsModalOpen(); const key = JSON.stringify( liteMobileAppTabs( items.map((item) => ({ title: item.title, url: toUrl(item.href), badge: item.badge, })), new URL(url, 'https://dev-null.sh').pathname, { appearance: appearance === 'light' ? 'light' : 'dark', tint: tint || null, isModalOpen, }, ), ); useEffect(() => { setLiteMobileAppTabs(JSON.parse(key) as LiteMobileAppTabsState); }, [key]); useEffect(() => hideLiteMobileAppTabs, []); return null; }