Group six more clusters out of web/src/lib
Takes the flat module count from 66 to 42, continuing what admin/ and
calendar/ started.
lib/mailbox/ archiveDate, emptyFolder, folderMove, labelTree,
mailboxName, mailboxRoute
lib/sieve/ sieve, sieveApply, sieveFolders
lib/input/ keyboard, swipe, touch, listSelection, dropUpload
lib/notify/ notify, webpush, webpushEnable
lib/sw/ swCache, swFacts, staleBuild
lib/text/ html, markdown, text, emlName
FOUR THINGS THE FILENAMES GET WRONG, each checked by reading the file
rather than trusting what it is called:
- appFolder is not a mailbox. It is the `ihasmail` folder in JMAP
*Files*, where the client keeps signature images and synced settings.
It stays flat.
- format holds no formatting of text. It re-exports the date and clock
formatters, so it belongs with dates/datetime, not with text/.
- preview is the file viewer deciding what it can show without
downloading, and source is where to point someone asking for this
instance's AGPL source. Neither is about text.
- notify is not Web Push. It is the tab title, the favicon badge and
the new-mail sound -- in-app notification, which is why it sits with
webpush rather than under sw/ with the service worker's own concerns.
threadScroll stays flat too: it decides where a conversation opens, which
is view state rather than a gesture, and input/ is honest only if
everything in it interprets something the reader did.
No behavior change. Almost every reference was on the @/ alias; eight
relative imports in files that did not move, or that moved away from a
sibling, needed rewriting by hand.
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { reloadIfServerRebuilt, makeConnectionWatcher, startBuildWatch } from "@/lib/sw/staleBuild";
|
||||
import { APP_VERSION } from "@/lib/version";
|
||||
|
||||
function healthReplies(body: unknown, ok = true) {
|
||||
return vi.fn().mockResolvedValue({ ok, json: async () => body } as unknown as Response);
|
||||
}
|
||||
|
||||
let reload: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
reload = vi.fn();
|
||||
Object.defineProperty(window, "location", {
|
||||
configurable: true,
|
||||
value: { ...window.location, reload },
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("reloadIfServerRebuilt", () => {
|
||||
it("reloads when the server reports a different build", async () => {
|
||||
vi.stubGlobal("fetch", healthReplies({ ok: true, version: `${APP_VERSION}-newer` }));
|
||||
expect(await reloadIfServerRebuilt()).toBe(true);
|
||||
expect(reload).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("leaves the page alone when the versions match", async () => {
|
||||
vi.stubGlobal("fetch", healthReplies({ ok: true, version: APP_VERSION }));
|
||||
expect(await reloadIfServerRebuilt()).toBe(false);
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reloads once per version, not once per 401", async () => {
|
||||
vi.stubGlobal("fetch", healthReplies({ ok: true, version: "9.9.9" }));
|
||||
expect(await reloadIfServerRebuilt()).toBe(true);
|
||||
expect(await reloadIfServerRebuilt()).toBe(false);
|
||||
expect(reload).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("clears the guard once the versions agree again", async () => {
|
||||
vi.stubGlobal("fetch", healthReplies({ ok: true, version: "9.9.9" }));
|
||||
await reloadIfServerRebuilt();
|
||||
vi.stubGlobal("fetch", healthReplies({ ok: true, version: APP_VERSION }));
|
||||
await reloadIfServerRebuilt();
|
||||
vi.stubGlobal("fetch", healthReplies({ ok: true, version: "9.9.9" }));
|
||||
expect(await reloadIfServerRebuilt()).toBe(true);
|
||||
expect(reload).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not reload when the server cannot be reached", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("offline")));
|
||||
expect(await reloadIfServerRebuilt()).toBe(false);
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not reload on a bad response or a missing version", async () => {
|
||||
vi.stubGlobal("fetch", healthReplies({ ok: true, version: "9.9.9" }, false));
|
||||
expect(await reloadIfServerRebuilt()).toBe(false);
|
||||
vi.stubGlobal("fetch", healthReplies({ ok: true }));
|
||||
expect(await reloadIfServerRebuilt()).toBe(false);
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("noticing without being asked", () => {
|
||||
it("checks when the push stream drops, but not before it has connected", async () => {
|
||||
const fetchMock = healthReplies({ ok: true, version: APP_VERSION });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const onState = makeConnectionWatcher();
|
||||
|
||||
// never connected: a disconnect is not news
|
||||
onState("connecting");
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
|
||||
onState("connected");
|
||||
onState("connecting");
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("asks the server once when several things notice at the same moment", async () => {
|
||||
const fetchMock = healthReplies({ ok: true, version: APP_VERSION });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
await Promise.all([reloadIfServerRebuilt(), reloadIfServerRebuilt(), reloadIfServerRebuilt()]);
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe("the poll is what the guarantee rests on", () => {
|
||||
it("checks on its own while the tab is visible, with nobody touching it", async () => {
|
||||
vi.useFakeTimers();
|
||||
const fetchMock = healthReplies({ ok: true, version: "9.9.9" });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
Object.defineProperty(document, "visibilityState", { configurable: true, get: () => "visible" });
|
||||
|
||||
startBuildWatch();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("leaves a hidden tab alone until it is looked at", async () => {
|
||||
vi.useFakeTimers();
|
||||
const fetchMock = healthReplies({ ok: true, version: APP_VERSION });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
let visibility = "hidden";
|
||||
Object.defineProperty(document, "visibilityState", { configurable: true, get: () => visibility });
|
||||
|
||||
startBuildWatch();
|
||||
await vi.advanceTimersByTimeAsync(180_000);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
|
||||
visibility = "visible";
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { publishWorkerFacts, FACTS_KEY, type WorkerFacts } from "@/lib/sw/swFacts";
|
||||
import { SW_CACHE_NAME } from "@/lib/sw/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 labeled in a language the reader does not use — the worker is plain
|
||||
* JavaScript outside the bundle and cannot reach a catalog.
|
||||
*
|
||||
* 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 catalog. 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,150 @@
|
||||
import { APP_VERSION } from "../version";
|
||||
import { withBase } from "../basePath";
|
||||
import { push, type PushState } from "@/jmap/push";
|
||||
|
||||
/**
|
||||
* Reload the page when the server is serving a build this one did not come
|
||||
* from.
|
||||
*
|
||||
* Signing out and picking up a new version are separate things, and only the
|
||||
* first happens on its own. An immutable instance holds sessions in memory, so
|
||||
* a deploy signs everyone out -- but the tab that was open still has the old
|
||||
* bundle in it, and a 401 only swaps the view to the sign-in form. The old
|
||||
* JavaScript would go on talking to the new server until someone happened to
|
||||
* reload by hand.
|
||||
*
|
||||
* `index.html` is served `no-cache` and the assets under it are content-hashed
|
||||
* and immutable, so a reload is all it takes; the only missing part was
|
||||
* something to ask for one. Comparing versions rather than reloading on every
|
||||
* 401 means an ordinary session expiry still lands on the sign-in form with the
|
||||
* page intact -- only a build that actually moved costs the page.
|
||||
*
|
||||
* The reload is unconditional once the versions differ. A compose window can
|
||||
* be holding text that never reached the server, and after a deploy it cannot
|
||||
* be saved either, since the session went with the container -- so this will
|
||||
* sometimes take an unsent draft with it. That is a deliberate trade: a tab
|
||||
* running code the server no longer speaks is the worse failure, and one that
|
||||
* stays behind because someone left a draft open is not automatic at all.
|
||||
*/
|
||||
const TRIED_KEY = "ihasmail:reloaded-for";
|
||||
|
||||
/** sessionStorage throws outright in some privacy modes; treat that as absent. */
|
||||
function tried(): string | null {
|
||||
try {
|
||||
return sessionStorage.getItem(TRIED_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function remember(version: string): void {
|
||||
try {
|
||||
sessionStorage.setItem(TRIED_KEY, version);
|
||||
} catch {
|
||||
/* nothing to do: the guard below is best-effort */
|
||||
}
|
||||
}
|
||||
|
||||
function forget(): void {
|
||||
try {
|
||||
sessionStorage.removeItem(TRIED_KEY);
|
||||
} catch {
|
||||
/* as above */
|
||||
}
|
||||
}
|
||||
|
||||
let inFlight: Promise<boolean> | null = null;
|
||||
|
||||
/**
|
||||
* True when a reload has been asked for and the caller should leave the page
|
||||
* alone. False for every other outcome, including not being able to tell --
|
||||
* failing to reach the server is not a reason to throw away what is on screen.
|
||||
*/
|
||||
export function reloadIfServerRebuilt(): Promise<boolean> {
|
||||
// Several things can notice a deploy at once -- the stream dropping and the
|
||||
// request that follows it -- and they should not each ask the server.
|
||||
inFlight ??= check().finally(() => {
|
||||
inFlight = null;
|
||||
});
|
||||
return inFlight;
|
||||
}
|
||||
|
||||
async function check(): Promise<boolean> {
|
||||
let serverVersion: string;
|
||||
try {
|
||||
const res = await fetch(withBase("/api/health"), { credentials: "same-origin", cache: "no-store" });
|
||||
if (!res.ok) return false;
|
||||
const body = (await res.json()) as { version?: unknown };
|
||||
if (typeof body.version !== "string" || !body.version) return false;
|
||||
serverVersion = body.version;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (serverVersion === APP_VERSION) {
|
||||
// Back in step, either because nothing changed or because an earlier
|
||||
// reload worked. Clear the guard so the next deploy is not mistaken for
|
||||
// one already attempted.
|
||||
forget();
|
||||
return false;
|
||||
}
|
||||
// Reloading once per version, not once per 401: if the new bundle somehow
|
||||
// still reports the old version -- a stale proxy cache, a half-finished
|
||||
// deploy -- this stops the two of them reloading each other in a loop.
|
||||
if (tried() === serverVersion) return false;
|
||||
remember(serverVersion);
|
||||
window.location.reload();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch for a deploy without waiting to be asked.
|
||||
*
|
||||
* Checking on a 401 alone was not automatic, only deferred: it needs the tab to
|
||||
* make a request, so one sitting idle keeps running the old build until someone
|
||||
* touches it.
|
||||
*
|
||||
* The obvious signal turned out to be the wrong one. A deploy kills the
|
||||
* EventSource behind `/api/events`, which looks like the perfect cue -- except
|
||||
* it arrives while the container is still being replaced, so the check that
|
||||
* follows cannot reach the server. Waiting for the stream to come back instead
|
||||
* does not work either: the session died with the old container, so the
|
||||
* reconnect is answered with a 401 and never reaches "connected" at all. The
|
||||
* drop is kept below because it is free and sometimes lands early enough to be
|
||||
* useful, but nothing depends on it.
|
||||
*
|
||||
* What the guarantee rests on is a slow poll while the tab is visible, plus a
|
||||
* check when it becomes visible again. Neither cares what the stream is doing
|
||||
* or whether anyone is at the keyboard: a tab left open through a deploy
|
||||
* notices within a minute, and a backgrounded one notices the moment it is
|
||||
* looked at. `/api/health` touches nothing upstream, so the cost is one small
|
||||
* request a minute per open tab.
|
||||
*/
|
||||
const POLL_MS = 60_000;
|
||||
|
||||
export function makeConnectionWatcher(): (state: PushState) => void {
|
||||
let wasConnected = false;
|
||||
return (state) => {
|
||||
if (state === "connected") {
|
||||
wasConnected = true;
|
||||
return;
|
||||
}
|
||||
// Only a drop is news. Never having connected is not evidence of anything.
|
||||
if (!wasConnected) return;
|
||||
wasConnected = false;
|
||||
void reloadIfServerRebuilt();
|
||||
};
|
||||
}
|
||||
|
||||
export function startBuildWatch(): void {
|
||||
push.onConnection(makeConnectionWatcher());
|
||||
|
||||
window.setInterval(() => {
|
||||
// A hidden tab is not being read, and will be checked when it surfaces.
|
||||
if (document.visibilityState === "visible") void reloadIfServerRebuilt();
|
||||
}, POLL_MS);
|
||||
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.visibilityState === "visible") void reloadIfServerRebuilt();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* The name of the cache the service worker keeps.
|
||||
*
|
||||
* It is `VERSION` in `web/public/sw.js`, and the worker is not built from this
|
||||
* source -- it is copied to `dist` verbatim, so nothing checks that the two
|
||||
* agree. They have to: the worker uses that cache to leave things for a tab to
|
||||
* collect when there was no tab to hand them to, and a name that has drifted
|
||||
* does not fail, it silently finds nothing. A push verification never
|
||||
* completes; a share arrives at an empty composer.
|
||||
*
|
||||
* One copy on this side of the line, so at least the app cannot disagree with
|
||||
* itself.
|
||||
*/
|
||||
export const SW_CACHE_NAME = "ihasmail-v2";
|
||||
@@ -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 catalog 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 */
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user