Group six more clusters out of web/src/lib

Takes the flat module count from 66 to 42, continuing what admin/ and
calendar/ started.

  lib/mailbox/  archiveDate, emptyFolder, folderMove, labelTree,
                mailboxName, mailboxRoute
  lib/sieve/    sieve, sieveApply, sieveFolders
  lib/input/    keyboard, swipe, touch, listSelection, dropUpload
  lib/notify/   notify, webpush, webpushEnable
  lib/sw/       swCache, swFacts, staleBuild
  lib/text/     html, markdown, text, emlName

FOUR THINGS THE FILENAMES GET WRONG, each checked by reading the file
rather than trusting what it is called:

  - appFolder is not a mailbox. It is the `ihasmail` folder in JMAP
    *Files*, where the client keeps signature images and synced settings.
    It stays flat.
  - format holds no formatting of text. It re-exports the date and clock
    formatters, so it belongs with dates/datetime, not with text/.
  - preview is the file viewer deciding what it can show without
    downloading, and source is where to point someone asking for this
    instance's AGPL source. Neither is about text.
  - notify is not Web Push. It is the tab title, the favicon badge and
    the new-mail sound -- in-app notification, which is why it sits with
    webpush rather than under sw/ with the service worker's own concerns.

threadScroll stays flat too: it decides where a conversation opens, which
is view state rather than a gesture, and input/ is honest only if
everything in it interprets something the reader did.

