Merge pull request #308 from Coffey-Labs/notification-actions
Archive and mark read from the notification itself
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