import { useEffect, useState, type ReactNode } from "react"; import { Link, useLocation } from "wouter"; import { BookOpen, Calendar, ChevronsUpDown, FolderOpen, Globe, HelpCircle, LogOut, Mail, Menu as MenuIcon, Moon, PenSquare, Plus, RefreshCw, Settings, Sun, Upload, Users, X } from "lucide-react"; import { useSession } from "@/store/session"; import { withBase } from "@/lib/basePath"; import { DEFAULT_APP_NAME } from "@/lib/brand"; import { useEffectiveTheme, useSettings } from "@/store/settings"; import { toggleTarget } from "@/lib/palette"; import { useMail } from "@/store/mail"; import { draftFromMailto, useCompose } from "@/store/compose"; import { Avatar, useIsMobile } from "@/ui/misc"; import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover"; import { SearchBar } from "./SearchBar"; import { MailboxTree } from "./mail/MailboxTree"; import { FilesTree } from "./files/FilesTree"; import { ContactsSidebar } from "./contacts/ContactsSidebar"; import { CalendarSidebar } from "./calendar/CalendarSidebar"; import { ShortcutsDialog, useGlobalShortcuts } from "./Shortcuts"; import { MailboxPicker } from "./mail/MailboxPicker"; import { formatSize } from "@/lib/format"; import { collectShare } from "@/lib/shareTarget"; import { TranslateBoundary } from "@/ui/TranslateBoundary"; import { t } from "@/lib/i18n"; 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 openShare = useCompose((s) => s.openFromShare); const pushState = useSession((s) => s.pushState); const session = useSession((s) => s.session); const logout = useSession((s) => s.logout); const appName = useSession((s) => s.session?.ihasmail?.appName) || DEFAULT_APP_NAME; const acctMenu = useMenu(); /* * "Go to folder" (#233), hosted here rather than in the mail view because * the `g` shortcuts are global: pressing it from the calendar should still * take you to a folder, and the mail view is not mounted to hear about it. */ const [goFolder, setGoFolder] = useState(false); const section = location.split("/")[1] || "mail"; useGlobalShortcuts({ onHelp: () => setHelpOpen(true), onGoToFolder: () => setGoFolder(true) }); useEffect(() => setDrawer(false), [location]); // Escape closes it too, for the tablet with a keyboard attached. useEffect(() => { if (!drawer) return; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setDrawer(false); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [drawer]); // 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]); /* * A share from the operating system, collected rather than read off the URL. * * The other deep links above arrive as a query the app can read on the spot. * A share cannot: it is a POST, the service worker answered it, and what it * left behind has to survive the redirect -- and, when nobody was signed in, * a trip through the sign-in page as well. So this asks on every start * instead of only when `?share=1` says so, and finds nothing almost every * time. The `at` stamp is what stops an abandoned one turning up days later. * * It runs here rather than in `main.tsx` because attaching needs an account: * `addFiles` uploads as it goes, and there is nothing to upload to until the * session is in place. AppShell only exists once there is one. */ useEffect(() => { void collectShare().then((share) => { if (!share) return; openShare(share); if (new URLSearchParams(window.location.search).has("share")) navigate("/mail", { replace: true }); }); }, [openShare, navigate]); /* * There is no account switcher any more. * * It existed to reach what other people shared, and was the wrong door: it * moved the whole app to somebody else's account, and Stalwart advertises * every capability on a shared account, so mail, calendar and contacts went * with it and were refused. Shares are listed where they belong now -- in * Files and in Contacts, beside the reader's own -- and found without anyone * having to know an account switch was involved. */ return (
{/* A product name, not a word: translated it is a different product. Read from the session rather than written here, so a deployment that set APP_NAME is called what it calls itself -- the document title has taken it from there all along. */} {appName}
{session?.username}
{session?.ihasmail?.loginName}
} label={t("Documentation")} href="https://docs.ihasmail.org" external /> {/* The project site. It is linked from the login screen footer, which is a page a signed-in user never sees again -- so from inside the app there was no way back to it. */} } label={t("About ihasmail")} href="https://ihasmail.org" external /> } label={t("Settings")} onClick={() => navigate("/settings")} /> } label={t("Refresh")} onClick={() => window.location.reload()} /> } label={t("Sign out")} onClick={() => void logout()} />
setDrawer(false)} /> {/* Scoped to the content, not the shell. If Chrome's translator breaks a message list, the top bar, the folder tree and any open composer are outside this and carry on -- so recovery is a pane blinking rather than the app disappearing. */}
{children}
{isMobile && ( <> {(section === "mail" || section === "search") && !location.split("/")[3] && ( )} )} setHelpOpen(false)} /> {goFolder && ( setGoFolder(false)} onPick={(id) => { setGoFolder(false); navigate(`/mail/${id}`); }} /> )}
); } /** 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 (
{t("{used} of {total}", { used: formatSize(q.used), total: 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 settings = useSettings((s) => s.settings); const update = useSettings((s) => s.update); const prefersDark = Boolean(window.matchMedia?.("(prefers-color-scheme: dark)").matches); const next = toggleTarget({ palette: settings.palette, mode: settings.mode }, prefersDark); // Name where it is going, and by the palette when the palette is changing -- // going back to ihasmail's own colours is not the same as "dark mode". // The palette never changes now, so the label is only ever the side. const label = next.mode === "light" ? t("light mode") : t("dark mode"); return ( ); }