ihasmail could hand a file to the share sheet as of #306, and was still not in it. Share a photo from the gallery, a link from the browser or a document from a file manager and ihasmail was not among the places it could go, which is the one piece of operating-system integration a mail app is expected to have. A share is a POST that navigates, and there is nothing on this side that can answer one: the app is a client-side router with no endpoint at that address, and the server behind it would need a route that understood the composer. So the service worker intercepts it, takes the form body, puts the files and text in its cache, and redirects to the app -- which finds them on start and opens a draft holding them. The subject is the shared title, the text and the link become the body, and files are attached and begin uploading. Nothing is addressed: a share says what to send, never who to. The body is pushed in above the signature rather than passed to open(), because open() only fits a signature when it is given no body at all -- the obvious version drops the signature from every message that started as a share, and nothing about the draft looks wrong afterwards. Collected on every start rather than when the launch URL says so. A share to a signed-out ihasmail lands on the sign-in page, and there is no account to attach to until it is done, so the payload has to outlive a redirect and a login -- which the query string does not. What that costs is a stash nobody came back for, so it carries a timestamp and expires after ten minutes. `accept` names wildcard families and explicit types and extensions both. A mail client attaches anything, but wildcards are not in the specification and operating systems differ over which form they match on, so the explicit list is what holds if the families are ignored. The cache name the worker and the app have to agree on now has one home on the app side. It was written out twice, and a drift would not fail -- a push verification would simply never complete and a share would arrive at an empty composer. One case is deliberately left to fail loudly: an app still installed whose worker has been cleared away POSTs to the server, which answers 405. A server route would trade a plain error for a silent nothing, and the payload is gone in both -- it only ever existed in that request body. Verified by test, not on a device: Android is the only place this exists at all, and the extension driving Chrome is not connected here. The handoff is pinned from the tab's side against a cache shaped exactly as the worker leaves it, since the two files never see each other.
338 lines
16 KiB
TypeScript
338 lines
16 KiB
TypeScript
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 (
|
|
<div className="app">
|
|
<header className="topbar">
|
|
<button className="icon-btn" aria-label={t("Menu")} onClick={() => (isMobile ? setDrawer((d) => !d) : update({ sidebarCollapsed: !collapsed }))}>
|
|
<MenuIcon size={22} />
|
|
</button>
|
|
<Link href="/mail" className="brand">
|
|
<img src={withBase("/img/logo.png")} alt="" />
|
|
{/* 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. */}
|
|
<span className="brand-name notranslate" translate="no">
|
|
{appName}
|
|
</span>
|
|
</Link>
|
|
<SearchBar />
|
|
<div className="topbar-actions">
|
|
<span className="push-status hide-mobile" role="img" aria-label={t(PUSH_LABEL[pushState])} title={t(PUSH_LABEL[pushState])}>
|
|
<span className={`push-dot ${pushState}`} />
|
|
</span>
|
|
<button className="icon-btn hide-mobile" aria-label={t("Keyboard shortcuts")} title={t("Keyboard shortcuts (?)")} onClick={() => setHelpOpen(true)}>
|
|
<HelpCircle size={21} />
|
|
</button>
|
|
<ThemeToggle />
|
|
<Link href="/settings" className={`icon-btn ${section === "settings" ? "active" : ""}`} aria-label={t("Settings")} title={t("Settings")}>
|
|
<Settings size={21} />
|
|
</Link>
|
|
<button className="icon-btn" style={{ width: "auto", padding: "0 2px", borderRadius: 999 }} onClick={acctMenu.open} aria-label={t("Account")}>
|
|
<Avatar who={{ name: session?.username, email: session?.username }} size="sm" />
|
|
</button>
|
|
<Popover anchor={acctMenu.anchor} onClose={acctMenu.close} align="end" width={280}>
|
|
<div style={{ padding: "10px 10px 6px", display: "flex", gap: 10, alignItems: "center" }}>
|
|
<Avatar who={{ name: session?.username, email: session?.username }} />
|
|
<div className="grow">
|
|
<div style={{ fontWeight: 600 }} className="truncate">
|
|
{session?.username}
|
|
</div>
|
|
<div className="hint truncate notranslate" translate="no">{session?.ihasmail?.loginName}</div>
|
|
</div>
|
|
</div>
|
|
<MenuSep />
|
|
<MenuItem icon={<BookOpen size={16} />} 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. */}
|
|
<MenuItem icon={<Globe size={16} />} label={t("About ihasmail")} href="https://ihasmail.org" external />
|
|
<MenuItem icon={<Settings size={16} />} label={t("Settings")} onClick={() => navigate("/settings")} />
|
|
<MenuItem icon={<RefreshCw size={16} />} label={t("Refresh")} onClick={() => window.location.reload()} />
|
|
<MenuItem icon={<LogOut size={16} />} label={t("Sign out")} onClick={() => void logout()} />
|
|
</Popover>
|
|
</div>
|
|
</header>
|
|
|
|
<div className={`app-body ${collapsed && !isMobile ? "collapsed" : ""}`}>
|
|
<div className={`drawer-backdrop ${drawer ? "open" : ""}`} onClick={() => setDrawer(false)} />
|
|
<aside className={`sidebar ${drawer ? "open" : ""}`}>
|
|
{/*
|
|
The way back out.
|
|
|
|
The drawer covers the top bar -- it has to, being taller than it --
|
|
so the hamburger that opened it is underneath, and pressing the
|
|
same place again did nothing. That left the dimmed strip beside the
|
|
drawer as the only exit, which is not a thing anyone is told about.
|
|
Putting a close where the hamburger was means the second press
|
|
lands on the control that undoes the first, which is where the hand
|
|
is already going. It cannot be done by raising the top bar over the
|
|
drawer instead: the top bar sits under everything that takes the
|
|
screen -- see the stack by `.dialog-backdrop` -- and lifting it
|
|
past the drawer would put it in among the composer and the
|
|
dialogs, which it has no business covering.
|
|
*/}
|
|
{isMobile && (
|
|
<div className="drawer-head">
|
|
<button className="icon-btn" aria-label={t("Close menu")} onClick={() => setDrawer(false)}>
|
|
<X size={22} />
|
|
</button>
|
|
</div>
|
|
)}
|
|
{/* Whatever this pane is for. Files offered Compose, which wrote mail
|
|
from the file manager and was the one thing nobody wanted there. */}
|
|
<button
|
|
className="compose-btn"
|
|
onClick={() => {
|
|
if (section === "calendar") window.dispatchEvent(new CustomEvent("ihm:new-event"));
|
|
else if (section === "contacts") window.dispatchEvent(new CustomEvent("ihm:new-contact"));
|
|
else if (section === "files") window.dispatchEvent(new CustomEvent("ihm:files-upload"));
|
|
else openCompose();
|
|
}}
|
|
>
|
|
{section === "files" ? <Upload size={22} /> : section === "calendar" || section === "contacts" ? <Plus size={22} /> : <PenSquare size={22} />}
|
|
<span>{section === "calendar" ? t("New event") : section === "contacts" ? t("New contact") : section === "files" ? t("Upload") : t("Compose")}</span>
|
|
</button>
|
|
<div className="sidebar-scroll">
|
|
{(section === "mail" || section === "search") && <MailboxTree />}
|
|
{section === "calendar" && <CalendarSidebar />}
|
|
{section === "contacts" && <ContactsSidebar />}
|
|
{section === "files" && <FilesTree />}
|
|
{section === "settings" && <div className="nav-section"><span>{t("Settings")}</span></div>}
|
|
</div>
|
|
{(section === "mail" || section === "search") && <QuotaBar />}
|
|
<nav className="module-bar" aria-label={t("Go to")}>
|
|
<ModuleLink href="/mail" icon={<Mail size={20} />} label={t("Mail")} active={section === "mail" || section === "search"} />
|
|
<ModuleLink href="/calendar" icon={<Calendar size={20} />} label={t("Calendar")} active={section === "calendar"} />
|
|
<ModuleLink href="/contacts" icon={<Users size={20} />} label={t("Contacts")} active={section === "contacts"} />
|
|
<ModuleLink href="/files" icon={<FolderOpen size={20} />} label={t("Files")} active={section === "files"} />
|
|
</nav>
|
|
</aside>
|
|
{/*
|
|
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.
|
|
*/}
|
|
<main className="main"><TranslateBoundary>{children}</TranslateBoundary></main>
|
|
</div>
|
|
|
|
{isMobile && (
|
|
<>
|
|
{(section === "mail" || section === "search") && !location.split("/")[3] && (
|
|
<button className="fab" aria-label={t("Compose")} onClick={() => openCompose()}>
|
|
<PenSquare size={24} />
|
|
</button>
|
|
)}
|
|
<nav className="mobile-tabbar" aria-label={t("Sections")}>
|
|
<Link href="/mail" className={section === "mail" || section === "search" ? "active" : ""}>
|
|
<Mail size={22} />
|
|
|
|
{t("Mail")}
|
|
</Link>
|
|
<Link href="/calendar" className={section === "calendar" ? "active" : ""}>
|
|
<Calendar size={22} />
|
|
|
|
{t("Calendar")}
|
|
</Link>
|
|
<Link href="/contacts" className={section === "contacts" ? "active" : ""}>
|
|
<Users size={22} />
|
|
|
|
{t("Contacts")}
|
|
</Link>
|
|
<Link href="/files" className={section === "files" ? "active" : ""}>
|
|
<FolderOpen size={22} />
|
|
|
|
{t("Files")}
|
|
</Link>
|
|
</nav>
|
|
</>
|
|
)}
|
|
<ShortcutsDialog open={helpOpen} onClose={() => setHelpOpen(false)} />
|
|
{goFolder && (
|
|
<MailboxPicker
|
|
title={t("Go to folder…")}
|
|
/* Read, not write: a shared folder you may read but not file into is
|
|
still somewhere worth going. */
|
|
need="mayReadItems"
|
|
onClose={() => setGoFolder(false)}
|
|
onPick={(id) => { setGoFolder(false); navigate(`/mail/${id}`); }}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** 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 (
|
|
<Link href={href} className={`module-link ${active ? "active" : ""}`} title={label} aria-label={label} aria-current={active ? "page" : undefined}>
|
|
{icon}
|
|
<span className="module-label">{label}</span>
|
|
</Link>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div className="quota" title={`${formatSize(q.used)} of ${formatSize(q.hardLimit)} used`}>
|
|
<div className="row" style={{ justifyContent: "space-between" }}>
|
|
<span>
|
|
{t("{used} of {total}", { used: formatSize(q.used), total: formatSize(q.hardLimit) })}
|
|
</span>
|
|
<ChevronsUpDown size={12} style={{ opacity: 0 }} />
|
|
</div>
|
|
<div className="quota-bar">
|
|
<span className={pct > 95 ? "danger" : pct > 80 ? "warn" : ""} style={{ width: `${pct}%` }} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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 (
|
|
<button
|
|
className="icon-btn"
|
|
aria-label={t("Switch to {theme}", { theme: label })}
|
|
title={t("Switch to {theme}", { theme: label })}
|
|
onClick={() => update(next)}
|
|
>
|
|
{effective === "dark" ? <Sun size={21} /> : <Moon size={21} />}
|
|
</button>
|
|
);
|
|
}
|