import { useEffect, useState, type ReactNode } from "react";
import { Link, useLocation } from "wouter";
import { Calendar, ChevronsUpDown, FolderOpen, HelpCircle, Mail, Menu as MenuIcon, Moon, PenSquare, Settings, Sun, Users, LogOut, Plus, RefreshCw } from "lucide-react";
import { useSession } from "@/store/session";
import { toggleTarget, useEffectiveTheme, useSettings } from "@/store/settings";
import { useMail } from "@/store/mail";
import { draftFromMailto, useCompose } from "@/store/compose";
import { Avatar, useIsMobile } from "@/ui/misc";
import { MenuItem, MenuSep, MenuTitle, Popover, useMenu } from "@/ui/popover";
import { SearchBar } from "./SearchBar";
import { MailboxTree } from "./mail/MailboxTree";
import { FilesTree } from "./files/FilesTree";
import { CalendarSidebar } from "./calendar/CalendarSidebar";
import { ShortcutsDialog, useGlobalShortcuts } from "./Shortcuts";
import { formatSize } from "@/lib/format";
import { CAP } from "@/jmap/client";
const PUSH_LABEL = {
connected: "Live updates connected",
connecting: "Live updates reconnecting…",
disconnected: "Live updates off — checking periodically instead",
} as const;
export function AppShell({ children }: { children: ReactNode }) {
const [location, navigate] = useLocation();
const isMobile = useIsMobile();
const collapsed = useSettings((s) => s.settings.sidebarCollapsed);
const update = useSettings((s) => s.update);
const [drawer, setDrawer] = useState(false);
const [helpOpen, setHelpOpen] = useState(false);
const openCompose = useCompose((s) => s.open);
const pushState = useSession((s) => s.pushState);
const session = useSession((s) => s.session);
const accountId = useSession((s) => s.accountId);
const setAccount = useSession((s) => s.setAccount);
const logout = useSession((s) => s.logout);
const acctMenu = useMenu();
const section = location.split("/")[1] || "mail";
useGlobalShortcuts({ onHelp: () => setHelpOpen(true) });
useEffect(() => setDrawer(false), [location]);
// Deep link: /mail?compose=new (PWA shortcut) / mailto handler
useEffect(() => {
const params = new URLSearchParams(window.location.search);
if (params.get("compose") === "new") {
openCompose();
navigate("/mail", { replace: true });
}
const mailto = params.get("mailto");
if (mailto) {
openCompose(draftFromMailto(mailto));
navigate("/mail", { replace: true });
}
}, [openCompose, navigate]);
const accounts = session ? Object.entries(session.accounts) : [];
const mailAccounts = accounts.filter(([, a]) => CAP.mail in (a.accountCapabilities ?? {}));
return (
(isMobile ? setDrawer(true) : update({ sidebarCollapsed: !collapsed }))}>
ihasmail{mailAccounts.length > 1 ? "" : ""}
setHelpOpen(true)}>
{session?.username}
{session?.ihasmail?.loginName}
{mailAccounts.length > 1 && (
<>
Accounts
{mailAccounts.map(([id, a]) => (
setAccount(id)} />
))}
>
)}
} label="Settings" onClick={() => navigate("/settings")} />
} label="Refresh" onClick={() => window.location.reload()} />
} label="Sign out" onClick={() => void logout()} />
setDrawer(false)} />
{
if (section === "calendar") window.dispatchEvent(new CustomEvent("ihm:new-event"));
else if (section === "contacts") window.dispatchEvent(new CustomEvent("ihm:new-contact"));
else openCompose();
}}
>
{section === "calendar" || section === "contacts" ? : }
{section === "calendar" ? "New event" : section === "contacts" ? "New contact" : "Compose"}
{(section === "mail" || section === "search") &&
}
{section === "calendar" &&
}
{section === "contacts" &&
Contacts
}
{section === "files" &&
}
{section === "settings" &&
Settings
}
{(section === "mail" || section === "search") && }
} label="Mail" active={section === "mail" || section === "search"} />
} label="Calendar" active={section === "calendar"} />
} label="Contacts" active={section === "contacts"} />
} label="Files" active={section === "files"} />
{children}
{isMobile && (
<>
{(section === "mail" || section === "search") && !location.split("/")[3] && (
openCompose()}>
)}
Mail
Calendar
Contacts
Files
>
)}
setHelpOpen(false)} />
);
}
/** Outlook-style module switcher at the bottom of the folder pane. */
function ModuleLink({ href, icon, label, active }: { href: string; icon: ReactNode; label: string; active: boolean }) {
return (
{icon}
{label}
);
}
function QuotaBar() {
const quotas = useMail((s) => s.quotas);
const q = quotas.find((x) => x.resourceType === "octets" && x.types.includes("Email")) ?? quotas.find((x) => x.resourceType === "octets");
if (!q || !q.hardLimit) return null;
const pct = Math.min(100, Math.round((q.used / q.hardLimit) * 100));
return (
{formatSize(q.used)} of {formatSize(q.hardLimit)}
95 ? "danger" : pct > 80 ? "warn" : ""} style={{ width: `${pct}%` }} />
);
}
/**
* Flip to light and back from the top bar.
*
* The setting has four values and only two of them are "light", so the button
* acts on what is actually on screen rather than on the setting: if you can
* see a dark theme, one click gives you light.
*
* Coming back is the part that needs remembering. There is more than one way
* to be dark — "dark", "ihasmail", or "system" while the OS is — so the way
* back is whichever you were on, kept in `lastDarkTheme`, rather than plain
* "dark" for everyone. Without that, two clicks would quietly move an
* ihasmail user onto a theme they never chose.
*/
function ThemeToggle() {
const effective = useEffectiveTheme();
const lastDarkTheme = useSettings((s) => s.settings.lastDarkTheme);
const update = useSettings((s) => s.update);
const next = toggleTarget(effective, lastDarkTheme);
// The label names where you are going, and going back is not always "dark"
// any more -- it is whichever theme you were on before flipping to light.
const label = next === "light" ? "light mode" : next === "system" ? "your system theme" : next === "ihasmail" ? "the ihasmail theme" : "dark mode";
return (
update({ theme: next })}
>
{effective === "dark" ? : }
);
}