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