Files
inbuxa-webmail/web/src/App.tsx
T
jcoffey-dev 9fcf4812f3
ci / node (pull_request) Skipped
ci / version (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 3m3s
ci / docker-build (pull_request) Skipped
ci / publish (pull_request) Skipped
ci / announce (pull_request) Skipped
Notifications with the app closed, for every signed-in account
Second of three for background push across accounts (multi-account
spec, MA-8 part 2).

Turning on "Notify me even when inbuxa is closed" now registers this
browser in every signed-in account, each through its own session (the
route from the previous change), and renewal keeps them all current.
GET /api/auth/accounts says which mail account each session is, so the
subscription can name it. The remembered endpoint is kept per account,
and survives the clean-up that follows switching accounts.

The service worker is told who the other accounts are. A push for one
of them is titled with that account's address, shown even while a tab
is focused (the tab only shows the front account's mail), and its
Mark read and Archive act through that account's session. Clicking it
brings that account to the front and opens the message. While push is
on, the tab's own polling of other accounts stops notifying, so nothing
arrives twice.

Signing out of one account removes this device's subscription there
only; signing out of all, or turning push off, removes every one.

Also fixes where every background notification opened: the worker
linked to /mail/inbox/<thread>, and the route takes a mailbox id there,
so a click landed on the inbox list with "That folder no longer
exists". The worker now gets each account's inbox id and links to the
message.

Checked end to end in Chrome against a local server with two accounts:
both registered and verified, a message to the account not in front
showed a notification under its address, and clicking it switched
accounts and opened the message. No new strings. typecheck, tests
(web 1547, server 279) and build pass.
2026-10-05 20:38:44 -07:00

379 lines
17 KiB
TypeScript

import { Fragment, lazy, Suspense, useEffect, useRef, useState } from "react";
import { Route, Switch, Redirect, useLocation, Router } from "wouter";
import { useSession, useViewingDelegation } from "@/store/session";
import { notifyOwnWhileAway, ownAccountAway, useMail } from "@/store/mail";
import { scheduleSupported, useScheduled } from "@/store/scheduled";
import { useContacts } from "@/store/contacts";
import { useCalendar } from "@/store/calendar";
import { useFiles } from "@/store/files";
import { useSieve } from "@/store/sieve";
import { push } from "@/jmap/push";
import { client } from "@/jmap/client";
import { ToastHost, toast } from "@/ui/toast";
import { ConfirmHost } from "@/ui/dialog";
import { Spinner } from "@/ui/misc";
import { LoginPage } from "@/views/Login";
import { AppShell } from "@/views/AppShell";
import { MailView } from "@/views/mail/MailView";
import { ComposerDock } from "@/views/compose/ComposerDock";
import { requestNotificationPermission, setBaseTitle, setUnreadBadge } from "@/lib/notify/notify";
import { publishWorkerFacts } from "@/lib/sw/swFacts";
import { PAINTED_FROM_CACHE, useSettings, syncedPart } from "@/store/settings";
import { armSettingsSync, loadRemoteSettings, loadSettingsOnce, queueSettingsPush, settingsSyncAvailable } from "@/lib/settingsSync";
import { loadSettingsPolicy } from "@/lib/settingsPolicy";
import { listenForVerification, renewWebPush } from "@/lib/notify/webpushEnable";
import { plural, t, useLanguageVersion, whenLanguageReady } from "@/lib/i18n";
import { confirmLeaveUnsaved, hasUnsavedChanges } from "@/lib/unsavedChanges";
import { BASE_PATH, withBase } from "@/lib/basePath";
import { DEFAULT_APP_NAME } from "@/lib/brand";
const ContactsView = lazy(() => import("@/views/contacts/ContactsView").then((m) => ({ default: m.ContactsView })));
const CalendarView = lazy(() => import("@/views/calendar/CalendarView").then((m) => ({ default: m.CalendarView })));
const FilesView = lazy(() => import("@/views/files/FilesView").then((m) => ({ default: m.FilesView })));
const SettingsView = lazy(() => import("@/views/settings/SettingsView").then((m) => ({ default: m.SettingsView })));
// Only ever opened by the few who administer, so nobody else downloads it.
const AdminView = lazy(() => import("@/views/admin/AdminView").then((m) => ({ default: m.AdminView })));
export function App() {
const status = useSession((s) => s.status);
const bootstrap = useSession((s) => s.bootstrap);
/*
* Subscribed once, here, and used as a key below.
*
* `t()` is a plain function rather than a hook, so a component has no way of
* knowing its strings just changed. Rather than make every one of the
* thousand call sites a subscriber -- which would turn extracting a string
* from "wrap it" into "wrap it and add a hook" -- the whole tree is thrown
* away and rebuilt when the catalog changes. Picking a language is a
* once-in-an-account event; paying for it there is far cheaper than paying
* for it on every render everywhere.
*/
const languageVersion = useLanguageVersion();
useEffect(() => {
void bootstrap();
}, [bootstrap]);
/*
* Wait for the catalog before the first paint.
*
* The tree is rebuilt when a catalog lands, so components recover on
* their own -- but a string computed in an effect does not. A toast fired
* in the gap is emitted in English and stays English, in an interface that
* is otherwise not. The wait costs nothing visible: the session bootstrap
* is already showing a spinner, and English resolves immediately.
*/
const [languageReady, setLanguageReady] = useState(false);
useEffect(() => {
let live = true;
void whenLanguageReady().finally(() => live && setLanguageReady(true));
return () => { live = false; };
}, []);
if (status === "loading" || !languageReady) {
return (
<div className="center" style={{ height: "100%" }}>
<Spinner size="lg" />
</div>
);
}
return (
/*
* Every in-app navigation runs through `aroundNav` -- links, redirects and
* `navigate()` alike, since wouter routes them all through the same place.
* That is what makes the guard hold for the app rail and the settings nav
* without either of them knowing an editor exists.
*
* The back button is the gap: by the time `popstate` arrives the history
* has already moved, and the only way to hold the page would be to push an
* entry back, which breaks the button for everyone who has nothing pending.
* Reload and tab close are covered by `beforeunload` instead.
*/
<Router
/*
* The one place the mount prefix enters the router. Every `<Route path>`,
* `<Link href>` and `navigate()` in the app stays written root-absolute
* -- `/mail/:mailboxId?` -- and wouter strips the base off the address
* before matching and puts it back on when it navigates. So a deep link
* to `/mail/inbox/abc` under a `/mail` mount is `/mail/mail/inbox/abc`
* and nothing in the views has to know it.
*
* Empty is wouter's own default, so the root case is untouched.
*/
base={BASE_PATH}
aroundNav={(navigate, to, options) => {
if (!hasUnsavedChanges()) {
navigate(to, options);
return;
}
void confirmLeaveUnsaved().then((ok) => {
if (ok) navigate(to, options);
});
}}
>
<Fragment key={languageVersion}>{status === "anonymous" ? <LoginPage /> : <AuthedApp />}</Fragment>
<ToastHost />
<ConfirmHost />
</Router>
);
}
function AuthedApp() {
const accountId = useSession((s) => s.accountId);
const viewing = useSession((s) => s.viewing);
const [location] = useLocation();
/*
* Settings that live with the account rather than the browser.
*
* When this browser has them cached they have already painted, and this only
* has to correct them (issue #54). When it does not -- an untrusted device,
* or the sign-out that every deploy causes -- the first frame is the
* defaults, and the defaults are English. Rendering then means anything
* computed before the settings land is computed in the wrong language: not
* the interface, which is rebuilt when the catalog arrives, but a string
* emitted once, like a toast. That is why the stale-folder toast came out
* in English on an otherwise German screen.
*
* So without a cache the tree waits, which costs nothing: there was nothing
* worth painting yet. With one it does not wait, and the screen is as quick
* as it was.
*
* Once per account, not once per mount: this subtree is keyed on the
* language version, so picking a language throws it away and builds it
* again. Re-reading the settings file there would apply a copy written
* before the change and undo it. And the load is not this mount's to
* cancel: the settings file choosing a language remounts the tree midway
* through it, and a load cut off there never armed the pushes, so nothing
* changed afterwards was saved (Gitea issue #23). `loadSettingsOnce` runs
* it to the end and lets every mount wait on it.
*/
const [ready, setReady] = useState(PAINTED_FROM_CACHE);
useEffect(() => {
let canceled = false;
void loadSettingsOnce(accountId, async (isCurrent) => {
/* Before the account's own settings, so both the seeding below and the
enforcement inside `hydrate` have something to apply. */
await loadSettingsPolicy();
if (!isCurrent()) return;
const remote = await loadRemoteSettings();
if (!isCurrent()) return;
if (remote) useSettings.getState().hydrate(remote);
// No settings file: this account has never had settings of its own, so
// the installation's defaults are what it starts on rather than
// ihasmail's. Issue #207.
else useSettings.getState().seedFromPolicy();
/*
* After both, and for everybody: a change the installation wants applied
* once has to reach accounts that already exist, which is the whole of
* why it is not just a default. Each is remembered, so a reader who turns
* one back off keeps it off. Issue #207.
*/
const applied = useSettings.getState().applyPolicyChanges();
if (applied.length) {
toast.show(plural(applied.length, {
one: "Your administrator changed {n} setting",
other: "Your administrator changed {n} settings",
}), { action: { label: t("Settings"), onClick: () => { window.location.href = withBase("/settings/general"); } } });
}
// The catalog for whatever language that turned out to be. Hydrating
// asks for it; this is waiting for the answer.
await whenLanguageReady();
if (!isCurrent()) return;
// Pushes were held back until now so they could not race the load. A
// change made while it was in flight was kept, and goes out here.
armSettingsSync();
// No file yet — seed one from what this browser has, so the next device
// to sign in starts from these rather than from the defaults.
if (!remote && settingsSyncAvailable()) queueSettingsPush(syncedPart(useSettings.getState().settings));
}).then(() => {
if (!canceled) setReady(true);
});
return () => {
canceled = true;
};
}, [accountId]);
// Initial data + push wiring
useEffect(() => {
if (!accountId) return;
const mail = useMail.getState();
void mail.loadMailboxes();
void mail.loadIdentities();
void mail.loadQuota();
// So a held message shows its banner wherever it is opened from, not just
// after a visit to the Scheduled folder.
if (scheduleSupported()) void useScheduled.getState().load();
void useContacts.getState().init();
void useCalendar.getState().init();
void useFiles.getState().init();
void useSieve.getState().init();
push.start();
// A push subscription stays silent until its verification code is echoed
// back, and the code may have arrived while no tab was open.
listenForVerification();
/*
* And a subscription expires -- seven days is the ceiling JMAP puts on one,
* and re-registering before that is the client's job. Nothing did it, so
* background notifications lapsed within a week of being switched on and
* only came back if somebody
* happened to toggle the switch. Opening the app is the only moment this
* can be done -- registering is a JMAP call, and the service worker has no
* session to make one with -- so it is done on every start.
*/
void renewWebPush();
const pending = new Map<string, Set<string>>();
let timer: number | null = null;
const unsub = push.subscribe((acct, type) => {
const set = pending.get(acct) ?? new Set<string>();
set.add(type);
pending.set(acct, set);
if (timer) return;
timer = window.setTimeout(() => {
timer = null;
for (const [a, types] of pending) {
if (a === useMail.getState().accountId) void useMail.getState().applyChanges(types);
// inbuxa AL-7: the reader's own mail, while a delegated account
// is in view, is still announced
if (a === ownAccountAway() && types.has("Email")) void notifyOwnWhileAway();
if (a === useContacts.getState().accountId) useContacts.getState().applyChanges(types);
if (a === useCalendar.getState().accountId) useCalendar.getState().applyChanges(types);
if (a === useFiles.getState().accountId) useFiles.getState().applyChanges(types);
if (a === useSieve.getState().accountId) useSieve.getState().applyChanges(types);
}
pending.clear();
}, 400);
});
const unsubState = client.onSessionState(() => void useSession.getState().refresh());
// Poll fallback when push is disconnected (every 2 minutes)
const poll = window.setInterval(() => {
if (!push.connected && document.visibilityState === "visible") {
void useMail.getState().applyChanges(new Set(["Email", "Mailbox"]));
}
}, 120_000);
return () => {
unsub();
unsubState();
window.clearInterval(poll);
push.stop();
};
}, [accountId]);
/*
* inbuxa AL-7: a delegated account, the whole of it, when it comes into
* view, and the reader's own when it goes back. Push carries nothing for an account only
* shared with the reader, so while one is open it is polled.
*/
const viewedOnce = useRef(false);
useEffect(() => {
if (!viewedOnce.current) {
viewedOnce.current = true;
if (!viewing) return;
}
const mail = useMail.getState();
void mail.loadMailboxes();
void mail.loadIdentities();
// The whole account follows: calendar, contacts and files too
void useCalendar.getState().init();
void useContacts.getState().init();
void useFiles.getState().init();
if (!viewing) return;
const poll = window.setInterval(() => {
if (document.visibilityState === "visible") {
void useMail.getState().applyChanges(new Set(["Email", "Mailbox"]));
}
}, 60_000);
return () => window.clearInterval(poll);
}, [viewing]);
// inbuxa AL-7: a delegation given or taken away while the app is open shows
// up when the reader comes back to it, without signing in again
useEffect(() => {
let last = 0;
const onVisible = () => {
if (document.visibilityState !== "visible" || Date.now() - last < 60_000) return;
last = Date.now();
void useSession.getState().refresh();
};
document.addEventListener("visibilitychange", onVisible);
return () => document.removeEventListener("visibilitychange", onVisible);
}, []);
const delegationEnded = useSession((s) => s.delegationEnded);
useEffect(() => {
if (!delegationEnded) return;
toast.show(t("You no longer have access to {name}. Back to your own mail.", { name: delegationEnded }));
useSession.getState().clearDelegationEnded();
}, [delegationEnded]);
// Unread badge in title/favicon
const inboxUnread = useMail((s) => {
const id = s.roleId("inbox");
return id ? (s.mailboxes[id]?.unreadEmails ?? 0) : 0;
});
const appName = useSession((s) => s.session?.ihasmail?.appName) || DEFAULT_APP_NAME;
// inbuxa AL-7: a locked account in view is named, with a padlock, in the
// tab; a shared or group mailbox (MA-A) is named without one
const viewingName = useSession((s) => (s.viewing ? s.session?.accounts[s.viewing]?.name : undefined));
const lockedInView = useViewingDelegation()?.kind === "lock";
useEffect(() => {
setBaseTitle(viewingName ? `${lockedInView ? "🔒 " : ""}${viewingName} · ${appName}` : appName);
setUnreadBadge(inboxUnread);
}, [inboxUnread, appName, viewingName, lockedInView]);
/*
* Leave the service worker its briefing.
*
* Written from here rather than once at startup because everything in it can
* change while the app is open -- the language from Settings, the archive
* folder from the mailbox list arriving -- and what is written is what the
* worker will still be reading a week from now, with no tab to correct it.
* See lib/swFacts.ts.
*/
const archiveId = useMail((s) => s.roleId("archive"));
const inboxId = useMail((s) => s.roleId("inbox"));
const languageVersion = useLanguageVersion();
useEffect(() => {
// inbuxa AL-7: the worker acts on the reader's own mail; while a
// delegated account is in view, the archive folder here is its
if (viewing) return;
void publishWorkerFacts(accountId, archiveId, inboxId);
}, [accountId, archiveId, inboxId, languageVersion, viewing]);
// Request notification permission lazily when enabled
const notif = useSettings((s) => s.settings.desktopNotifications);
useEffect(() => {
if (notif) void requestNotificationPermission();
}, [notif]);
// Nothing worth painting until the account's settings are in force; see the
// comment on `ready` above. With a cache this was true from the first frame.
if (!ready) {
return (
<div className="center" style={{ height: "100%" }}>
<Spinner size="lg" />
</div>
);
}
return (
<AppShell>
<Suspense fallback={<Spinner size="lg" />}>
<Switch>
<Route path="/mail/:mailboxId?/:threadId?">{(p) => <MailView mailboxId={p.mailboxId} threadId={p.threadId} />}</Route>
<Route path="/search/:threadId?">{(p) => <MailView search threadId={p.threadId} />}</Route>
<Route path="/contacts/:id?">{(p) => <ContactsView id={p.id} />}</Route>
<Route path="/calendar/:view?/:date?">{(p) => <CalendarView view={p.view} date={p.date} />}</Route>
<Route path="/files/:nodeId?">{(p) => <FilesView nodeId={p.nodeId} />}</Route>
<Route path="/settings/:section?">{(p) => <SettingsView section={p.section} />}</Route>
<Route path="/admin/:section?/:id?">{(p) => <AdminView section={p.section} id={p.id} />}</Route>
<Route path="/login">
<Redirect to="/mail" />
</Route>
<Route>{location === "/" ? <Redirect to="/mail" /> : <Redirect to="/mail" />}</Route>
</Switch>
</Suspense>
<ComposerDock />
</AppShell>
);
}