Three things an installed ihasmail did not do that a phone user expects, and all three are about the app once it is off the browser tab. The unread count was painted into the tab title and the favicon, neither of which exists in `display: standalone` -- so putting ihasmail on a home screen threw the count away entirely. It goes to the Badging API as well now. Web Push marks the icon while the app is closed, and marks it with a dot rather than a figure: the service worker has no session to ask how many messages are unread, and a push carries the new mail rather than a total, so counting the payload would badge "2" over an inbox holding forty. The next tab to open writes the real count over it. Sharing is new. Everything that left ihasmail left as a download, which on a phone is close to a dead end -- the file lands in Downloads and whoever meant to send it somewhere goes looking for it in a file manager. The share sheet is now on the message menu, on each attachment row, and in the file viewer, which is where an attachment is already open and where both callers meet. A message shares as text rather than as the .eml beside it: a share sheet is aimed at everything that is not a mail client, and an .eml in a chat app is an attachment nobody can open. Every control feature-detects, and sharing a file is a separate question from sharing at all -- desktop Linux and Firefox have neither, and not every browser with `share` takes files. Anything that fails, including the transient activation running out while a large attachment is fetched, falls through to the download the button sits beside, so the worst case costs a tap rather than the file. `NotAllowedError` is reported as unsupported for that reason: it cannot be told apart from a refusal, and a toast about activation is not something a reader can act on. The share strings are contextual keys rather than the existing "Share…". That one means granting another account access, and several languages use a different verb for it -- German had "Freigeben" where the sheet wants "Teilen". Three new strings, in all nine catalogues. The manifest gains `launch_handler: navigate-existing`, so a mailto:, a shortcut or a notification tapped while ihasmail is running arrives in the copy that is running: two windows on one inbox disagree about what has been read. `focus-existing` would have been wrong -- it only focuses and leaves the target URL to launchQueue, which nothing here consumes, so it would swallow the mailto. There is deliberately still no `id`, and the manifest now says why: it is the one member resolved against the origin of start_url rather than against the manifest's own address, so no relative form can name a subpath mount, and the default id already is start_url -- writing one now would give every installed copy a new identity and orphan it as a second app. Verified by test rather than on a device: the extension driving Chrome was not connected, and Chrome on Linux has no Web Share to drive anyway. The preview dialog is covered by a component test that stubs the browser both ways.
178 lines
7.3 KiB
JavaScript
178 lines
7.3 KiB
JavaScript
/* ihasmail service worker.
|
|
Two jobs: app-shell caching for installability and fast loads (API requests
|
|
are never cached), and Web Push, which is the only part of ihasmail that runs
|
|
when no tab is open. */
|
|
const VERSION = "ihasmail-v2";
|
|
|
|
/*
|
|
* The mount, worked out rather than configured.
|
|
*
|
|
* This file is copied to the build verbatim -- Vite's `base` never touches
|
|
* public/ -- so there is nothing to substitute BASE_PATH into. It does not
|
|
* need one: the worker is served from the mount, so its own address says
|
|
* where that is. `/mail/sw.js` gives `/mail`, `/sw.js` gives `""`, which is
|
|
* the same canonical form the rest of the app uses.
|
|
*
|
|
* Deriving it here also means the worker cannot disagree with the page that
|
|
* registered it, which a second copy of the value in a build-time constant
|
|
* eventually would.
|
|
*/
|
|
const BASE = new URL("./", self.location).pathname.replace(/\/$/, "");
|
|
const SHELL = [`${BASE}/`, `${BASE}/manifest.webmanifest`, `${BASE}/img/logo.png`, `${BASE}/img/icon-192.png`, `${BASE}/favicon.ico`];
|
|
|
|
self.addEventListener("install", (event) => {
|
|
event.waitUntil(caches.open(VERSION).then((c) => c.addAll(SHELL)).then(() => self.skipWaiting()));
|
|
});
|
|
|
|
self.addEventListener("activate", (event) => {
|
|
event.waitUntil(
|
|
caches.keys().then((keys) => Promise.all(keys.filter((k) => k !== VERSION).map((k) => caches.delete(k)))).then(() => self.clients.claim())
|
|
);
|
|
});
|
|
|
|
self.addEventListener("fetch", (event) => {
|
|
const req = event.request;
|
|
if (req.method !== "GET") return;
|
|
const url = new URL(req.url);
|
|
if (url.origin !== self.location.origin) return;
|
|
if (url.pathname.startsWith(`${BASE}/api/`)) return;
|
|
|
|
// Hashed build assets: cache-first.
|
|
if (url.pathname.startsWith(`${BASE}/assets/`)) {
|
|
event.respondWith(
|
|
caches.match(req).then((hit) => hit || fetch(req).then((res) => {
|
|
const copy = res.clone();
|
|
caches.open(VERSION).then((c) => c.put(req, copy));
|
|
return res;
|
|
}))
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Navigations & everything else: network-first, fall back to cached shell.
|
|
if (req.mode === "navigate") {
|
|
event.respondWith(fetch(req).catch(() => caches.match(`${BASE}/`)));
|
|
return;
|
|
}
|
|
event.respondWith(fetch(req).catch(() => caches.match(req)));
|
|
});
|
|
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Web Push */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
/*
|
|
* Stalwart signs with VAPID and pushes straight to the browser's push service;
|
|
* nothing here talks to ihasmail's server. The payload is an EmailPush object
|
|
* (draft-ietf-jmap-emailpush) carrying enough of the message to show a useful
|
|
* notification without a round-trip — which matters, because when this fires
|
|
* there may be no session to make one with.
|
|
*
|
|
* A JMAP subscription also delivers a PushVerification first, and stays silent
|
|
* until the client echoes its code back. That cannot be done from here (no
|
|
* credentials), so it is stashed for a tab to collect and confirm.
|
|
*/
|
|
|
|
/*
|
|
* Absolute, and anchored to the mount rather than to whatever page happens to
|
|
* be open.
|
|
*
|
|
* A relative key is resolved against the URL of whoever is asking: the worker
|
|
* lives at `<base>/sw.js`, so it stored this under `<base>/…`, while a tab at
|
|
* `/mail/inbox/abc` looked for it under `/mail/inbox/…`. The two only ever
|
|
* agreed when the open page was the root, so a verification code that arrived
|
|
* with no tab open was written where the next tab would not look -- and the
|
|
* subscription stayed silent, which is the same thing push failing looks like.
|
|
*/
|
|
const VERIFY_KEY = `${BASE}/ihasmail-push-verification`;
|
|
|
|
function textOf(email) {
|
|
const from = email?.from?.[0];
|
|
const who = from?.name || from?.email || "New message";
|
|
const what = email?.subject || "(no subject)";
|
|
return { title: who, body: what, preview: email?.preview || "" };
|
|
}
|
|
|
|
self.addEventListener("push", (event) => {
|
|
let data = null;
|
|
try {
|
|
data = event.data ? event.data.json() : null;
|
|
} catch {
|
|
/* not JSON: fall through to the generic notification below */
|
|
}
|
|
|
|
// The verification handshake. No credentials here, so hand it to a tab —
|
|
// an open one now, or the next one to start.
|
|
if (data && data["@type"] === "PushVerification") {
|
|
event.waitUntil((async () => {
|
|
const payload = { id: data.pushSubscriptionId, code: data.verificationCode };
|
|
const clients = await self.clients.matchAll({ includeUncontrolled: true, type: "window" });
|
|
if (clients.length) {
|
|
for (const c of clients) c.postMessage({ type: "push-verification", ...payload });
|
|
} else {
|
|
const cache = await caches.open(VERSION);
|
|
await cache.put(VERIFY_KEY, new Response(JSON.stringify(payload)));
|
|
}
|
|
})());
|
|
return;
|
|
}
|
|
|
|
const emails = (data && data["@type"] === "EmailPush" && Array.isArray(data.emails)) ? data.emails : [];
|
|
event.waitUntil((async () => {
|
|
/*
|
|
* Mark the app icon, without claiming a number.
|
|
*
|
|
* `setAppBadge()` with no count shows a dot rather than a figure, which is
|
|
* the only honest thing to show from here: this worker has no session, so
|
|
* it cannot ask how many messages are unread, and a push carries the new
|
|
* mail rather than a total. Counting the payload would badge "2" over an
|
|
* inbox holding forty. The next time a tab opens, `setUnreadBadge` writes
|
|
* the real count over the dot.
|
|
*/
|
|
if ("setAppBadge" in self.navigator) await self.navigator.setAppBadge().catch(() => {});
|
|
|
|
if (!emails.length) {
|
|
// A StateChange, or a payload too large to carry the message. Say
|
|
// something true rather than inventing a sender.
|
|
await self.registration.showNotification("New mail", {
|
|
icon: `${BASE}/img/icon-192.png`, badge: `${BASE}/img/favicon-64.png`, tag: "ihasmail-mail", data: { url: `${BASE}/mail` },
|
|
});
|
|
return;
|
|
}
|
|
// One notification per message, collapsing repeats of the same message by
|
|
// tag so a re-push does not stack.
|
|
for (const email of emails.slice(0, 5)) {
|
|
const { title, body, preview } = textOf(email);
|
|
await self.registration.showNotification(title, {
|
|
body: preview ? `${body}\n${preview}` : body,
|
|
icon: `${BASE}/img/icon-192.png`,
|
|
badge: `${BASE}/img/favicon-64.png`,
|
|
tag: `ihasmail-${email.id || body}`,
|
|
data: { url: email.id ? `${BASE}/mail/inbox/${email.id}` : `${BASE}/mail` },
|
|
});
|
|
}
|
|
})());
|
|
});
|
|
|
|
self.addEventListener("notificationclick", (event) => {
|
|
event.notification.close();
|
|
const url = event.notification.data?.url || `${BASE}/mail`;
|
|
event.waitUntil((async () => {
|
|
const clients = await self.clients.matchAll({ includeUncontrolled: true, type: "window" });
|
|
// Reuse a tab if one is open rather than piling up windows. Same origin is
|
|
// not enough under a prefix: `includeUncontrolled` widens the match to the
|
|
// whole origin, so on a host that also serves something else this would
|
|
// navigate a stranger's tab to our inbox.
|
|
for (const c of clients) {
|
|
const at = new URL(c.url);
|
|
if (at.origin === self.location.origin && (at.pathname === BASE || at.pathname.startsWith(`${BASE}/`))) {
|
|
await c.focus();
|
|
if ("navigate" in c) await c.navigate(url).catch(() => {});
|
|
return;
|
|
}
|
|
}
|
|
await self.clients.openWindow(url);
|
|
})());
|
|
});
|