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 } 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, 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 { formatSize } from "@/lib/format"; import { TranslateBoundary } from "@/ui/TranslateBoundary"; 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 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]); /* * 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. "ihasmail" translated is a different product, and the one on the tab beside it is still called this. */} ihasmail
{session?.username}
{session?.ihasmail?.loginName}
} label="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="About ihasmail" href="https://ihasmail.org" external /> } label="Settings" onClick={() => navigate("/settings")} /> } label="Refresh" onClick={() => window.location.reload()} /> } label="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)} />
); } /** 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 ( ); }