mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-03 16:16:59 +00:00
175 lines
6.5 KiB
TypeScript
175 lines
6.5 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
|
import { useTranslations } from "next-intl";
|
|
import { MoreVertical, Terminal, Bell } from "@/components/icons";
|
|
import { IconButton } from "@/components/ui/icon-button";
|
|
import { ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
|
|
import { AccountSwitcher } from "@/components/layout/account-switcher";
|
|
import { PluginActions } from "@/components/plugins/plugin-actions";
|
|
import { useMenuNavigation } from "@/hooks/use-menu-navigation";
|
|
import { useIsDesktop, useIsMobile } from "@/hooks/use-media-query";
|
|
import { openCommandPalette } from "@/lib/app-commands";
|
|
import { cn } from "@/lib/utils";
|
|
import {
|
|
NotificationSheet,
|
|
formatBadgeCount,
|
|
useUnreadNotificationCount,
|
|
} from "@/components/notifications/notification-center";
|
|
|
|
/** Plugin buttons in the top bar are not about any one item. */
|
|
const TOP_BAR_CONTEXT: Record<string, unknown> = {};
|
|
|
|
export interface PhoneHeaderMenuProps {
|
|
/**
|
|
* The app's own entries, listed above the shell's. They get the function
|
|
* that closes the menu, to call when one is picked.
|
|
*/
|
|
menuItems?: (close: () => void) => ReactNode;
|
|
}
|
|
|
|
/**
|
|
* The phone header's overflow menu: the app's own entries if it has any, the
|
|
* notifications (the desktop bell), the command palette (no Ctrl+K on a
|
|
* phone) and what the desktop top bar shows as buttons (plugin
|
|
* top-bar-actions) and has no room for here.
|
|
*/
|
|
export function PhoneHeaderMenu({ menuItems }: PhoneHeaderMenuProps) {
|
|
const t = useTranslations("top_bar");
|
|
const tPalette = useTranslations("command_palette");
|
|
const tNotifications = useTranslations("notification_center");
|
|
const unread = useUnreadNotificationCount();
|
|
const [showNotifications, setShowNotifications] = useState(false);
|
|
const [open, setOpen] = useState(false);
|
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
|
const close = useCallback(() => setOpen(false), []);
|
|
const { menuRef, onKeyDown } = useMenuNavigation<HTMLDivElement>({ open, onClose: close, triggerRef });
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
const onPointerDown = (e: MouseEvent) => {
|
|
if (triggerRef.current?.contains(e.target as Node) || menuRef.current?.contains(e.target as Node)) return;
|
|
setOpen(false);
|
|
};
|
|
document.addEventListener("mousedown", onPointerDown);
|
|
return () => document.removeEventListener("mousedown", onPointerDown);
|
|
}, [open, menuRef]);
|
|
|
|
const appItems = menuItems?.(close);
|
|
|
|
return (
|
|
<div className="relative">
|
|
<IconButton
|
|
ref={triggerRef}
|
|
size="icon-lg"
|
|
onClick={() => setOpen((v) => !v)}
|
|
className={cn(open && "bg-accent")}
|
|
label={unread > 0 ? `${t("more")} (${tNotifications("unread_count", { count: unread })})` : t("more")}
|
|
title=""
|
|
aria-haspopup="menu"
|
|
aria-expanded={open}
|
|
>
|
|
<MoreVertical />
|
|
{unread > 0 && (
|
|
<span className="absolute top-2 end-2 w-2 h-2 rounded-full bg-red-500 ring-2 ring-background" aria-hidden="true" />
|
|
)}
|
|
</IconButton>
|
|
{open && (
|
|
<div
|
|
ref={menuRef}
|
|
onKeyDown={onKeyDown}
|
|
role="menu"
|
|
aria-label={t("more")}
|
|
className="absolute end-0 top-full mt-1 z-50 min-w-[200px] max-w-[calc(100vw-2rem)] rounded-md border border-border bg-background py-1 shadow-lg"
|
|
>
|
|
{appItems && (
|
|
<>
|
|
{appItems}
|
|
<ContextMenuSeparator />
|
|
</>
|
|
)}
|
|
<ContextMenuItem
|
|
icon={Bell}
|
|
label={tNotifications("title")}
|
|
shortcut={unread > 0 ? formatBadgeCount(unread) : undefined}
|
|
onClick={() => { close(); setShowNotifications(true); }}
|
|
/>
|
|
<ContextMenuItem
|
|
icon={Terminal}
|
|
label={tPalette("title")}
|
|
onClick={() => { close(); openCommandPalette(); }}
|
|
/>
|
|
<PluginActions surface="top-bar-actions" context={TOP_BAR_CONTEXT} variant="menu" onRan={close} />
|
|
</div>
|
|
)}
|
|
{showNotifications && <NotificationSheet onClose={() => setShowNotifications(false)} />}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export interface PhoneHeaderActionsProps extends PhoneHeaderMenuProps {
|
|
/**
|
|
* Below which width the app shows the phone shell (bottom tab bar, no
|
|
* rail): "md" for apps that keep the rail on a tablet, "lg" for those that
|
|
* use the phone shell up to the desktop. Above it nothing is drawn, as the
|
|
* rail or the Mountain View top bar holds these controls there.
|
|
*/
|
|
below?: "md" | "lg";
|
|
className?: string;
|
|
/** Mountain View: opens the merged across-accounts inbox from the avatar's popover. */
|
|
onSelectAllInboxes?: () => void;
|
|
allInboxesSelected?: boolean;
|
|
}
|
|
|
|
/**
|
|
* The cluster at the end of every app's phone header (answer 12 of the
|
|
* 2026-10-27 usability test): a ⋮ with notifications and the command palette,
|
|
* then the account avatar. Drop it in as the last child of the header row.
|
|
*/
|
|
export function PhoneHeaderActions({
|
|
below = "lg",
|
|
className,
|
|
menuItems,
|
|
onSelectAllInboxes,
|
|
allInboxesSelected = false,
|
|
}: PhoneHeaderActionsProps) {
|
|
const isMobile = useIsMobile();
|
|
const isDesktop = useIsDesktop();
|
|
const shown = below === "md" ? isMobile : !isDesktop;
|
|
if (!shown) return null;
|
|
|
|
return (
|
|
<div data-phone-header-actions="" className={cn("flex items-center gap-1 flex-shrink-0 ms-auto", className)}>
|
|
<PhoneHeaderMenu menuItems={menuItems} />
|
|
{/* Which account you are in is otherwise only visible in Settings.
|
|
Last, so it sits on the far edge, mirrored under RTL by the flex row. */}
|
|
<AccountSwitcher
|
|
variant="header"
|
|
onSelectAllInboxes={onSelectAllInboxes}
|
|
allInboxesSelected={allInboxesSelected}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export interface PhoneAppHeaderProps extends PhoneHeaderActionsProps {
|
|
title: string;
|
|
/** Controls drawn before the title, such as a back or menu button. */
|
|
leading?: ReactNode;
|
|
}
|
|
|
|
/** A whole phone header for a screen that has none of its own: title, then the shared cluster. */
|
|
export function PhoneAppHeader({ title, leading, ...actions }: PhoneAppHeaderProps) {
|
|
const isMobile = useIsMobile();
|
|
const isDesktop = useIsDesktop();
|
|
const shown = (actions.below ?? "lg") === "md" ? isMobile : !isDesktop;
|
|
if (!shown) return null;
|
|
return (
|
|
<header className="flex items-center gap-2 h-14 shrink-0 border-b border-border bg-background px-4">
|
|
{leading}
|
|
<h1 className="font-semibold text-lg truncate min-w-0" title={title}>{title}</h1>
|
|
<PhoneHeaderActions {...actions} />
|
|
</header>
|
|
);
|
|
}
|