Archive and mark read from the notification itself
Both happen in the background. The phone stays where it is. This was twice described as impossible, here and in FEATURES.md: the service worker was said to have no session, so anything touching mail had to open the app. That is wrong, and checking it rather than repeating it is the whole of this change. ihasmail's session is an httpOnly cookie against its own origin and the only other thing the API asks for is a fixed `x-requested-with` header, which is not a secret and is not held anywhere. A same-origin fetch from the worker carries the cookie like any other. Confirmed against the mock: logging in with curl and then issuing `Email/set` with nothing but that cookie and the static headers marked a message read and moved it to Archive, HTTP 200. Nothing the tab holds in memory is involved, because the API asks for none of it. Two actions, because `maxActions` is two on Android and anything past it is dropped without a word. Archive and Mark as read are the two worth having: they are what somebody does to a notification they have already read the whole of. Reply is not among them -- it would have to open the app, which is what tapping the notification does already. The worker still cannot reach a catalogue. It is plain JavaScript copied into the build, outside the bundle, with no i18n and no idea which mailbox is the archive. So the app writes both down in the same cache it already uses for handoffs, and rewrites them whenever the language, the account or the folder list changes. Where there is no such note -- between installing this worker and next opening ihasmail -- the notification appears with no buttons at all, rather than English ones over a mailbox guessed by name. That also fixes two strings the worker had always shown in English regardless: "New mail" and "(no subject)". A session can be gone by the time a button is pressed. That comes back as a refusal and the notification says so, rather than vanishing as though it had worked. It does not open the app to recover: being interrupted is what the button existed to avoid. The two claims that were wrong are corrected rather than quietly deleted, including the one about push renewal -- which still needs a tab, but for a different reason than the one given. The reason is when the worker runs, not what it may do: it wakes only for a push, and the push stops when the subscription lapses. Two new strings, in all nine catalogues.
This commit is contained in:
+41
-6
@@ -1119,9 +1119,14 @@ needed nothing in either half.
|
|||||||
when the open page happened to be the root.
|
when the open page happened to be the root.
|
||||||
- **The subscription is renewed on every app start**, because a JMAP push
|
- **The subscription is renewed on every app start**, because a JMAP push
|
||||||
subscription expires — seven days is the ceiling — and re-registering before
|
subscription expires — seven days is the ceiling — and re-registering before
|
||||||
it lapses is the client's job. Renewal can only happen with a page open:
|
it lapses is the client's job. Renewal happens with a page open, and the
|
||||||
registering is a JMAP call and the service worker has no session to make one
|
reason is *when* the service worker runs rather than what it is allowed to
|
||||||
with. So the guarantee is that background notifications keep working as long
|
do: it only wakes for an event, and the event that would wake it is a push
|
||||||
|
that stops arriving the moment the subscription lapses. A renewal that can
|
||||||
|
only run while renewal is still unnecessary is no schedule at all. (This
|
||||||
|
page previously said the worker had no session to register with. That was
|
||||||
|
wrong — see **Acting on a notification** below.) So the guarantee is that
|
||||||
|
background notifications keep working as long
|
||||||
as ihasmail is opened now and again, and the two-day renewal window means
|
as ihasmail is opened now and again, and the two-day renewal window means
|
||||||
once a week is enough. A browser that dropped or rotated its subscription on
|
once a week is enough. A browser that dropped or rotated its subscription on
|
||||||
its own is re-subscribed at the same moment, rather than left with a switch
|
its own is re-subscribed at the same moment, rather than left with a switch
|
||||||
@@ -1150,9 +1155,11 @@ needed nothing in either half.
|
|||||||
neither -- in `display: standalone` there is no tab strip and no favicon on
|
neither -- in `display: standalone` there is no tab strip and no favicon on
|
||||||
screen, so a home-screen ihasmail showed nothing at all. Web Push marks the
|
screen, so a home-screen ihasmail showed nothing at all. Web Push marks the
|
||||||
icon while the app is closed, with a dot rather than a figure: the service
|
icon while the app is closed, with a dot rather than a figure: the service
|
||||||
worker has no session to ask how many messages are unread, and a push carries
|
worker is not told how many messages are unread — a push carries the new mail
|
||||||
the new mail rather than a total, so counting the payload would badge "2" over
|
rather than a total, so counting the payload would badge "2" over an inbox
|
||||||
an inbox holding forty. The next tab to open writes the real count over it.
|
holding forty. The next tab to open writes the real count over it. It could
|
||||||
|
now ask, which is a change since this was written; whether a badge is worth a
|
||||||
|
request on every push is a separate question and has not been answered yet.
|
||||||
Unsupported browsers show nothing, as does iOS until notification permission
|
Unsupported browsers show nothing, as does iOS until notification permission
|
||||||
has been granted, which is that platform's condition for a badge.
|
has been granted, which is that platform's condition for a badge.
|
||||||
- **In the share sheet** — share a photo, a link or a file from any other app
|
- **In the share sheet** — share a photo, a link or a file from any other app
|
||||||
@@ -1169,6 +1176,34 @@ needed nothing in either half.
|
|||||||
expires after ten minutes rather than opening a composer full of a forgotten
|
expires after ten minutes rather than opening a composer full of a forgotten
|
||||||
photo the next time you look. Android and Chromium only; iOS does not
|
photo the next time you look. Android and Chromium only; iOS does not
|
||||||
implement share targets.
|
implement share targets.
|
||||||
|
- **Acting on a notification.** Archive and Mark as read sit on the
|
||||||
|
notification itself, and both happen where you are — the phone stays in your
|
||||||
|
hand, or in your pocket. They are the two a phone shows: `maxActions` is two
|
||||||
|
on Android, and anything past it is dropped silently, so these are the two
|
||||||
|
worth having rather than the two that came first. Reply is deliberately not
|
||||||
|
among them, because it would have to open the app, and tapping the
|
||||||
|
notification already does that.
|
||||||
|
|
||||||
|
This was described here as impossible, and it is worth saying why it was not.
|
||||||
|
ihasmail's session is an httpOnly cookie against its own origin, and the only
|
||||||
|
other thing the API asks for is a fixed header that is not a secret. A
|
||||||
|
same-origin request from the service worker carries the cookie like any
|
||||||
|
other, so `Email/set` from a notification is an ordinary call. What the
|
||||||
|
worker genuinely cannot reach is anything a *tab* holds in memory — and the
|
||||||
|
API asks for none of it.
|
||||||
|
|
||||||
|
What it cannot reach is a catalogue. The worker is plain JavaScript outside
|
||||||
|
the bundle, with no i18n and no idea which mailbox is the archive, so the app
|
||||||
|
writes both down for it whenever the language, the account or the folder list
|
||||||
|
changes. Where there is no such note — between installing a new worker and
|
||||||
|
next opening ihasmail — the notification appears with no action buttons at
|
||||||
|
all rather than English ones over a guessed mailbox.
|
||||||
|
|
||||||
|
A session can still be gone by the time a button is pressed: expired, signed
|
||||||
|
out, or a cookie that did not outlive the browser. That comes back as a
|
||||||
|
refusal, and the notification says so rather than disappearing as though it
|
||||||
|
had worked. It does not open the app to recover — being interrupted is the
|
||||||
|
thing the button existed to avoid.
|
||||||
- **Share** — a message, or one attachment, handed to the operating system's
|
- **Share** — a message, or one attachment, handed to the operating system's
|
||||||
share sheet instead of to the filesystem. On a phone a download is close to a
|
share sheet instead of to the filesystem. On a phone a download is close to a
|
||||||
dead end: the file lands in Downloads and whoever wanted to send it somewhere
|
dead end: the file lands in Downloads and whoever wanted to send it somewhere
|
||||||
|
|||||||
+149
-13
@@ -130,14 +130,23 @@ self.addEventListener("fetch", (event) => {
|
|||||||
|
|
||||||
/*
|
/*
|
||||||
* Stalwart signs with VAPID and pushes straight to the browser's push service;
|
* 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
|
* nothing here talks to ihasmail's server on the way in. The payload is an
|
||||||
* (draft-ietf-jmap-emailpush) carrying enough of the message to show a useful
|
* EmailPush object (draft-ietf-jmap-emailpush) carrying enough of the message
|
||||||
* notification without a round-trip — which matters, because when this fires
|
* to show a useful notification without a round-trip, which is what lets a
|
||||||
* there may be no session to make one with.
|
* notification appear immediately rather than after a request.
|
||||||
|
*
|
||||||
|
* This file used to say that a round-trip was impossible here, and it was
|
||||||
|
* wrong: see the note on `jmap()`. What it can do is ask; what it cannot do is
|
||||||
|
* be sure of an answer, since the session may be gone by the time it does. So
|
||||||
|
* the payload still carries the message and the request is only made when
|
||||||
|
* somebody presses something.
|
||||||
*
|
*
|
||||||
* A JMAP subscription also delivers a PushVerification first, and stays silent
|
* 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
|
* until the client echoes its code back. It is stashed for a tab to confirm
|
||||||
* credentials), so it is stashed for a tab to collect and confirm.
|
* rather than answered here — on the same reasoning, and because a
|
||||||
|
* verification that failed silently would leave push looking broken with
|
||||||
|
* nothing to show for it. Answering it directly is now possible and is worth
|
||||||
|
* revisiting.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -153,13 +162,90 @@ self.addEventListener("fetch", (event) => {
|
|||||||
*/
|
*/
|
||||||
const VERIFY_KEY = `${BASE}/ihasmail-push-verification`;
|
const VERIFY_KEY = `${BASE}/ihasmail-push-verification`;
|
||||||
|
|
||||||
function textOf(email) {
|
/*
|
||||||
|
* What a tab wrote down for this worker: the account, which mailbox is the
|
||||||
|
* archive, and the worker's own text in the reader's language. See
|
||||||
|
* `lib/swFacts.ts` for why any of that has to be handed over rather than
|
||||||
|
* worked out here.
|
||||||
|
*
|
||||||
|
* Everything that depends on it is skipped when it is missing, which is the
|
||||||
|
* state between installing this worker and next opening the app. An action
|
||||||
|
* button with no label, or one that files mail into a mailbox guessed by name,
|
||||||
|
* is worse than the notification that was here before.
|
||||||
|
*/
|
||||||
|
const FACTS_KEY = `${BASE}/ihasmail-worker-facts`;
|
||||||
|
|
||||||
|
async function readFacts() {
|
||||||
|
try {
|
||||||
|
const hit = await (await caches.open(VERSION)).match(FACTS_KEY);
|
||||||
|
return hit ? await hit.json() : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* A JMAP call, made as the reader.
|
||||||
|
*
|
||||||
|
* This worker was written believing it could not do this -- that acting on
|
||||||
|
* mail needed a session it had no way to hold. It does not: ihasmail's session
|
||||||
|
* is an httpOnly cookie against its own origin, and the only other thing the
|
||||||
|
* API asks for is a fixed `x-requested-with` header that is not a secret and
|
||||||
|
* is not held anywhere. A same-origin fetch from here carries the cookie like
|
||||||
|
* any other, so `Email/set` from a notification is an ordinary request.
|
||||||
|
*
|
||||||
|
* What is genuinely not available is anything the *tab* holds in memory, and
|
||||||
|
* the answer is that the API asks for none of it.
|
||||||
|
*
|
||||||
|
* The session can still be gone -- expired, signed out, or a cookie that did
|
||||||
|
* not survive the browser closing -- which arrives as a 401 and is reported
|
||||||
|
* rather than swallowed. A tap that silently does nothing is the failure worth
|
||||||
|
* avoiding here: the reader has already put the phone down.
|
||||||
|
*/
|
||||||
|
async function jmap(methodCalls) {
|
||||||
|
const res = await fetch(`${BASE}/api/jmap`, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: { "content-type": "application/json", accept: "application/json", "x-requested-with": "ihasmail" },
|
||||||
|
body: JSON.stringify({ using: ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"], methodCalls }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const body = await res.json();
|
||||||
|
// A JMAP method can fail inside a 200. Treat that as a failure too, rather
|
||||||
|
// than reporting success because the transport was fine.
|
||||||
|
const first = body?.methodResponses?.[0];
|
||||||
|
if (!first || first[0] === "error") throw new Error(first?.[1]?.type || "error");
|
||||||
|
const notUpdated = first[1]?.notUpdated;
|
||||||
|
if (notUpdated && Object.keys(notUpdated).length) throw new Error("notUpdated");
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
function textOf(email, strings) {
|
||||||
const from = email?.from?.[0];
|
const from = email?.from?.[0];
|
||||||
const who = from?.name || from?.email || "New message";
|
const who = from?.name || from?.email || strings.newMessage;
|
||||||
const what = email?.subject || "(no subject)";
|
const what = email?.subject || strings.noSubject;
|
||||||
return { title: who, body: what, preview: email?.preview || "" };
|
return { title: who, body: what, preview: email?.preview || "" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Two, because that is what a phone shows. `Notification.maxActions` is 2 on
|
||||||
|
* Android Chrome, and anything past it is dropped silently -- so these are the
|
||||||
|
* two worth having rather than the two that happened to come first. Both are
|
||||||
|
* triage: they are what somebody does to a notification they have read the
|
||||||
|
* whole of on the lock screen and does not need to open.
|
||||||
|
*
|
||||||
|
* Reply is deliberately not among them. It cannot be done from here, so it
|
||||||
|
* would have to open the app -- and an action that opens the app is what
|
||||||
|
* tapping the notification already does.
|
||||||
|
*/
|
||||||
|
function actionsFor(facts) {
|
||||||
|
if (!facts) return [];
|
||||||
|
const actions = [];
|
||||||
|
if (facts.archiveId) actions.push({ action: "archive", title: facts.strings.archive });
|
||||||
|
actions.push({ action: "read", title: facts.strings.markRead });
|
||||||
|
return actions;
|
||||||
|
}
|
||||||
|
|
||||||
self.addEventListener("push", (event) => {
|
self.addEventListener("push", (event) => {
|
||||||
let data = null;
|
let data = null;
|
||||||
try {
|
try {
|
||||||
@@ -186,6 +272,8 @@ self.addEventListener("push", (event) => {
|
|||||||
|
|
||||||
const emails = (data && data["@type"] === "EmailPush" && Array.isArray(data.emails)) ? data.emails : [];
|
const emails = (data && data["@type"] === "EmailPush" && Array.isArray(data.emails)) ? data.emails : [];
|
||||||
event.waitUntil((async () => {
|
event.waitUntil((async () => {
|
||||||
|
const facts = await readFacts();
|
||||||
|
const strings = facts?.strings ?? { newMail: "New mail", newMessage: "New message", noSubject: "(no subject)" };
|
||||||
/*
|
/*
|
||||||
* Mark the app icon, without claiming a number.
|
* Mark the app icon, without claiming a number.
|
||||||
*
|
*
|
||||||
@@ -201,7 +289,7 @@ self.addEventListener("push", (event) => {
|
|||||||
if (!emails.length) {
|
if (!emails.length) {
|
||||||
// A StateChange, or a payload too large to carry the message. Say
|
// A StateChange, or a payload too large to carry the message. Say
|
||||||
// something true rather than inventing a sender.
|
// something true rather than inventing a sender.
|
||||||
await self.registration.showNotification("New mail", {
|
await self.registration.showNotification(strings.newMail, {
|
||||||
icon: `${BASE}/img/icon-192.png`, badge: `${BASE}/img/favicon-64.png`, tag: "ihasmail-mail", data: { url: `${BASE}/mail` },
|
icon: `${BASE}/img/icon-192.png`, badge: `${BASE}/img/favicon-64.png`, tag: "ihasmail-mail", data: { url: `${BASE}/mail` },
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -209,21 +297,69 @@ self.addEventListener("push", (event) => {
|
|||||||
// One notification per message, collapsing repeats of the same message by
|
// One notification per message, collapsing repeats of the same message by
|
||||||
// tag so a re-push does not stack.
|
// tag so a re-push does not stack.
|
||||||
for (const email of emails.slice(0, 5)) {
|
for (const email of emails.slice(0, 5)) {
|
||||||
const { title, body, preview } = textOf(email);
|
const { title, body, preview } = textOf(email, strings);
|
||||||
await self.registration.showNotification(title, {
|
await self.registration.showNotification(title, {
|
||||||
body: preview ? `${body}\n${preview}` : body,
|
body: preview ? `${body}\n${preview}` : body,
|
||||||
icon: `${BASE}/img/icon-192.png`,
|
icon: `${BASE}/img/icon-192.png`,
|
||||||
badge: `${BASE}/img/favicon-64.png`,
|
badge: `${BASE}/img/favicon-64.png`,
|
||||||
tag: `ihasmail-${email.id || body}`,
|
tag: `ihasmail-${email.id || body}`,
|
||||||
data: { url: email.id ? `${BASE}/mail/inbox/${email.id}` : `${BASE}/mail` },
|
// Only where there is a message to act on: a payload without an id can
|
||||||
|
// be shown but not archived, and a button that cannot work should not
|
||||||
|
// be drawn.
|
||||||
|
actions: email.id ? actionsFor(facts) : [],
|
||||||
|
data: {
|
||||||
|
url: email.id ? `${BASE}/mail/inbox/${email.id}` : `${BASE}/mail`,
|
||||||
|
id: email.id || null,
|
||||||
|
title,
|
||||||
|
accountId: facts?.accountId ?? null,
|
||||||
|
archiveId: facts?.archiveId ?? null,
|
||||||
|
failed: strings.failed ?? null,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
})());
|
})());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Do what the button said, without opening anything.
|
||||||
|
*
|
||||||
|
* The whole point of an action is that the phone goes back in the pocket, so
|
||||||
|
* this must not fall back to opening the app when the call fails -- that is
|
||||||
|
* the same interruption the action existed to avoid. It re-notifies instead,
|
||||||
|
* saying it did not happen, and leaves opening ihasmail to the reader.
|
||||||
|
*
|
||||||
|
* Archiving replaces the mailbox set rather than adding to it, which is what
|
||||||
|
* archiving is: the message leaves the inbox. Marking read is a keyword and
|
||||||
|
* touches nothing else.
|
||||||
|
*/
|
||||||
|
async function runAction(action, data) {
|
||||||
|
const { id, accountId, archiveId } = data;
|
||||||
|
if (!id || !accountId) return;
|
||||||
|
const patch = action === "archive"
|
||||||
|
? { mailboxIds: { [archiveId]: true } }
|
||||||
|
: { "keywords/$seen": true };
|
||||||
|
try {
|
||||||
|
if (action === "archive" && !archiveId) throw new Error("no archive mailbox");
|
||||||
|
await jmap([["Email/set", { accountId, update: { [id]: patch } }, "0"]]);
|
||||||
|
} catch {
|
||||||
|
await self.registration.showNotification(data.title || "ihasmail", {
|
||||||
|
body: data.failed || "Could not do that — open ihasmail and try again",
|
||||||
|
icon: `${BASE}/img/icon-192.png`,
|
||||||
|
badge: `${BASE}/img/favicon-64.png`,
|
||||||
|
tag: `ihasmail-failed-${id}`,
|
||||||
|
data: { url: data.url },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
self.addEventListener("notificationclick", (event) => {
|
self.addEventListener("notificationclick", (event) => {
|
||||||
event.notification.close();
|
event.notification.close();
|
||||||
const url = event.notification.data?.url || `${BASE}/mail`;
|
const data = event.notification.data || {};
|
||||||
|
if (event.action === "archive" || event.action === "read") {
|
||||||
|
event.waitUntil(runAction(event.action, data));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const url = data.url || `${BASE}/mail`;
|
||||||
event.waitUntil((async () => {
|
event.waitUntil((async () => {
|
||||||
const clients = await self.clients.matchAll({ includeUncontrolled: true, type: "window" });
|
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
|
// Reuse a tab if one is open rather than piling up windows. Same origin is
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { AppShell } from "@/views/AppShell";
|
|||||||
import { MailView } from "@/views/mail/MailView";
|
import { MailView } from "@/views/mail/MailView";
|
||||||
import { ComposerDock } from "@/views/compose/ComposerDock";
|
import { ComposerDock } from "@/views/compose/ComposerDock";
|
||||||
import { setUnreadBadge } from "@/lib/notify";
|
import { setUnreadBadge } from "@/lib/notify";
|
||||||
|
import { publishWorkerFacts } from "@/lib/swFacts";
|
||||||
import { PAINTED_FROM_CACHE, useSettings, syncedPart } from "@/store/settings";
|
import { PAINTED_FROM_CACHE, useSettings, syncedPart } from "@/store/settings";
|
||||||
import { armSettingsSync, loadRemoteSettings, queueSettingsPush, settingsAlreadyLoadedFor, settingsSyncAvailable } from "@/lib/settingsSync";
|
import { armSettingsSync, loadRemoteSettings, queueSettingsPush, settingsAlreadyLoadedFor, settingsSyncAvailable } from "@/lib/settingsSync";
|
||||||
import { loadSettingsPolicy } from "@/lib/settingsPolicy";
|
import { loadSettingsPolicy } from "@/lib/settingsPolicy";
|
||||||
@@ -263,6 +264,21 @@ function AuthedApp() {
|
|||||||
});
|
});
|
||||||
}, [inboxUnread, appName]);
|
}, [inboxUnread, appName]);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 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 languageVersion = useLanguageVersion();
|
||||||
|
useEffect(() => {
|
||||||
|
void publishWorkerFacts(accountId, archiveId);
|
||||||
|
}, [accountId, archiveId, languageVersion]);
|
||||||
|
|
||||||
// Request notification permission lazily when enabled
|
// Request notification permission lazily when enabled
|
||||||
const notif = useSettings((s) => s.settings.desktopNotifications);
|
const notif = useSettings((s) => s.settings.desktopNotifications);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { publishWorkerFacts, FACTS_KEY, type WorkerFacts } from "@/lib/swFacts";
|
||||||
|
import { SW_CACHE_NAME } from "@/lib/swCache";
|
||||||
|
import { setCatalog } from "@/lib/i18n";
|
||||||
|
import { catalog as de } from "@/locales/de";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The briefing is the only thing standing between a notification action and a
|
||||||
|
* button labelled in a language the reader does not use — the worker is plain
|
||||||
|
* JavaScript outside the bundle and cannot reach a catalogue.
|
||||||
|
*
|
||||||
|
* It is also the only place the archive mailbox is named, and getting that
|
||||||
|
* wrong does not fail visibly: a message would be filed somewhere, just not
|
||||||
|
* where Archive means.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function fakeCaches() {
|
||||||
|
const store = new Map<string, string>();
|
||||||
|
const cache = {
|
||||||
|
put: vi.fn(async (key: string, res: Response) => void store.set(key, await res.text())),
|
||||||
|
match: vi.fn(async (key: string) => (store.has(key) ? new Response(store.get(key)) : undefined)),
|
||||||
|
delete: vi.fn(async () => true),
|
||||||
|
};
|
||||||
|
// Only the worker's own cache: a briefing put anywhere else is one the
|
||||||
|
// worker will never read.
|
||||||
|
const other = { put: vi.fn(), match: vi.fn(), delete: vi.fn() };
|
||||||
|
vi.stubGlobal("caches", { open: vi.fn(async (name: string) => (name === SW_CACHE_NAME ? cache : other)) });
|
||||||
|
return { store, cache };
|
||||||
|
}
|
||||||
|
|
||||||
|
const written = (store: Map<string, string>) => JSON.parse(store.get(FACTS_KEY)!) as WorkerFacts;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
setCatalog("en", { strings: {}, plurals: {} });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the worker's briefing", () => {
|
||||||
|
it("names the account and the archive mailbox", async () => {
|
||||||
|
const { store } = fakeCaches();
|
||||||
|
await publishWorkerFacts("a1", "mb-archive");
|
||||||
|
const facts = written(store);
|
||||||
|
expect(facts.accountId).toBe("a1");
|
||||||
|
expect(facts.archiveId).toBe("mb-archive");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("carries the worker's text in the language the tab is in", async () => {
|
||||||
|
// The worker has no catalogue. Everything it will say has to be said here
|
||||||
|
// first, or a German reader gets English buttons on their lock screen.
|
||||||
|
setCatalog("de", de);
|
||||||
|
const { store } = fakeCaches();
|
||||||
|
await publishWorkerFacts("a1", "mb-archive");
|
||||||
|
const facts = written(store);
|
||||||
|
expect(facts.strings.archive).toBe("Archivieren");
|
||||||
|
expect(facts.strings.markRead).toBe("Als gelesen markieren");
|
||||||
|
expect(facts.strings.newMail).toBe("Neue E-Mail");
|
||||||
|
expect(facts.strings.noSubject).toBe("(kein Betreff)");
|
||||||
|
expect(facts.strings.failed).not.toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says so when there is no archive folder, rather than inventing one", async () => {
|
||||||
|
// The worker draws no Archive button on a null. An account without an
|
||||||
|
// archive is not a reason to file mail somewhere else.
|
||||||
|
const { store } = fakeCaches();
|
||||||
|
await publishWorkerFacts("a1", null);
|
||||||
|
expect(written(store).archiveId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes nothing before there is an account", async () => {
|
||||||
|
const { cache } = fakeCaches();
|
||||||
|
await publishWorkerFacts(null, null);
|
||||||
|
expect(cache.put).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not throw where the browser has no cache storage", async () => {
|
||||||
|
vi.stubGlobal("caches", undefined);
|
||||||
|
await expect(publishWorkerFacts("a1", "mb-archive")).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("carries every string the worker looks up", async () => {
|
||||||
|
// The worker reads these by name and shows `undefined` for a missing one,
|
||||||
|
// which is the kind of thing that only appears on somebody's lock screen.
|
||||||
|
const { store } = fakeCaches();
|
||||||
|
await publishWorkerFacts("a1", "mb-archive");
|
||||||
|
const facts = written(store);
|
||||||
|
for (const k of ["newMail", "newMessage", "noSubject", "archive", "markRead", "failed"] as const) {
|
||||||
|
expect(facts.strings[k], `missing ${k}`).toBeTruthy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
/*
|
||||||
|
* What the service worker cannot work out for itself.
|
||||||
|
*
|
||||||
|
* The worker can act on mail — see the note on `jmap()` in sw.js — but it
|
||||||
|
* cannot read a catalogue or a store. It is plain JavaScript copied into the
|
||||||
|
* build, outside the bundle, with no i18n and no idea which mailbox is the
|
||||||
|
* archive. Both of those are things a tab knows and can simply write down.
|
||||||
|
*
|
||||||
|
* So the app leaves a short briefing in the same cache it uses for every other
|
||||||
|
* handoff, and the worker reads it when a notification arrives. Where there is
|
||||||
|
* none, the worker offers no actions at all rather than guessing: an untitled
|
||||||
|
* button that files mail somewhere is worse than a notification you have to
|
||||||
|
* open.
|
||||||
|
*
|
||||||
|
* That means the actions appear once ihasmail has been opened since the worker
|
||||||
|
* was installed, which is the same condition background notifications already
|
||||||
|
* carry — a push subscription has to be renewed from a tab too.
|
||||||
|
*/
|
||||||
|
import { withBase } from "./basePath";
|
||||||
|
import { SW_CACHE_NAME } from "./swCache";
|
||||||
|
import { t } from "./i18n";
|
||||||
|
|
||||||
|
export const FACTS_KEY = "/ihasmail-worker-facts";
|
||||||
|
|
||||||
|
export interface WorkerFacts {
|
||||||
|
/** The account the notifications are about. */
|
||||||
|
accountId: string;
|
||||||
|
/** Where Archive files to; null where the account has no archive folder. */
|
||||||
|
archiveId: string | null;
|
||||||
|
/** The worker's own user-visible text, in the language this tab is in. */
|
||||||
|
strings: {
|
||||||
|
newMail: string;
|
||||||
|
newMessage: string;
|
||||||
|
noSubject: string;
|
||||||
|
archive: string;
|
||||||
|
markRead: string;
|
||||||
|
failed: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write the briefing.
|
||||||
|
*
|
||||||
|
* Called again whenever what is in it could have changed — the language, the
|
||||||
|
* account, the archive folder — because it is what the worker will still be
|
||||||
|
* reading in a week's time. Rewriting it is one cache put; there is nothing to
|
||||||
|
* gain by working out whether it differs.
|
||||||
|
*/
|
||||||
|
export async function publishWorkerFacts(accountId: string | null, archiveId: string | null): Promise<void> {
|
||||||
|
if (typeof caches === "undefined" || !accountId) return;
|
||||||
|
const facts: WorkerFacts = {
|
||||||
|
accountId,
|
||||||
|
archiveId,
|
||||||
|
strings: {
|
||||||
|
newMail: t("New mail"),
|
||||||
|
newMessage: t("New message"),
|
||||||
|
noSubject: t("(no subject)"),
|
||||||
|
archive: t("Archive"),
|
||||||
|
markRead: t("Mark as read"),
|
||||||
|
failed: t("Could not do that — open ihasmail and try again"),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const cache = await caches.open(SW_CACHE_NAME);
|
||||||
|
await cache.put(withBase(FACTS_KEY), new Response(JSON.stringify(facts), { headers: { "content-type": "application/json" } }));
|
||||||
|
} catch {
|
||||||
|
/* no cache storage: the worker falls back to a notification with no actions */
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -892,6 +892,8 @@ export const catalog: Catalog = {
|
|||||||
|
|
||||||
// ── Composer status, calendar title ────────────────────────────────
|
// ── Composer status, calendar title ────────────────────────────────
|
||||||
"New message": "Neue Nachricht",
|
"New message": "Neue Nachricht",
|
||||||
|
"New mail": "Neue E-Mail",
|
||||||
|
"Could not do that — open ihasmail and try again": "Nicht möglich – öffnen Sie ihasmail und versuchen Sie es erneut",
|
||||||
"Sending…": "Wird gesendet…",
|
"Sending…": "Wird gesendet…",
|
||||||
"Saving…": "Wird gespeichert…",
|
"Saving…": "Wird gespeichert…",
|
||||||
"Error": "Fehler",
|
"Error": "Fehler",
|
||||||
|
|||||||
@@ -865,6 +865,8 @@ export const catalog: Catalog = {
|
|||||||
|
|
||||||
// ── Composer status, calendar title ────────────────────────────────
|
// ── Composer status, calendar title ────────────────────────────────
|
||||||
"New message": "Mensaje nuevo",
|
"New message": "Mensaje nuevo",
|
||||||
|
"New mail": "Correo nuevo",
|
||||||
|
"Could not do that — open ihasmail and try again": "No se pudo hacer eso: abra ihasmail e inténtelo de nuevo",
|
||||||
"Sending…": "Enviando…",
|
"Sending…": "Enviando…",
|
||||||
"Saving…": "Guardando…",
|
"Saving…": "Guardando…",
|
||||||
"Error": "Error",
|
"Error": "Error",
|
||||||
|
|||||||
@@ -870,6 +870,8 @@ export const catalog: Catalog = {
|
|||||||
|
|
||||||
// ── Composer status, calendar title ────────────────────────────────
|
// ── Composer status, calendar title ────────────────────────────────
|
||||||
"New message": "Nouveau message",
|
"New message": "Nouveau message",
|
||||||
|
"New mail": "Nouveau courrier",
|
||||||
|
"Could not do that — open ihasmail and try again": "Impossible : ouvrez ihasmail et réessayez",
|
||||||
"Sending…": "Envoi…",
|
"Sending…": "Envoi…",
|
||||||
"Saving…": "Enregistrement…",
|
"Saving…": "Enregistrement…",
|
||||||
"Error": "Erreur",
|
"Error": "Erreur",
|
||||||
|
|||||||
@@ -873,6 +873,8 @@ export const catalog: Catalog = {
|
|||||||
|
|
||||||
// ── Composer status, calendar title ────────────────────────────────
|
// ── Composer status, calendar title ────────────────────────────────
|
||||||
"New message": "新規メール",
|
"New message": "新規メール",
|
||||||
|
"New mail": "新着メール",
|
||||||
|
"Could not do that — open ihasmail and try again": "実行できませんでした - ihasmail を開いてやり直してください",
|
||||||
"Sending…": "送信中…",
|
"Sending…": "送信中…",
|
||||||
"Saving…": "保存中…",
|
"Saving…": "保存中…",
|
||||||
"Error": "エラー",
|
"Error": "エラー",
|
||||||
|
|||||||
@@ -861,6 +861,8 @@ export const catalog: Catalog = {
|
|||||||
|
|
||||||
// ── Composer status, calendar title ────────────────────────────────
|
// ── Composer status, calendar title ────────────────────────────────
|
||||||
"New message": "Nieuw bericht",
|
"New message": "Nieuw bericht",
|
||||||
|
"New mail": "Nieuwe e-mail",
|
||||||
|
"Could not do that — open ihasmail and try again": "Dat lukte niet — open ihasmail en probeer het opnieuw",
|
||||||
"Sending…": "Bezig met verzenden…",
|
"Sending…": "Bezig met verzenden…",
|
||||||
"Saving…": "Bezig met opslaan…",
|
"Saving…": "Bezig met opslaan…",
|
||||||
"Error": "Fout",
|
"Error": "Fout",
|
||||||
|
|||||||
@@ -868,6 +868,8 @@ export const catalog: Catalog = {
|
|||||||
|
|
||||||
// ── Composer status, calendar title ────────────────────────────────
|
// ── Composer status, calendar title ────────────────────────────────
|
||||||
"New message": "Nova mensagem",
|
"New message": "Nova mensagem",
|
||||||
|
"New mail": "Novo e-mail",
|
||||||
|
"Could not do that — open ihasmail and try again": "Não foi possível fazer isso — abra o ihasmail e tente novamente",
|
||||||
"Sending…": "Enviando…",
|
"Sending…": "Enviando…",
|
||||||
"Saving…": "Salvando…",
|
"Saving…": "Salvando…",
|
||||||
"Error": "Erro",
|
"Error": "Erro",
|
||||||
|
|||||||
@@ -867,6 +867,8 @@ export const catalog: Catalog = {
|
|||||||
|
|
||||||
// ── Composer status, calendar title ────────────────────────────────
|
// ── Composer status, calendar title ────────────────────────────────
|
||||||
"New message": "Новое письмо",
|
"New message": "Новое письмо",
|
||||||
|
"New mail": "Новое письмо",
|
||||||
|
"Could not do that — open ihasmail and try again": "Не удалось — откройте ihasmail и повторите попытку",
|
||||||
"Sending…": "Отправка…",
|
"Sending…": "Отправка…",
|
||||||
"Saving…": "Сохранение…",
|
"Saving…": "Сохранение…",
|
||||||
"Error": "Ошибка",
|
"Error": "Ошибка",
|
||||||
|
|||||||
@@ -861,6 +861,8 @@ export const catalog: Catalog = {
|
|||||||
|
|
||||||
// ── Composer status, calendar title ────────────────────────────────
|
// ── Composer status, calendar title ────────────────────────────────
|
||||||
"New message": "Новий лист",
|
"New message": "Новий лист",
|
||||||
|
"New mail": "Новий лист",
|
||||||
|
"Could not do that — open ihasmail and try again": "Не вдалося — відкрийте ihasmail і повторіть спробу",
|
||||||
"Sending…": "Надсилання…",
|
"Sending…": "Надсилання…",
|
||||||
"Saving…": "Збереження…",
|
"Saving…": "Збереження…",
|
||||||
"Error": "Помилка",
|
"Error": "Помилка",
|
||||||
|
|||||||
@@ -872,6 +872,8 @@ export const catalog: Catalog = {
|
|||||||
|
|
||||||
// ── Composer status, calendar title ────────────────────────────────
|
// ── Composer status, calendar title ────────────────────────────────
|
||||||
"New message": "新邮件",
|
"New message": "新邮件",
|
||||||
|
"New mail": "新邮件",
|
||||||
|
"Could not do that — open ihasmail and try again": "无法执行 — 请打开 ihasmail 后重试",
|
||||||
"Sending…": "正在发送…",
|
"Sending…": "正在发送…",
|
||||||
"Saving…": "正在保存…",
|
"Saving…": "正在保存…",
|
||||||
"Error": "错误",
|
"Error": "错误",
|
||||||
|
|||||||
Reference in New Issue
Block a user