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.
175 lines
6.9 KiB
TypeScript
175 lines
6.9 KiB
TypeScript
/**
|
|
* 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;
|
|
}
|
|
}
|