No behavior change. Almost every reference was on the @/ alias; eight
relative imports in files that did not move, or that moved away from a
sibling, needed rewriting by hand.
This commit is contained in:
2026-09-15 23:17:50 -07:00
parent 5cc31037c1
commit bd6a605d61
95 changed files with 104 additions and 104 deletions
+174
View File
@@ -0,0 +1,174 @@
/**
* Turning Web Push on and off, and completing the handshake it needs.
*
* Kept apart from `webpush.ts` so that module stays pure JMAP and stays
* testable: everything here touches the browser's service worker and
* permission prompt, none of which exists under a test runner.
*/
import { CAP } from "@/jmap/client";
import { withBase } from "../basePath";
import { SW_CACHE_NAME } from "../sw/swCache";
import { isDeviceTrusted } from "@/lib/storage";
import { useSession } from "@/store/session";
import { useMail } from "@/store/mail";
import {
applicationServerKey,
createSubscription,
decodeApplicationServerKey,
deviceClientId,
findSubscription,
listSubscriptions,
needsRenewal,
pushEnabledHere,
setPushEnabledHere,
subscriptionPayload,
unsubscribeThisDevice,
verifySubscription,
webPushAvailable,
} from "@/lib/notify/webpush";
let listening = false;
/**
* Watch for the verification code the server pushes.
*
* The service worker cannot answer it — a JMAP call needs the session cookie
* and this is a background context — so it forwards the code here, or leaves it
* in the cache when no tab was open to forward it to.
*/
export function listenForVerification(): void {
if (listening || typeof navigator === "undefined" || !("serviceWorker" in navigator)) return;
listening = true;
navigator.serviceWorker.addEventListener("message", (e: MessageEvent) => {
const d = e.data as { type?: string; id?: string; code?: string } | undefined;
if (d?.type === "push-verification" && d.id && d.code) void verifySubscription(d.id, d.code).catch(() => {});
});
void collectStoredVerification();
}
/** Pick up a code that arrived while no tab was open. */
async function collectStoredVerification(): Promise<void> {
try {
const cache = await caches.open(SW_CACHE_NAME);
// The same absolute key the worker writes. Relative would be resolved
// against this document's URL, which is a different place on every route.
const key = withBase("/ihasmail-push-verification");
const hit = await cache.match(key);
if (!hit) return;
const { id, code } = (await hit.json()) as { id?: string; code?: string };
await cache.delete(key);
if (id && code) await verifySubscription(id, code);
} catch {
/* nothing waiting, or no cache: not a failure */
}
}
/**
* Subscribe this browser. Safe to call again — the deviceClientId makes a
* repeat replace rather than accumulate.
*
* Returns why it could not, rather than throwing, because every reason is
* something to tell the user plainly: an old server, a browser without push, a
* permission they declined.
*/
export async function enableWebPush(): Promise<{ ok: true } | { ok: false; reason: string }> {
if (!webPushAvailable()) {
return { ok: false, reason: "This browser or mail server does not support background notifications." };
}
if (Notification.permission === "denied") {
return { ok: false, reason: "Notifications are blocked for this site in your browser's settings." };
}
// A subscription outlives the tab and belongs to the account, not the
// session -- so on a machine the user has told us is not theirs, it would go
// on delivering their mail to it long after they had gone.
if (!isDeviceTrusted()) {
return { ok: false, reason: "Background notifications need a device you have marked as your own. Sign in again with \u201CThis is my own device\u201D ticked." };
}
const key = applicationServerKey();
if (!key) return { ok: false, reason: "This mail server does not publish a push key." };
try {
await registerThisBrowser(key);
setPushEnabledHere(true);
listenForVerification();
return { ok: true };
} catch (err) {
return { ok: false, reason: (err as Error).message || "Could not subscribe to notifications." };
}
}
/**
* Get this browser subscribed at the push service and registered at Stalwart.
*
* Shared by turning push on and by renewing it, because they are the same
* call: `deviceClientId` makes a repeat registration replace rather than
* accumulate, so there is no separate "update" path to get wrong.
*
* The local subscription is created when it is missing rather than only reused.
* A browser may drop or rotate one on its own -- a `pushsubscriptionchange`
* nobody was open to hear -- and the version that only reused an existing one
* gave up there, leaving push off for good with the switch still saying it was
* on.
*/
async function registerThisBrowser(key: string): Promise<void> {
const reg = await navigator.serviceWorker.ready;
const sub = (await reg.pushManager.getSubscription()) ?? (await reg.pushManager.subscribe({
// Web Push requires it, and Chrome refuses a subscription without it.
userVisibleOnly: true,
applicationServerKey: decodeApplicationServerKey(key),
}));
const accountId = useSession.getState().ownAccountFor(CAP.mail);
const inboxId = useMail.getState().roleId("inbox");
await createSubscription(subscriptionPayload(sub, accountId, inboxId));
}
/**
* Keep a subscription alive, from app start.
*
* Renewal has to happen here rather than in the service worker: registering
* with Stalwart is a JMAP call, and a JMAP call needs the session cookie that
* only a page has. So the guarantee is "push keeps working as long as ihasmail
* is opened now and again", and the renewal window is wide enough that once a
* week is enough.
*
* Silent by design. Every reason to stop is a normal state -- push was never
* turned on here, the permission is gone, the device is not trusted any more --
* and none of them is news to deliver on a cold start.
*/
export async function renewWebPush(): Promise<void> {
if (!pushEnabledHere() || !webPushAvailable()) return;
if (typeof Notification === "undefined" || Notification.permission !== "granted") return;
const key = applicationServerKey();
if (!key) return;
try {
if (!needsRenewal(await listSubscriptions(), deviceClientId())) return;
await registerThisBrowser(key);
listenForVerification();
} catch {
/* offline, or the server said no: the next start tries again */
}
}
/** Remove this browser's subscription, at the browser and at the server. */
export async function disableWebPush(): Promise<void> {
await unsubscribeThisDevice();
}
/**
* Whether *this browser* has a subscription registered at the server.
*
* The device has to match. This used to answer "does the account have any
* subscription at all", which is true the moment one other device has one --
* so a phone that had never successfully registered, or whose registration had
* since expired, showed the switch already on and delivered nothing. The
* account-wide question is not one this switch is asking.
*/
export async function webPushActive(): Promise<boolean> {
try {
const reg = await navigator.serviceWorker?.getRegistration();
if (!(await reg?.pushManager.getSubscription())) return false;
return Boolean(findSubscription(await listSubscriptions(), deviceClientId()));
} catch {
return false;
}
}