Files
sendou.ink/app/components/MobileNav.tsx
Kalle e3bfd23dc2
Some checks failed
E2E Tests / e2e (push) Has been cancelled
Tests and checks on push / run-checks-and-tests (push) Has been cancelled
Updates translation progress / update-translation-progress-issue (push) Has been cancelled
Set up prefetch intent
2026-09-09 20:21:49 +03:00

664 lines
16 KiB
TypeScript

import clsx from "clsx";
import {
Calendar,
ChevronRight,
Heart,
LogIn,
Menu,
MessageSquare,
Settings,
Tv,
User,
Users,
X,
} from "lucide-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link, useLocation } from "react-router";
import { useUser } from "~/features/auth/core/user";
import { ScheduleNudge } from "~/features/availability/components/ScheduleNudge";
import { useChatContext } from "~/features/chat/ChatProvider";
import { FriendMenu } from "~/features/friends/components/FriendMenu";
import { SENDOUQ_ACTIVITY_LABEL } from "~/features/friends/friends-constants";
import { canAccessTrophies } from "~/features/trophies/trophies-utils";
import { useClosePopoversOnNavigation } from "~/hooks/useClosePopoversOnNavigation";
import { useUnseenFriendRequests } from "~/hooks/useUnseenFriendRequests";
import type { RootLoaderData } from "~/root";
import {
EVENTS_PAGE,
FRIENDS_PAGE,
navIconUrl,
SENDOU_INK_BASE_URL,
SETTINGS_PAGE,
SUPPORT_PAGE,
teamPage,
userPage,
} from "~/utils/urls";
import { Avatar } from "./Avatar";
import { EventsList } from "./EventsList";
import { LinkButton } from "./elements/Button";
import { isOwnToggle } from "./elements/Popover";
import { Image } from "./Image";
import { LazyChatSidebar } from "./layout/LazyChatSidebar";
import { LogInButtonContainer } from "./layout/LogInButtonContainer";
import {
NotificationContent,
useNotifications,
} from "./layout/NotificationPopover";
import { navItems } from "./layout/nav-items";
import styles from "./MobileNav.module.css";
import { NotificationDot } from "./NotificationDot";
import { ShareUrlButton } from "./ShareUrlButton";
import { StreamListItems } from "./StreamListItems";
type SidebarData = RootLoaderData["sidebar"] | undefined;
type PanelType = (typeof PANEL_TYPES)[number];
type PanelIds = Record<PanelType, string>;
type PanelToggleHandler = (event: React.ToggleEvent<HTMLDivElement>) => void;
const MOBILE_NAV_ID = "mobile-nav";
const PANEL_TYPES = ["menu", "friends", "tourneys", "chat", "you"] as const;
/**
* The bottom tab bar and its panels. The panels are native popovers opened by
* the tabs, so they work before hydration; opening one closes the other and the
* tab bar stays usable underneath. The state here only mirrors them.
*
* The bar itself is a manual popover shown on hydration so it lives in the top
* layer; before that it is a plain fixed bar.
*/
export function MobileNav({ sidebarData }: { sidebarData: SidebarData }) {
const [activePanel, setActivePanel] = React.useState<PanelType | null>(null);
const [skipAnimation, setSkipAnimation] = React.useState(false);
const rootRef = React.useRef<HTMLDivElement>(null);
const user = useUser();
const { showUnseenDot } = useNotifications();
const chatContext = useChatContext();
const uid = React.useId();
const panelIds = Object.fromEntries(
PANEL_TYPES.map((panel) => [panel, panelDomId(uid, panel)]),
) as PanelIds;
useClosePopoversOnNavigation(rootRef);
const chatContextRef = React.useRef(chatContext);
chatContextRef.current = chatContext;
React.useEffect(() => {
// a panel can be opened before hydration, its toggle event long gone
const openPanel = PANEL_TYPES.find((panel) =>
document.getElementById(panelDomId(uid, panel))?.matches(":popover-open"),
);
if (openPanel) {
setActivePanel(openPanel);
if (openPanel === "chat") {
chatContextRef.current?.setChatOpen(true);
}
}
const root = rootRef.current;
if (!root || root.matches(":popover-open")) return;
root.showPopover();
}, [uid]);
const hasFriendInSendouQ =
sidebarData?.friends.some((f) => f.subtitle === SENDOUQ_ACTIVITY_LABEL) ??
false;
const unseenFriendRequests = useUnseenFriendRequests(
sidebarData?.incomingFriendRequestIds ?? [],
);
const onPanelToggle =
(panel: PanelType): PanelToggleHandler =>
(event) => {
if (!isOwnToggle(event)) return;
const open = event.newState === "open";
setActivePanel((current) =>
open ? panel : current === panel ? null : current,
);
if (panel === "chat") {
chatContext?.setChatOpen(open);
}
};
// a panel taking another one's place appears in place instead of sliding
// up again; decided as the tab is pressed, before the popovers switch
const rememberOpenPanel = () => setSkipAnimation(activePanel !== null);
return (
<div
id={MOBILE_NAV_ID}
className={styles.mobileNav}
ref={rootRef}
popover="manual"
>
<MenuPanel
id={panelIds.menu}
streams={sidebarData?.streams ?? []}
savedTournamentIds={sidebarData?.savedTournamentIds}
skipAnimation={skipAnimation}
onToggle={onPanelToggle("menu")}
/>
{user ? (
<>
<FriendsPanel
id={panelIds.friends}
friends={sidebarData?.friends ?? []}
skipAnimation={skipAnimation}
onToggle={onPanelToggle("friends")}
/>
<TourneysPanel
id={panelIds.tourneys}
events={sidebarData?.events ?? []}
showScheduleNudge={sidebarData?.scheduleNudge ?? false}
skipAnimation={skipAnimation}
onToggle={onPanelToggle("tourneys")}
/>
<ChatPanel
id={panelIds.chat}
isOpen={activePanel === "chat"}
skipAnimation={skipAnimation}
onToggle={onPanelToggle("chat")}
/>
<YouPanel
id={panelIds.you}
isOpen={activePanel === "you"}
skipAnimation={skipAnimation}
onToggle={onPanelToggle("you")}
/>
</>
) : null}
<MobileTabBar
panelIds={panelIds}
activePanel={activePanel}
onBeforeToggle={rememberOpenPanel}
isLoggedIn={Boolean(user)}
hasUnseenNotifications={showUnseenDot}
hasFriendInSendouQ={hasFriendInSendouQ}
unseenFriendRequests={unseenFriendRequests}
/>
</div>
);
}
function MobileTabBar({
panelIds,
activePanel,
onBeforeToggle,
isLoggedIn,
hasUnseenNotifications,
hasFriendInSendouQ,
unseenFriendRequests,
}: {
panelIds: PanelIds;
activePanel: PanelType | null;
onBeforeToggle: () => void;
isLoggedIn: boolean;
hasUnseenNotifications: boolean;
hasFriendInSendouQ: boolean;
unseenFriendRequests: number;
}) {
const { t } = useTranslation(["front", "common"]);
const chatContext = useChatContext();
return (
<nav className={styles.tabBar}>
<MobileTab
icon={<Menu />}
label={t("front:mobileNav.menu")}
panelId={panelIds.menu}
isActive={activePanel === "menu"}
onBeforeToggle={onBeforeToggle}
/>
{isLoggedIn ? (
<>
<MobileTab
icon={<Users />}
label={t("front:mobileNav.friends")}
panelId={panelIds.friends}
isActive={activePanel === "friends"}
onBeforeToggle={onBeforeToggle}
showNotificationDot={hasFriendInSendouQ}
badgeCount={unseenFriendRequests}
badgeLeft={hasFriendInSendouQ}
/>
<MobileTab
icon={<Calendar />}
label={t("front:sideNav.myCalendar")}
panelId={panelIds.tourneys}
isActive={activePanel === "tourneys"}
onBeforeToggle={onBeforeToggle}
/>
<MobileTab
icon={<MessageSquare />}
label={t("front:mobileNav.chat")}
panelId={panelIds.chat}
isActive={activePanel === "chat"}
onBeforeToggle={onBeforeToggle}
unreadCount={chatContext?.totalUnreadCount}
/>
<MobileTab
icon={<User />}
label={t("front:mobileNav.you")}
panelId={panelIds.you}
isActive={activePanel === "you"}
onBeforeToggle={onBeforeToggle}
showNotificationDot={hasUnseenNotifications}
/>
</>
) : (
<LogInButtonContainer>
<button type="submit" className={styles.tab}>
<span className={styles.tabIcon}>
<LogIn />
</span>
<span>{t("front:mobileNav.login")}</span>
</button>
</LogInButtonContainer>
)}
</nav>
);
}
function MobileTab({
icon,
label,
panelId,
isActive,
onBeforeToggle,
showNotificationDot,
unreadCount,
badgeCount,
badgeLeft,
}: {
icon: React.ReactNode;
label: string;
panelId: string;
isActive: boolean;
onBeforeToggle: () => void;
showNotificationDot?: boolean;
unreadCount?: number;
badgeCount?: number;
badgeLeft?: boolean;
}) {
const count = unreadCount ?? badgeCount;
return (
<button
type="button"
className={styles.tab}
data-active={isActive}
popoverTarget={panelId}
onPointerDown={onBeforeToggle}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") onBeforeToggle();
}}
>
<span className={styles.tabIcon}>
{icon}
{showNotificationDot ? <NotificationDot /> : null}
{count ? (
<span
className={clsx(styles.tabBadge, {
[styles.tabBadgeLeft]: badgeLeft,
})}
>
{count}
</span>
) : null}
</span>
<span>{label}</span>
</button>
);
}
function PanelCloseButton({ panelId }: { panelId: string }) {
const { t } = useTranslation(["common"]);
return (
<button
type="button"
className={styles.panelCloseButton}
popoverTarget={panelId}
popoverTargetAction="hide"
aria-label={t("common:actions.close")}
>
<X size={18} />
</button>
);
}
function MobilePanel({
id,
title,
icon,
children,
skipAnimation,
onToggle,
}: {
id: string;
title: string;
icon: React.ReactNode;
children: React.ReactNode;
skipAnimation: boolean;
onToggle: PanelToggleHandler;
}) {
const titleId = `${id}-title`;
return (
<div
id={id}
popover="auto"
role="dialog"
aria-labelledby={titleId}
className={clsx(styles.panel, skipAnimation && styles.noAnimation)}
onToggle={onToggle}
>
<div className={styles.panelDialog}>
<header className={styles.panelHeader}>
<div className={styles.panelIconContainer}>{icon}</div>
<h2 id={titleId} className={styles.panelTitle}>
{title}
</h2>
<PanelCloseButton panelId={id} />
</header>
<div className={clsx(styles.panelContent, "scrollbar")}>{children}</div>
</div>
</div>
);
}
function MenuPanel({
id,
streams,
savedTournamentIds,
skipAnimation,
onToggle,
}: {
id: string;
streams: NonNullable<SidebarData>["streams"];
savedTournamentIds?: number[];
skipAnimation: boolean;
onToggle: PanelToggleHandler;
}) {
const { t } = useTranslation(["front", "common"]);
const user = useUser();
const location = useLocation();
const titleId = `${id}-title`;
return (
<div
id={id}
popover="auto"
role="dialog"
aria-labelledby={titleId}
className={clsx(
styles.menuOverlay,
"scrollbar",
skipAnimation && styles.noAnimation,
)}
onToggle={onToggle}
>
<div className={styles.panelDialog}>
<header className={styles.menuHeader}>
<div className={styles.panelIconContainer}>
<Menu size={18} />
</div>
<h2 id={titleId} className={styles.panelTitle}>
{t("front:mobileNav.menu")}
</h2>
<div className={styles.menuHeaderActions}>
{!user?.roles.includes("MINOR_SUPPORT") ? (
<LinkButton
to={SUPPORT_PAGE}
size="small"
icon={<Heart />}
variant="outlined"
>
{t("common:pages.support")}
</LinkButton>
) : null}
<ShareUrlButton
variant="minimal"
shape="square"
url={`${SENDOU_INK_BASE_URL}${location.pathname}${location.search}`}
/>
<PanelCloseButton panelId={id} />
</div>
</header>
<nav aria-label={t("front:mobileNav.menu")}>
<ul className={styles.navGrid}>
{navItems
.filter(
(item) => item.name !== "trophies" || canAccessTrophies(user),
)
.map((item) => (
<li key={item.name}>
<Link
to={`/${item.url}`}
prefetch="intent"
className={styles.navItem}
>
<div className={styles.navItemImage}>
<Image
path={navIconUrl(item.name)}
height={32}
width={32}
alt=""
/>
</div>
<span>{t(`common:pages.${item.name}` as any)}</span>
</Link>
</li>
))}
</ul>
</nav>
<section>
<header className={styles.menuHeader}>
<div className={styles.panelIconContainer}>
<Tv size={18} />
</div>
<h3 className={styles.panelTitle}>{t("front:sideNav.streams")}</h3>
</header>
{streams.length === 0 ? (
<div className={styles.sideNavEmpty}>
{t("front:sideNav.noStreams")}
</div>
) : null}
<ul className={styles.streamsList}>
<StreamListItems
streams={streams}
isLoggedIn={Boolean(user)}
savedTournamentIds={savedTournamentIds}
/>
</ul>
</section>
</div>
</div>
);
}
function FriendsPanel({
id,
friends,
skipAnimation,
onToggle,
}: {
id: string;
friends: NonNullable<SidebarData>["friends"];
skipAnimation: boolean;
onToggle: PanelToggleHandler;
}) {
const { t } = useTranslation(["front", "common"]);
return (
<MobilePanel
id={id}
title={t("front:sideNav.friends")}
icon={<Users size={18} />}
skipAnimation={skipAnimation}
onToggle={onToggle}
>
{friends.length > 0 ? (
friends.map((friend) => <FriendMenu key={friend.id} {...friend} />)
) : (
<div className={styles.sideNavEmpty}>
{t("front:sideNav.friends.noFriends")}
</div>
)}
<Link to={FRIENDS_PAGE} className={styles.panelSectionLink}>
{t("common:actions.viewAll")}
<ChevronRight size={14} />
</Link>
</MobilePanel>
);
}
function TourneysPanel({
id,
events,
showScheduleNudge,
skipAnimation,
onToggle,
}: {
id: string;
events: NonNullable<SidebarData>["events"];
showScheduleNudge: boolean;
skipAnimation: boolean;
onToggle: PanelToggleHandler;
}) {
const { t } = useTranslation(["front", "common"]);
return (
<MobilePanel
id={id}
title={t("front:sideNav.myCalendar")}
icon={<Calendar size={18} />}
skipAnimation={skipAnimation}
onToggle={onToggle}
>
{showScheduleNudge ? <ScheduleNudge panel /> : null}
<EventsList events={events} />
<Link to={EVENTS_PAGE} className={styles.panelSectionLink}>
{t("common:actions.viewAll")}
<ChevronRight size={14} />
</Link>
</MobilePanel>
);
}
function YouPanel({
id,
isOpen,
skipAnimation,
onToggle,
}: {
id: string;
isOpen: boolean;
skipAnimation: boolean;
onToggle: PanelToggleHandler;
}) {
const { t } = useTranslation(["front", "common"]);
const user = useUser();
const { notifications, unseenIds } = useNotifications();
if (!user) {
return null;
}
return (
<MobilePanel
id={id}
title={t("front:mobileNav.you")}
icon={<User size={18} />}
skipAnimation={skipAnimation}
onToggle={onToggle}
>
<div className={styles.youPanelUserRow}>
<Link to={userPage(user)} className={styles.youPanelUser}>
<Avatar user={user} size="sm" />
<span className={styles.youPanelUsername}>{user.username}</span>
</Link>
{user.team ? (
<Link
to={teamPage(user.team.customUrl)}
className={styles.youPanelSettingsButton}
aria-label={t("common:header.myTeam")}
>
{user.team.avatarUrl ? (
<img
src={user.team.avatarUrl}
alt=""
className={styles.youPanelTeamAvatar}
width={22}
height={22}
/>
) : (
<Image path={navIconUrl("t")} alt="" width={22} height={22} />
)}
</Link>
) : null}
<Link
to={SETTINGS_PAGE}
className={styles.youPanelSettingsButton}
aria-label={t("common:pages.settings")}
>
<Settings size={18} />
</Link>
</div>
{notifications ? (
<NotificationContent
notifications={notifications}
unseenIds={unseenIds}
isOpen={isOpen}
/>
) : null}
</MobilePanel>
);
}
/** The chat needs JavaScript regardless, so unlike the other panels this one fills in only once open. */
function ChatPanel({
id,
isOpen,
skipAnimation,
onToggle,
}: {
id: string;
isOpen: boolean;
skipAnimation: boolean;
onToggle: PanelToggleHandler;
}) {
const { t } = useTranslation(["front"]);
const panelRef = React.useRef<HTMLDivElement>(null);
return (
<div
ref={panelRef}
id={id}
popover="auto"
role="dialog"
aria-label={t("front:mobileNav.chat")}
className={clsx(styles.menuOverlay, skipAnimation && styles.noAnimation)}
onToggle={onToggle}
>
<div className={styles.panelDialog}>
{isOpen ? (
<LazyChatSidebar onClose={() => panelRef.current?.hidePopover()} />
) : null}
</div>
</div>
);
}
function panelDomId(uid: string, panel: PanelType) {
return `${uid}-${panel}`;
}