diff --git a/frontend/public/sw.js b/frontend/public/sw.js index 8fca783..2288ad1 100644 --- a/frontend/public/sw.js +++ b/frontend/public/sw.js @@ -377,8 +377,33 @@ self.addEventListener("fetch", (event) => { if (STATIC_RE.test(url.pathname)) event.respondWith(staticFile(req)); }); +/** + * Pre-loading from the page (lib/offlinePrefetch.ts): fetch one URL and save it, + * answering on the message port with { status, body } — status 0 when the + * server could not be reached. By message rather than by the page's own fetch, + * so it works for a page this worker does not control (a hard reload, or the + * page that just turned offline access on). + */ +async function fetchForPage(url, port) { + const req = new Request(new URL(url, self.location.origin).href, { credentials: "same-origin" }); + const cls = classify(new URL(req.url)); + try { + const res = await network(req, API_TIMEOUT); + offline = false; + if (res.status === 401) await purge(); + else if (cls && res.status === 200) await save(req, res.clone(), cls); + port.postMessage({ status: res.status, body: await res.text() }); + } catch { + offline = true; + port.postMessage({ status: 0 }); + } +} + self.addEventListener("message", (event) => { if (event.data?.type === "purge") event.waitUntil(purge()); + if (event.data?.type === "fetch" && event.ports[0]) { + event.waitUntil(fetchForPage(event.data.url, event.ports[0])); + } }); self.addEventListener("install", (event) => { diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index e6417d3..2c6da9a 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -96,6 +96,23 @@ export async function apiFetch(path: string, init: RequestInit = {}): Promise return data as T; } +type ThreadsOpts = { offset?: number; limit?: number; filter?: string; order?: "asc" | "desc" }; + +// Thread URLs, shared with offline pre-loading: it must save the very URLs the +// app asks for later. +export function threadsPath(view = "unified", opts?: ThreadsOpts): string { + const params = new URLSearchParams({ view }); + if (opts?.offset) params.set("offset", String(opts.offset)); + if (opts?.limit) params.set("limit", String(opts.limit)); + if (opts?.filter && opts.filter !== "all") params.set("filter", opts.filter); + if (opts?.order) params.set("order", opts.order); + return `/threads?${params.toString()}`; +} + +export function threadPath(id: string, sort: "asc" | "desc" = "asc", view = "unified"): string { + return `/threads/${id}?sort=${sort}&view=${encodeURIComponent(view)}`; +} + export const api = { adminSettings: () => apiFetch("/admin/settings"), // PUT merges: send only the fields you are changing. @@ -158,19 +175,9 @@ export const api = { uiManifest: () => apiFetch("/plugins/ui-manifest"), syncNow: () => apiFetch<{ status: string }>("/accounts/sync", { method: "POST" }), - threads: ( - view = "unified", - opts?: { offset?: number; limit?: number; filter?: string; order?: "asc" | "desc" }, - ) => { - const params = new URLSearchParams({ view }); - if (opts?.offset) params.set("offset", String(opts.offset)); - if (opts?.limit) params.set("limit", String(opts.limit)); - if (opts?.filter && opts.filter !== "all") params.set("filter", opts.filter); - if (opts?.order) params.set("order", opts.order); - return apiFetch(`/threads?${params.toString()}`); - }, + threads: (view = "unified", opts?: ThreadsOpts) => apiFetch(threadsPath(view, opts)), thread: (id: string, sort: "asc" | "desc" = "asc", view = "unified") => - apiFetch(`/threads/${id}?sort=${sort}&view=${encodeURIComponent(view)}`), + apiFetch(threadPath(id, sort, view)), search: (q: string, cursor?: string) => { const params = new URLSearchParams({ q }); diff --git a/frontend/src/lib/offline.ts b/frontend/src/lib/offline.ts index e8659aa..819e2b0 100644 --- a/frontend/src/lib/offline.ts +++ b/frontend/src/lib/offline.ts @@ -142,22 +142,14 @@ export async function startOffline(): Promise { if (regs.length) await disableOffline(); } -/** Resolves once the service worker controls this page (it saves what we fetch). */ +/** Whether the service worker is installed and running (it does the saving). */ export async function offlineReady(): Promise { if (!("serviceWorker" in navigator) || !offlineEnabled()) return false; - await navigator.serviceWorker.ready; - if (navigator.serviceWorker.controller) return true; - return new Promise((resolve) => { - const timer = window.setTimeout(() => resolve(false), 5000); - navigator.serviceWorker.addEventListener( - "controllerchange", - () => { - window.clearTimeout(timer); - resolve(true); - }, - { once: true }, - ); - }); + // Not `controller`: a page loaded with a hard reload is never controlled, + // yet the worker is there and can fetch and save on its behalf. + const timeout = new Promise((resolve) => window.setTimeout(() => resolve(null), 10_000)); + const reg = await Promise.race([navigator.serviceWorker.ready, timeout]); + return Boolean(reg?.active); } export async function enableOffline(): Promise { @@ -190,7 +182,10 @@ export async function clearOfflineData(): Promise { /* ignore */ } emitSetting(); - const sw = "serviceWorker" in navigator ? navigator.serviceWorker.controller : null; + // The registration's worker, not `controller`: after a hard reload the page + // is not controlled, but the worker (and its saved mail) is still there. + const reg = "serviceWorker" in navigator ? await navigator.serviceWorker.getRegistration() : undefined; + const sw = reg?.active ?? null; // Through the service worker when there is one: it keeps an index of what is // saved in memory, which must be reset along with the saved copies. if (sw) sw.postMessage({ type: "purge" }); diff --git a/frontend/src/lib/offlinePrefetch.ts b/frontend/src/lib/offlinePrefetch.ts index 2a42941..e94fab8 100644 --- a/frontend/src/lib/offlinePrefetch.ts +++ b/frontend/src/lib/offlinePrefetch.ts @@ -1,19 +1,66 @@ -// Pre-loading the newest Inbox conversations for offline reading. Everything is -// fetched exactly as the app would fetch it when opened — same URLs — and the -// service worker saves the answers as they pass (public/sw.js). Bodies and -// images are fetched once; the conversation itself is fetched every time, +// Pre-loading the newest Inbox conversations for offline reading. +// +// Every request goes through the service worker by message ("fetch this and +// save it"), not through the page's own fetch: a page loaded with a hard +// reload (Ctrl+Shift+R), or the one that just turned offline access on, is not +// controlled by the service worker, and its requests would pass by it unsaved. +// The URLs are exactly the ones the app asks for when opened offline. +// +// Bodies and images are fetched once; the conversation itself every time, // since new replies, flags and moves change it. -import { api } from "./api"; -import { isOffline, isSaved, offlineReady, setLastSync } from "./offline"; +import { threadPath, threadsPath } from "./api"; +import { isOffline, isSaved, offlineReady, reportOffline, setLastSync } from "./offline"; +import type { MessageEnvelope, ThreadDetail, ThreadListOut, UserSettings } from "./types"; const INBOX = "folder:inbox"; const COUNT = 50; // the list's page size, so the first page is saved as shown const PARALLEL = 3; +const TIMEOUT_MS = 30_000; const CID_RE = /\/api\/messages\/[0-9a-fA-F-]{36}\/cid\/[^"'\s)&]+/g; +// What the app asks for when it starts: without these it cannot open offline. +const STARTUP = [ + "/auth/me", + "/accounts", + "/mailboxes", + "/mailboxes/counts", + "/settings/quota", + "/saved-searches", + "/plugins/ui-manifest", +]; + let running: Promise | null = null; +class Unreachable extends Error {} + +/** Fetch through the service worker, which saves the answer; returns its text. */ +async function viaWorker(url: string): Promise { + const reg = await navigator.serviceWorker.ready; + const worker = reg.active; + if (!worker) throw new Unreachable("no service worker"); + const reply = await new Promise<{ status: number; body?: string }>((resolve, reject) => { + const channel = new MessageChannel(); + const timer = window.setTimeout(() => reject(new Unreachable("timeout")), TIMEOUT_MS); + channel.port1.onmessage = (e) => { + window.clearTimeout(timer); + resolve(e.data); + }; + worker.postMessage({ type: "fetch", url }, [channel.port2]); + }); + if (reply.status === 0) { + reportOffline(); + throw new Unreachable("offline"); + } + return reply.status === 200 ? (reply.body ?? "") : null; +} + +async function getJson(path: string): Promise { + const body = await viaWorker(`/api${path}`); + if (body === null) throw new Error(`could not load ${path}`); + return JSON.parse(body) as T; +} + async function inPool(items: T[], n: number, fn: (item: T) => Promise): Promise { let next = 0; const worker = async () => { @@ -21,7 +68,8 @@ async function inPool(items: T[], n: number, fn: (item: T) => Promise): const item = items[next++]; try { await fn(item); - } catch { + } catch (err) { + if (err instanceof Unreachable) return; /* one conversation failing must not stop the rest */ } } @@ -29,47 +77,33 @@ async function inPool(items: T[], n: number, fn: (item: T) => Promise): await Promise.all(Array.from({ length: n }, worker)); } -async function fetchOnce(url: string): Promise { - if (await isSaved(url)) return null; - const resp = await fetch(url, { credentials: "include" }); - return resp.ok ? resp.text() : null; +async function saveMessage(m: MessageEnvelope): Promise { + const htmlUrl = `/api/messages/${m.id}/html${m.images_allowed ? "?images=1" : ""}`; + if (!(await isSaved(htmlUrl))) { + const html = await viaWorker(htmlUrl); + for (const cid of new Set(html?.match(CID_RE) ?? [])) { + if (!(await isSaved(cid))) await viaWorker(cid); + } + } + const attUrl = `/api/messages/${m.id}/attachments`; + if (m.has_attachments && !(await isSaved(attUrl))) await viaWorker(attUrl); } async function run(): Promise { if (!(await offlineReady()) || isOffline()) return; - // What the app asks for when it starts (who is signed in, accounts, folders…). - // The page fetched these once on load — possibly before the service worker - // was there to save them — so fetch them again now, or the app could not - // start offline at all. - await Promise.allSettled([ - api.me(), - api.accounts(), - api.mailboxes(), - api.quota(), - api.savedSearches(), - api.uiManifest(), - ]); - const settings = await api.settings(); + for (const path of STARTUP) await viaWorker(`/api${path}`); + const settings = await getJson("/settings"); const order = settings.thread_order ?? "asc"; // The list as the app shows it (newest first is the default), plus the other // order when that is what this device uses, so the list itself opens offline. - const list = await api.threads(INBOX, { limit: COUNT, order: "desc" }); + const list = await getJson(threadsPath(INBOX, { limit: COUNT, order: "desc" })); if (localStorage.getItem("mail.listOrder") === "oldest") { - await api.threads(INBOX, { limit: COUNT, order: "asc" }); + await viaWorker(`/api${threadsPath(INBOX, { limit: COUNT, order: "asc" })}`); } - await api.folderCounts(); await inPool(list.threads, PARALLEL, async (summary) => { - const detail = await api.thread(summary.id, order, INBOX); - for (const m of detail.messages) { - const html = await fetchOnce(`/api/messages/${m.id}/html${m.images_allowed ? "?images=1" : ""}`); - if (html) { - for (const cid of new Set(html.match(CID_RE) ?? [])) await fetchOnce(cid); - } - if (m.has_attachments && !(await isSaved(`/api/messages/${m.id}/attachments`))) { - await api.messageAttachments(m.id); - } - } + const detail = await getJson(threadPath(summary.id, order, INBOX)); + for (const m of detail.messages) await saveMessage(m); }); if (!isOffline()) setLastSync(Date.now()); } @@ -77,7 +111,7 @@ async function run(): Promise { /** Save the newest Inbox conversations; a no-op unless offline access is on. */ export function prefetchRecent(): Promise { running ??= run() - .catch(() => {}) + .catch((err) => console.warn("offline access: saving recent mail failed", err)) .finally(() => { running = null; });