Offline access: read recent mail without a connection
An opt-in per device (Settings → Security → Offline access) installs a service worker that keeps the newest 50 Inbox conversations, and the ones opened recently, for reading when the server cannot be reached. While online everything still comes from the server; the saved copy is only the fallback, marked X-WM-Offline so the app knows it is offline. Offline, an amber cloud next to the theme toggle says so and when mail was last saved; actions that need the server are greyed out and say "Not available offline"; conversations and folders not saved here are faded or say so. A probe notices the server coming back, shows "Back online" and refreshes everything. Saved mail is wiped on sign-out, on any 401, and when another user signs in. nginx never caches /sw.js, so releases reach it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -76,3 +76,10 @@ location = /healthz {
|
||||
location = /index.html {
|
||||
add_header Cache-Control "no-store, must-revalidate";
|
||||
}
|
||||
|
||||
# The offline-access service worker (frontend/public/sw.js): a fixed URL the
|
||||
# browser re-checks for a new version, so it must not be cached either.
|
||||
location = /sw.js {
|
||||
add_header Cache-Control "no-cache";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,409 @@
|
||||
// Offline access (Settings → Security → "Keep recent mail on this device").
|
||||
//
|
||||
// Registered only on devices where the user turned it on. While the server
|
||||
// answers, everything comes from the network exactly as before; each answer is
|
||||
// also saved here. Only when the server cannot be reached is the saved copy
|
||||
// served instead — marked with `X-WM-Offline: 1`, which tells the page to show
|
||||
// that it is offline. Saved mail is the newest Inbox conversations (fetched by
|
||||
// the page, see lib/offlinePrefetch.ts) plus the ones opened recently, at most
|
||||
// MAX_THREADS; older ones are dropped.
|
||||
//
|
||||
// Saved mail is wiped when the server answers a request with 401 (signed out,
|
||||
// session expired or revoked), when another user signs in, and on request from
|
||||
// the page (sign-out, or the setting turned off).
|
||||
//
|
||||
// Plain JavaScript, served as is from /sw.js: a service worker must live at a
|
||||
// fixed URL, so it cannot be a hashed Vite chunk.
|
||||
|
||||
const SHELL = "wm-shell-v1";
|
||||
const DATA = "wm-data-v1";
|
||||
const INDEX_KEY = "/__wm/offline-index";
|
||||
const APP_KEY = "/__wm/app";
|
||||
|
||||
const MAX_THREADS = 80; // the newest 50 Inbox conversations + recently opened ones
|
||||
const MAX_ORPHANS = 50; // message parts whose conversation was never saved
|
||||
const MAX_LISTS = 40; // thread lists (one per folder / filter / page)
|
||||
const MAX_AVATARS = 300;
|
||||
|
||||
// How long to wait for the server before falling back to the saved copy. Long
|
||||
// enough that a slow connection still gets fresh data; a missing one fails at
|
||||
// once anyway. Once a request has failed we know we are offline, and serve the
|
||||
// saved copy straight away until a request succeeds again.
|
||||
const API_TIMEOUT = 15000;
|
||||
const NAV_TIMEOUT = 8000;
|
||||
const RETRY_TIMEOUT = 4000;
|
||||
|
||||
const META = new Set([
|
||||
"/api/auth/me",
|
||||
"/api/accounts",
|
||||
"/api/mailboxes",
|
||||
"/api/mailboxes/counts",
|
||||
"/api/settings",
|
||||
"/api/settings/quota",
|
||||
"/api/saved-searches",
|
||||
"/api/plugins/ui-manifest",
|
||||
]);
|
||||
const ID = "[0-9a-fA-F-]{36}";
|
||||
const THREAD_RE = new RegExp(`^/api/threads/(${ID})$`);
|
||||
const MESSAGE_RE = new RegExp(`^/api/messages/(${ID})/(?:html|attachments|cid/.+)$`);
|
||||
|
||||
/** What a request is, for saving it; null = never saved (search, downloads, …). */
|
||||
function classify(url) {
|
||||
const p = url.pathname;
|
||||
if (META.has(p)) return { kind: "meta" };
|
||||
if (p === "/api/threads") return { kind: "list" };
|
||||
if (p === "/api/avatar") return { kind: "avatar" };
|
||||
let m = p.match(THREAD_RE);
|
||||
if (m) return { kind: "thread", id: m[1] };
|
||||
m = p.match(MESSAGE_RE);
|
||||
if (m) return { kind: "message", id: m[1] };
|
||||
return null;
|
||||
}
|
||||
|
||||
let offline = false;
|
||||
|
||||
// ---- the index: what is saved, and when it was last fetched ------------------
|
||||
|
||||
const emptyIndex = () => ({ user: null, threads: {}, msgs: {}, lists: {}, avatars: {} });
|
||||
let index = null;
|
||||
let chain = Promise.resolve();
|
||||
|
||||
/** Run `fn` on the index, one at a time (fetches finish concurrently). */
|
||||
function withIndex(fn) {
|
||||
const run = chain.then(async () => {
|
||||
if (!index) {
|
||||
try {
|
||||
const hit = await (await caches.open(DATA)).match(INDEX_KEY);
|
||||
index = hit ? await hit.json() : emptyIndex();
|
||||
} catch {
|
||||
index = emptyIndex();
|
||||
}
|
||||
}
|
||||
const changed = await fn(index);
|
||||
if (changed !== false) {
|
||||
const cache = await caches.open(DATA);
|
||||
await cache.put(
|
||||
INDEX_KEY,
|
||||
new Response(JSON.stringify(index), { headers: { "Content-Type": "application/json" } }),
|
||||
);
|
||||
}
|
||||
});
|
||||
chain = run.catch(() => {});
|
||||
return run;
|
||||
}
|
||||
|
||||
/** Forget everything saved (signed out, another user, setting turned off). */
|
||||
function purge() {
|
||||
const run = chain.then(async () => {
|
||||
index = emptyIndex();
|
||||
await caches.delete(DATA);
|
||||
});
|
||||
chain = run.catch(() => {});
|
||||
return run;
|
||||
}
|
||||
|
||||
function oldestFirst(record) {
|
||||
return Object.entries(record).sort((a, b) => a[1] - b[1]);
|
||||
}
|
||||
|
||||
/** Record a saved response and drop whatever no longer fits. */
|
||||
async function record(ix, url, cls, body) {
|
||||
const now = Date.now();
|
||||
if (cls.kind === "thread") {
|
||||
let ids = [];
|
||||
try {
|
||||
ids = JSON.parse(new TextDecoder().decode(body)).messages.map((m) => m.id);
|
||||
} catch {
|
||||
/* not the shape we know: keep the thread, without its messages */
|
||||
}
|
||||
ix.threads[cls.id] = { at: now, msgs: ids };
|
||||
for (const id of ids) ix.msgs[id] = { t: cls.id, at: now };
|
||||
} else if (cls.kind === "message") {
|
||||
ix.msgs[cls.id] = { t: ix.msgs[cls.id]?.t ?? null, at: now };
|
||||
} else if (cls.kind === "list") {
|
||||
ix.lists[url.pathname + url.search] = now;
|
||||
} else if (cls.kind === "avatar") {
|
||||
ix.avatars[url.pathname + url.search] = now;
|
||||
}
|
||||
return trim(ix);
|
||||
}
|
||||
|
||||
async function trim(ix) {
|
||||
const dropThreads = new Set();
|
||||
const dropMsgs = new Set();
|
||||
const dropUrls = new Set();
|
||||
|
||||
const threads = Object.entries(ix.threads).sort((a, b) => b[1].at - a[1].at);
|
||||
for (const [id] of threads.slice(MAX_THREADS)) dropThreads.add(id);
|
||||
const orphans = [];
|
||||
for (const [id, m] of Object.entries(ix.msgs)) {
|
||||
if (m.t && (dropThreads.has(m.t) || !ix.threads[m.t])) dropMsgs.add(id);
|
||||
else if (!m.t) orphans.push([id, m.at]);
|
||||
}
|
||||
orphans.sort((a, b) => b[1] - a[1]);
|
||||
for (const [id] of orphans.slice(MAX_ORPHANS)) dropMsgs.add(id);
|
||||
for (const [saved, max] of [
|
||||
[ix.lists, MAX_LISTS],
|
||||
[ix.avatars, MAX_AVATARS],
|
||||
]) {
|
||||
const all = oldestFirst(saved);
|
||||
for (const [u] of all.slice(0, Math.max(0, all.length - max))) {
|
||||
dropUrls.add(u);
|
||||
delete saved[u];
|
||||
}
|
||||
}
|
||||
if (!dropThreads.size && !dropMsgs.size && !dropUrls.size) return;
|
||||
|
||||
for (const id of dropThreads) delete ix.threads[id];
|
||||
for (const id of dropMsgs) delete ix.msgs[id];
|
||||
const cache = await caches.open(DATA);
|
||||
for (const req of await cache.keys()) {
|
||||
const u = new URL(req.url);
|
||||
const p = u.pathname;
|
||||
const t = p.match(THREAD_RE);
|
||||
const m = p.match(MESSAGE_RE);
|
||||
if (
|
||||
dropUrls.has(p + u.search) ||
|
||||
(t && dropThreads.has(t[1])) ||
|
||||
(m && dropMsgs.has(m[1]))
|
||||
) {
|
||||
await cache.delete(req);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- saving and serving ------------------------------------------------------
|
||||
|
||||
/** Save a copy of a server answer (`res` is a clone the page never reads). */
|
||||
async function save(req, res, cls) {
|
||||
const url = new URL(req.url);
|
||||
const body = await res.arrayBuffer();
|
||||
if (cls.kind === "meta" && url.pathname === "/api/auth/me") {
|
||||
// Another user signed in on this browser: the saved mail is not theirs.
|
||||
let id = null;
|
||||
try {
|
||||
id = JSON.parse(new TextDecoder().decode(body)).id ?? null;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
await withIndex(async (ix) => {
|
||||
if (ix.user === id) return false;
|
||||
if (ix.user && id) {
|
||||
Object.assign(ix, emptyIndex());
|
||||
await caches.delete(DATA);
|
||||
}
|
||||
ix.user = id;
|
||||
});
|
||||
}
|
||||
const headers = new Headers(res.headers);
|
||||
headers.set("X-WM-Saved-At", String(Date.now()));
|
||||
const cache = await caches.open(DATA);
|
||||
await cache.put(url.href, new Response(body, { status: 200, headers }));
|
||||
if (cls.kind !== "meta") await withIndex((ix) => record(ix, url, cls, body));
|
||||
}
|
||||
|
||||
/** The saved copy for a request; conversations also match with other options. */
|
||||
async function lookup(req, cls) {
|
||||
const cache = await caches.open(DATA);
|
||||
const hit = await cache.match(req.url, { ignoreVary: true });
|
||||
if (hit || (cls.kind !== "thread" && cls.kind !== "message")) return hit;
|
||||
// The same conversation opened from another folder or in the other order, or
|
||||
// a body asked for with/without remote images: still the same mail.
|
||||
return cache.match(req.url, { ignoreVary: true, ignoreSearch: true });
|
||||
}
|
||||
|
||||
function fromSaved(hit) {
|
||||
const headers = new Headers(hit.headers);
|
||||
headers.set("X-WM-Offline", "1");
|
||||
return new Response(hit.body, { status: hit.status, statusText: hit.statusText, headers });
|
||||
}
|
||||
|
||||
function notSaved() {
|
||||
return new Response(
|
||||
JSON.stringify({ error: { code: "offline", message: "Not available offline" } }),
|
||||
{ status: 503, headers: { "Content-Type": "application/json", "X-WM-Offline": "1" } },
|
||||
);
|
||||
}
|
||||
|
||||
async function network(req, ms) {
|
||||
if (req.mode === "navigate") {
|
||||
// A page load cannot be re-issued with a signal: just stop waiting for it.
|
||||
let timer;
|
||||
const late = new Promise((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error("timeout")), ms);
|
||||
});
|
||||
try {
|
||||
return await Promise.race([fetch(req), late]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
const ctrl = new AbortController();
|
||||
const timer = setTimeout(() => ctrl.abort(), ms);
|
||||
try {
|
||||
return await fetch(req, { signal: ctrl.signal });
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function fromServer(event, req, cls, ms) {
|
||||
const res = await network(req, ms);
|
||||
offline = false;
|
||||
if (res.status === 401) event.waitUntil(purge());
|
||||
// Copied now, before the page starts reading the original.
|
||||
else if (cls && res.status === 200) event.waitUntil(save(req, res.clone(), cls).catch(() => {}));
|
||||
return res;
|
||||
}
|
||||
|
||||
async function apiData(event, req, cls) {
|
||||
if (offline) {
|
||||
const hit = await lookup(req, cls);
|
||||
if (hit) return fromSaved(hit);
|
||||
// Not saved — and perhaps the connection is back: one quick try.
|
||||
try {
|
||||
return await fromServer(event, req, cls, RETRY_TIMEOUT);
|
||||
} catch {
|
||||
return notSaved();
|
||||
}
|
||||
}
|
||||
try {
|
||||
return await fromServer(event, req, cls, API_TIMEOUT);
|
||||
} catch {
|
||||
offline = true;
|
||||
const hit = await lookup(req, cls);
|
||||
return hit ? fromSaved(hit) : notSaved();
|
||||
}
|
||||
}
|
||||
|
||||
/** Every other API request: straight to the server, noting whether it answered. */
|
||||
async function apiOther(event, req) {
|
||||
try {
|
||||
const res = await fetch(req);
|
||||
offline = false;
|
||||
if (res.status === 401) event.waitUntil(purge());
|
||||
return res;
|
||||
} catch {
|
||||
offline = true;
|
||||
return notSaved();
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the app itself ----------------------------------------------------------
|
||||
|
||||
/** The files an app page loads up front (its scripts, styles and icons). */
|
||||
function referencedAssets(html) {
|
||||
const out = new Set();
|
||||
for (const m of html.matchAll(/(?:src|href)="(\/[^"]+\.(?:js|css|svg|png|ico|woff2?))"/g)) {
|
||||
out.add(m[1]);
|
||||
}
|
||||
return [...out];
|
||||
}
|
||||
|
||||
/** Keep the app page, and everything it loads, for opening without a connection. */
|
||||
async function saveApp(res) {
|
||||
const html = await res.text();
|
||||
const cache = await caches.open(SHELL);
|
||||
const previous = await cache.match(APP_KEY);
|
||||
if (previous && (await previous.text()) === html) return;
|
||||
await cache.put(APP_KEY, new Response(html, { headers: { "Content-Type": "text/html" } }));
|
||||
const wanted = referencedAssets(html);
|
||||
// A new release: its files have new names, so the old ones can go. Files
|
||||
// loaded later (lazy chunks) are saved again the first time they are used.
|
||||
for (const req of await cache.keys()) {
|
||||
const p = new URL(req.url).pathname;
|
||||
if (p.startsWith("/assets/") && !wanted.includes(p)) await cache.delete(req);
|
||||
}
|
||||
await Promise.all(
|
||||
wanted.map(async (p) => {
|
||||
if (await cache.match(p)) return;
|
||||
try {
|
||||
const r = await fetch(p);
|
||||
if (r.ok) await cache.put(p, r);
|
||||
} catch {
|
||||
/* next time */
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function appPage(event, req) {
|
||||
try {
|
||||
const res = await network(req, offline ? RETRY_TIMEOUT : NAV_TIMEOUT);
|
||||
offline = false;
|
||||
if (res.ok && (res.headers.get("Content-Type") || "").includes("text/html")) {
|
||||
event.waitUntil(saveApp(res.clone()).catch(() => {}));
|
||||
}
|
||||
return res;
|
||||
} catch {
|
||||
offline = true;
|
||||
const hit = await (await caches.open(SHELL)).match(APP_KEY);
|
||||
return hit || Response.error();
|
||||
}
|
||||
}
|
||||
|
||||
/** Hashed build files never change, so a saved copy is always right. */
|
||||
async function staticFile(req) {
|
||||
const cache = await caches.open(SHELL);
|
||||
const hit = await cache.match(req, { ignoreVary: true });
|
||||
if (hit) return hit;
|
||||
const res = await fetch(req);
|
||||
if (res.ok) await cache.put(req, res.clone());
|
||||
return res;
|
||||
}
|
||||
|
||||
const STATIC_RE = /^(?:\/assets\/|\/fonts\/|\/[^/]+\.(?:svg|png|ico)$)/;
|
||||
|
||||
function isAppPage(url) {
|
||||
const p = url.pathname;
|
||||
return !p.startsWith("/api/") && !p.startsWith("/admin") && !(p.endsWith(".html") && p !== "/index.html");
|
||||
}
|
||||
|
||||
self.addEventListener("fetch", (event) => {
|
||||
const req = event.request;
|
||||
if (req.method !== "GET") return;
|
||||
const url = new URL(req.url);
|
||||
if (url.origin !== self.location.origin) return;
|
||||
if (req.mode === "navigate") {
|
||||
if (isAppPage(url)) event.respondWith(appPage(event, req));
|
||||
return;
|
||||
}
|
||||
if (url.pathname.startsWith("/api/")) {
|
||||
if (url.pathname === "/api/ws") return;
|
||||
const cls = classify(url);
|
||||
event.respondWith(cls ? apiData(event, req, cls) : apiOther(event, req));
|
||||
return;
|
||||
}
|
||||
if (STATIC_RE.test(url.pathname)) event.respondWith(staticFile(req));
|
||||
});
|
||||
|
||||
self.addEventListener("message", (event) => {
|
||||
if (event.data?.type === "purge") event.waitUntil(purge());
|
||||
});
|
||||
|
||||
self.addEventListener("install", (event) => {
|
||||
// Save the app straight away, so it opens offline even if the page that
|
||||
// turned this on is never reloaded.
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch("/", { cache: "no-store" });
|
||||
if (res.ok) await saveApp(res);
|
||||
} catch {
|
||||
/* saved on the next visit */
|
||||
}
|
||||
await self.skipWaiting();
|
||||
})(),
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
for (const name of await caches.keys()) {
|
||||
if (name.startsWith("wm-") && name !== SHELL && name !== DATA) await caches.delete(name);
|
||||
}
|
||||
await self.clients.claim();
|
||||
})(),
|
||||
);
|
||||
});
|
||||
+25
-2
@@ -2,6 +2,9 @@ import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect } from "react";
|
||||
import { Navigate, Route, Routes, useLocation } from "react-router-dom";
|
||||
|
||||
import { OfflineController } from "./components/Offline";
|
||||
import { useT } from "./i18n";
|
||||
import { isOfflineError } from "./lib/api";
|
||||
import { useMe } from "./lib/auth";
|
||||
import { connectWs } from "./lib/ws";
|
||||
import { PluginPage } from "./plugins/PluginPage";
|
||||
@@ -14,7 +17,8 @@ import { ResetPassword } from "./routes/ResetPassword";
|
||||
import { Settings } from "./routes/settings/Settings";
|
||||
|
||||
function RequireAuth({ children }: { children: JSX.Element }) {
|
||||
const { data, isLoading, isError } = useMe();
|
||||
const { data, isLoading, isError, error, refetch } = useMe();
|
||||
const t = useT();
|
||||
const location = useLocation();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -26,10 +30,29 @@ function RequireAuth({ children }: { children: JSX.Element }) {
|
||||
if (isLoading) {
|
||||
return <div className="p-8 text-gray-500">Loading…</div>;
|
||||
}
|
||||
if (isError && isOfflineError(error)) {
|
||||
// No connection and nothing saved on this device: not a reason to sign out.
|
||||
return (
|
||||
<div className="flex h-screen flex-col items-center justify-center gap-3 bg-app-bg text-app-muted">
|
||||
<span>{t("offline.cantReach")}</span>
|
||||
<button
|
||||
className="rounded border border-app-border px-3 py-1.5 text-sm text-app-fg hover:bg-app-hover"
|
||||
onClick={() => void refetch()}
|
||||
>
|
||||
{t("common.retry")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (isError || !data) {
|
||||
return <Navigate to="/login" state={{ from: location }} replace />;
|
||||
}
|
||||
return children;
|
||||
return (
|
||||
<>
|
||||
<OfflineController />
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function App() {
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
// What the app shows and does around being offline:
|
||||
// - OfflineController (once, behind sign-in): notices the connection coming
|
||||
// back, refreshes everything then, keeps the saved mail up to date, and
|
||||
// stops clicks on buttons that need the server (`.wm-online-only`).
|
||||
// - OfflineIndicator (the sidebar header): the amber "Offline" cloud, with an
|
||||
// explanation on click, and a short green "Back online" when it returns.
|
||||
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
|
||||
|
||||
import { useT } from "../i18n";
|
||||
import {
|
||||
isOffline,
|
||||
offlineEnabled,
|
||||
reportOnline,
|
||||
savedWhen,
|
||||
startOffline,
|
||||
useLastSync,
|
||||
useOffline,
|
||||
useOfflineEnabled,
|
||||
} from "../lib/offline";
|
||||
import { prefetchRecent } from "../lib/offlinePrefetch";
|
||||
import { subscribeWs } from "../lib/ws";
|
||||
|
||||
const PROBE_MS = 10_000;
|
||||
const BACK_ONLINE_MS = 3_000;
|
||||
const REFRESH_MS = 15 * 60_000;
|
||||
|
||||
// ---- the short-lived bits of UI the two share ---------------------------------
|
||||
|
||||
type Notice = { key: "offline" | "offlineNotSaved" | "notAvailable"; seq: number } | null;
|
||||
|
||||
let notice: Notice = null;
|
||||
let backOnline = false;
|
||||
const listeners = new Set<() => void>();
|
||||
const emit = () => listeners.forEach((l) => l());
|
||||
const subscribe = (l: () => void) => {
|
||||
listeners.add(l);
|
||||
return () => {
|
||||
listeners.delete(l);
|
||||
};
|
||||
};
|
||||
let noticeTimer: number | undefined;
|
||||
let noticeSeq = 0;
|
||||
|
||||
function showNotice(key: NonNullable<Notice>["key"], ms: number) {
|
||||
notice = { key, seq: ++noticeSeq };
|
||||
emit();
|
||||
window.clearTimeout(noticeTimer);
|
||||
noticeTimer = window.setTimeout(() => {
|
||||
notice = null;
|
||||
emit();
|
||||
}, ms);
|
||||
}
|
||||
|
||||
function CloudOff({ size = 16 }: { size?: number }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M2 2l20 20" />
|
||||
<path d="M5.8 5.8A7 7 0 0 0 9 19h8.5a4.4 4.4 0 0 0 1.2-.17" />
|
||||
<path d="M21.5 15.5A4.5 4.5 0 0 0 17.5 10h-1.3A7 7 0 0 0 9.4 5.1" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function Check({ size = 16 }: { size?: number }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M5 12.5l4.5 4.5L19 7.5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- the controller ------------------------------------------------------------
|
||||
|
||||
export function OfflineController() {
|
||||
const t = useT();
|
||||
const queryClient = useQueryClient();
|
||||
const offline = useOffline();
|
||||
const enabled = useOfflineEnabled();
|
||||
const current = useSyncExternalStore(subscribe, () => notice);
|
||||
const wasOffline = useRef(false);
|
||||
|
||||
// Install (or remove) the service worker to match this device's setting.
|
||||
useEffect(() => {
|
||||
void startOffline();
|
||||
}, [enabled]);
|
||||
|
||||
// Going offline: say so once; coming back: refresh everything and save anew.
|
||||
useEffect(() => {
|
||||
if (offline) {
|
||||
wasOffline.current = true;
|
||||
showNotice(offlineEnabled() ? "offline" : "offlineNotSaved", 6000);
|
||||
return;
|
||||
}
|
||||
if (!wasOffline.current) return;
|
||||
wasOffline.current = false;
|
||||
notice = null;
|
||||
backOnline = true;
|
||||
emit();
|
||||
const timer = window.setTimeout(() => {
|
||||
backOnline = false;
|
||||
emit();
|
||||
}, BACK_ONLINE_MS);
|
||||
void queryClient.invalidateQueries();
|
||||
void prefetchRecent();
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [offline, queryClient]);
|
||||
|
||||
// While offline: ask the server now and then whether it is back.
|
||||
useEffect(() => {
|
||||
if (!offline) return;
|
||||
const probe = async () => {
|
||||
try {
|
||||
const resp = await fetch("/api/healthz", { cache: "no-store", credentials: "include" });
|
||||
if (!resp.headers.has("X-WM-Offline")) reportOnline();
|
||||
} catch {
|
||||
/* still offline */
|
||||
}
|
||||
};
|
||||
const timer = window.setInterval(probe, PROBE_MS);
|
||||
window.addEventListener("online", probe);
|
||||
return () => {
|
||||
window.clearInterval(timer);
|
||||
window.removeEventListener("online", probe);
|
||||
};
|
||||
}, [offline]);
|
||||
|
||||
// Keep the saved mail current: shortly after start, after new mail (gathered
|
||||
// up for a minute), and every quarter of an hour.
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const start = window.setTimeout(() => void prefetchRecent(), 5000);
|
||||
const every = window.setInterval(() => void prefetchRecent(), REFRESH_MS);
|
||||
let pending: number | undefined;
|
||||
const off = subscribeWs((event) => {
|
||||
if (event !== "message.new" || pending) return;
|
||||
pending = window.setTimeout(() => {
|
||||
pending = undefined;
|
||||
void prefetchRecent();
|
||||
}, 60_000);
|
||||
});
|
||||
return () => {
|
||||
window.clearTimeout(start);
|
||||
window.clearInterval(every);
|
||||
window.clearTimeout(pending);
|
||||
off();
|
||||
};
|
||||
}, [enabled]);
|
||||
|
||||
// Buttons that need the server do nothing offline, and say why.
|
||||
useEffect(() => {
|
||||
const onClick = (e: MouseEvent) => {
|
||||
if (!isOffline()) return;
|
||||
const el = (e.target as Element | null)?.closest?.(".wm-online-only");
|
||||
if (!el) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
showNotice("notAvailable", 2500);
|
||||
};
|
||||
document.addEventListener("click", onClick, true);
|
||||
return () => document.removeEventListener("click", onClick, true);
|
||||
}, []);
|
||||
|
||||
if (!current) return null;
|
||||
return (
|
||||
<div
|
||||
key={current.seq}
|
||||
role="status"
|
||||
className="wm-offline-notice pointer-events-none fixed bottom-6 left-1/2 z-[70] flex max-w-[calc(100vw-2rem)] -translate-x-1/2 items-center gap-2.5 rounded-xl border border-app-border bg-app-elevated px-4 py-2.5 text-sm text-app-fg shadow-lg"
|
||||
>
|
||||
<span className="text-amber-500">
|
||||
<CloudOff size={18} />
|
||||
</span>
|
||||
{current.key === "offline"
|
||||
? t("offline.noticeSaved")
|
||||
: current.key === "offlineNotSaved"
|
||||
? t("offline.noticeNotSaved")
|
||||
: t("offline.notAvailable")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- the sidebar indicator -----------------------------------------------------
|
||||
|
||||
export function OfflineIndicator({ compact = false }: { compact?: boolean }) {
|
||||
const t = useT();
|
||||
const offline = useOffline();
|
||||
const enabled = useOfflineEnabled();
|
||||
const back = useSyncExternalStore(subscribe, () => backOnline);
|
||||
const last = useLastSync();
|
||||
const when = savedWhen(last);
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!offline) setOpen(false);
|
||||
}, [offline]);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDoc = (e: MouseEvent) => {
|
||||
if (!ref.current?.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", onDoc);
|
||||
return () => document.removeEventListener("mousedown", onDoc);
|
||||
}, [open]);
|
||||
|
||||
if (back && !offline) {
|
||||
return (
|
||||
<span
|
||||
role="status"
|
||||
className="inline-flex h-7 items-center gap-1 rounded px-1.5 text-[13px] font-medium text-emerald-500"
|
||||
title={t("offline.backOnline")}
|
||||
>
|
||||
<Check />
|
||||
{!compact && t("offline.backOnline")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (!offline) return null;
|
||||
|
||||
const explanation = enabled
|
||||
? when
|
||||
? t("offline.explainSaved", { when })
|
||||
: t("offline.explainSavedNoTime")
|
||||
: t("offline.explainNotSaved");
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
title={explanation}
|
||||
aria-label={t("offline.label")}
|
||||
aria-expanded={open}
|
||||
className="inline-flex h-7 items-center gap-1 rounded px-1.5 text-[13px] font-medium text-amber-500 hover:bg-app-hover"
|
||||
>
|
||||
<CloudOff />
|
||||
{!compact && t("offline.label")}
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute left-0 top-full z-50 mt-1 w-64 rounded-lg border border-app-border bg-app-elevated p-3 text-[13px] leading-snug text-app-fg shadow-lg">
|
||||
<div className="mb-1 flex items-center gap-1.5 font-semibold text-amber-500">
|
||||
<CloudOff size={15} />
|
||||
{t("offline.label")}
|
||||
</div>
|
||||
{explanation}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Shown in the reading pane for a conversation that was never saved here. */
|
||||
export function NotSavedOffline() {
|
||||
const t = useT();
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 px-6 text-center text-app-faint">
|
||||
<span className="text-amber-500">
|
||||
<CloudOff size={40} />
|
||||
</span>
|
||||
<span className="text-xl">{t("offline.threadNotSaved")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useRef } from "react";
|
||||
|
||||
import { useT } from "../../i18n";
|
||||
import { api } from "../../lib/api";
|
||||
import { useSavedThreads } from "../../lib/offline";
|
||||
import { formatClock, getDateLocale } from "../../lib/timeFormat";
|
||||
import type { Address, ThreadSummary } from "../../lib/types";
|
||||
import { Icon } from "../Icon";
|
||||
@@ -137,6 +138,8 @@ export function MessageList({
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
const labelMap = labelsQuery.data ?? {};
|
||||
// Offline: conversations not saved on this device are shown faded.
|
||||
const saved = useSavedThreads();
|
||||
|
||||
// The user's own addresses, shown as "me" in the sender column.
|
||||
const accounts = useQuery({ queryKey: ["accounts"], queryFn: api.accounts });
|
||||
@@ -170,6 +173,7 @@ export function MessageList({
|
||||
const selected = selectedId === thread.id;
|
||||
const checked = checkedIds.has(thread.id);
|
||||
const dimmed = dimmedIds?.has(thread.id) ?? false;
|
||||
const unsaved = saved !== null && !saved.has(thread.id);
|
||||
// The sender column lists everyone who wrote in the thread, oldest first,
|
||||
// with the user's own accounts shown as "me" — so a thread you started
|
||||
// and someone answered reads "me, Alice". Recipients are not listed.
|
||||
@@ -206,7 +210,7 @@ export function MessageList({
|
||||
// A row leaving the list lets the ones below glide up into its place.
|
||||
className={`group absolute inset-x-0 px-2 ${compact ? "pt-5" : "pt-[7px]"} motion-safe:transition-transform motion-safe:duration-200 motion-safe:ease-out ${
|
||||
dimmed ? "pointer-events-none opacity-40" : "cursor-pointer"
|
||||
}`}
|
||||
} ${unsaved ? "opacity-45" : ""}`}
|
||||
>
|
||||
<div
|
||||
className={`flex h-full items-center gap-3 rounded-lg px-3 ${
|
||||
@@ -355,8 +359,8 @@ export function MessageList({
|
||||
onClick={stop(() => onTogglePin(thread.id, thread.pinned))}
|
||||
className={
|
||||
thread.pinned
|
||||
? "text-[rgb(220_129_143)]"
|
||||
: "invisible text-app-muted hover:text-app-fg group-hover:visible"
|
||||
? "wm-online-only text-[rgb(220_129_143)]"
|
||||
: "wm-online-only invisible text-app-muted hover:text-app-fg group-hover:visible"
|
||||
}
|
||||
>
|
||||
<Icon name="pin" size={17} />
|
||||
@@ -368,7 +372,7 @@ export function MessageList({
|
||||
type="button"
|
||||
title={t("mail.restore")}
|
||||
onClick={stop(() => onRestore(thread.id))}
|
||||
className="invisible text-app-muted hover:text-app-fg group-hover:visible"
|
||||
className="wm-online-only invisible text-app-muted hover:text-app-fg group-hover:visible"
|
||||
>
|
||||
<Icon name="restore" size={17} />
|
||||
</button>
|
||||
@@ -377,7 +381,7 @@ export function MessageList({
|
||||
type="button"
|
||||
title={t("mail.archive")}
|
||||
onClick={stop(() => onArchive(thread.id))}
|
||||
className="invisible text-app-muted hover:text-app-fg group-hover:visible"
|
||||
className="wm-online-only invisible text-app-muted hover:text-app-fg group-hover:visible"
|
||||
>
|
||||
<Icon name="archive" size={15} />
|
||||
</button>
|
||||
@@ -388,7 +392,7 @@ export function MessageList({
|
||||
onClick={stop(() =>
|
||||
folder === "trash" ? onDeletePermanent(thread.id) : onTrash(thread.id),
|
||||
)}
|
||||
className="invisible mr-[7px] text-app-muted hover:text-app-fg group-hover:visible"
|
||||
className="wm-online-only invisible mr-[7px] text-app-muted hover:text-app-fg group-hover:visible"
|
||||
>
|
||||
<Icon name="trash" size={17} />
|
||||
</button>
|
||||
|
||||
@@ -30,13 +30,14 @@ export function MessageBody({
|
||||
onMailtoRef.current = onMailto;
|
||||
const [height, setHeight] = useState(80);
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
const { data, isLoading, isError, error } = useQuery({
|
||||
queryKey: ["message-html", message.id, allowed],
|
||||
queryFn: async () => {
|
||||
// ?images=1 asks the server to render remote images for this one request
|
||||
// (the "Load images" one-off); "Always from this sender" persists separately.
|
||||
const url = `/api/messages/${message.id}/html${allowed ? "?images=1" : ""}`;
|
||||
const resp = await fetch(url, { credentials: "include" });
|
||||
if (resp.headers.has("X-WM-Offline") && !resp.ok) throw new Error("offline");
|
||||
if (!resp.ok) throw new Error("failed to load message");
|
||||
return resp.text();
|
||||
},
|
||||
@@ -170,7 +171,11 @@ export function MessageBody({
|
||||
</div>
|
||||
)}
|
||||
{isLoading && <div className="p-3 text-sm text-app-faint">{t("mail.loadingThread")}</div>}
|
||||
{isError && <div className="p-3 text-sm text-red-500">{t("mail.loadFailed")}</div>}
|
||||
{isError && (
|
||||
<div className={`p-3 text-sm ${error.message === "offline" ? "text-app-faint" : "text-red-500"}`}>
|
||||
{error.message === "offline" ? t("offline.notAvailable") : t("mail.loadFailed")}
|
||||
</div>
|
||||
)}
|
||||
{data !== undefined && !isLoading && (
|
||||
<iframe
|
||||
ref={frameRef}
|
||||
|
||||
@@ -211,7 +211,7 @@ function ActionsMenu({
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
className="flex items-center gap-1 rounded px-1.5 py-1 text-xs text-app-muted hover:bg-app-hover hover:text-app-fg"
|
||||
className="wm-online-only flex items-center gap-1 rounded px-1.5 py-1 text-xs text-app-muted hover:bg-app-hover hover:text-app-fg"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
{t("mail.actions")}
|
||||
@@ -343,7 +343,7 @@ function MessageItem({
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
const replyBtn = (kind: string, label: string, icon: string) => (
|
||||
<button
|
||||
className="flex items-center gap-1.5 rounded-md border border-app-border px-3 py-1.5 text-xs text-app-fg hover:bg-app-hover"
|
||||
className="wm-online-only flex items-center gap-1.5 rounded-md border border-app-border px-3 py-1.5 text-xs text-app-fg hover:bg-app-hover"
|
||||
onClick={() => onReply(kind)}
|
||||
>
|
||||
<Icon name={icon} size={14} />
|
||||
@@ -430,7 +430,7 @@ function MessageItem({
|
||||
title={t("mail.markReadUnread")}
|
||||
aria-label={t("mail.markReadUnread")}
|
||||
onClick={onToggleRead}
|
||||
className="grid h-4 w-4 place-items-center"
|
||||
className="wm-online-only grid h-4 w-4 place-items-center"
|
||||
>
|
||||
<span
|
||||
className={`h-3 w-3 rounded-full border ${
|
||||
@@ -442,7 +442,7 @@ function MessageItem({
|
||||
</button>
|
||||
{canReply && (
|
||||
<button
|
||||
className="flex items-center gap-1 text-app-muted hover:text-app-fg"
|
||||
className="wm-online-only flex items-center gap-1 text-app-muted hover:text-app-fg"
|
||||
onClick={() => onReply(multiRecipient ? "reply_all" : "reply")}
|
||||
>
|
||||
<Icon name={multiRecipient ? "reply-all" : "reply"} size={14} />
|
||||
|
||||
@@ -545,6 +545,27 @@ export const en = {
|
||||
"theme.dark": "Dark",
|
||||
"theme.system": "System",
|
||||
"theme.toggle": "Toggle theme",
|
||||
"offline.label": "Offline",
|
||||
"offline.backOnline": "Back online",
|
||||
"offline.explainSaved": "Offline — showing mail saved on this device, last updated {when}.",
|
||||
"offline.explainSavedNoTime": "Offline — showing mail saved on this device.",
|
||||
"offline.explainNotSaved":
|
||||
"Can't reach the server. To read recent mail without a connection, turn on offline access in Settings → Security.",
|
||||
"offline.noticeSaved": "You're offline. Recent mail is still available.",
|
||||
"offline.noticeNotSaved": "You're offline — can't reach the server.",
|
||||
"offline.notAvailable": "Not available offline",
|
||||
"offline.threadNotSaved": "This conversation isn't saved on this device.",
|
||||
"offline.folderNotSaved": "This folder isn't saved on this device.",
|
||||
"offline.searchPlaceholder": "Search (offline)",
|
||||
"offline.cantReach": "Can't reach the server.",
|
||||
"offline.settingTitle": "Offline access",
|
||||
"offline.settingHelp":
|
||||
"Keep your newest 50 Inbox conversations, and the ones you open, on this device, so you can read them without a connection. They are stored unencrypted in this browser and removed when you sign out — leave this off on shared computers.",
|
||||
"offline.settingStatus": "{count} conversations saved · {size} · updated {when}",
|
||||
"offline.settingStatusNoTime": "{count} conversations saved · {size}",
|
||||
"offline.settingSaving": "Saving recent mail…",
|
||||
"offline.settingUnsupported":
|
||||
"Not available here: browsers allow offline access only when the app is opened over HTTPS.",
|
||||
} as const;
|
||||
|
||||
export type MessageKey = keyof typeof en;
|
||||
|
||||
@@ -221,3 +221,27 @@ body {
|
||||
border: 2px solid transparent;
|
||||
background-clip: content-box;
|
||||
}
|
||||
|
||||
/* Offline (components/Offline.tsx): buttons that need the server look
|
||||
unavailable, and their clicks are stopped with a "Not available offline". */
|
||||
html[data-offline] .wm-online-only {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
html[data-offline] .wm-online-only:hover {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.wm-offline-notice {
|
||||
animation: wm-offline-in 180ms ease-out;
|
||||
}
|
||||
@keyframes wm-offline-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, 8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, 0);
|
||||
}
|
||||
}
|
||||
|
||||
+21
-6
@@ -32,6 +32,12 @@ import type {
|
||||
UserSettings,
|
||||
} from "./types";
|
||||
import type { UiManifest } from "../plugins/types";
|
||||
import { noteResponse, reportOffline } from "./offline";
|
||||
|
||||
/** The request failed only because the server could not be reached. */
|
||||
export function isOfflineError(error: unknown): boolean {
|
||||
return error instanceof ApiError && error.code === "offline";
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
code: string;
|
||||
@@ -65,12 +71,21 @@ export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
}
|
||||
const resp = await fetch(`/api${path}`, {
|
||||
...init,
|
||||
method,
|
||||
headers,
|
||||
credentials: "include",
|
||||
});
|
||||
let resp: Response;
|
||||
try {
|
||||
resp = await fetch(`/api${path}`, {
|
||||
...init,
|
||||
method,
|
||||
headers,
|
||||
credentials: "include",
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === "AbortError") throw err;
|
||||
// The server could not be reached at all (and nothing saved stood in).
|
||||
reportOffline();
|
||||
throw new ApiError(0, "offline", "Not available offline");
|
||||
}
|
||||
noteResponse(resp);
|
||||
if (resp.status === 204) return undefined as T;
|
||||
const isJson = resp.headers.get("content-type")?.includes("application/json");
|
||||
const data = isJson ? await resp.json() : await resp.text();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type QueryClient, useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { api, ApiError } from "./api";
|
||||
import { clearOfflineData } from "./offline";
|
||||
|
||||
export function useMe() {
|
||||
return useQuery({
|
||||
@@ -8,7 +9,7 @@ export function useMe() {
|
||||
queryFn: api.me,
|
||||
retry: (count, error) => {
|
||||
// Do not retry an unauthenticated response.
|
||||
if (error instanceof ApiError && error.status === 401) return false;
|
||||
if (error instanceof ApiError && (error.status === 401 || error.code === "offline")) return false;
|
||||
return count < 2;
|
||||
},
|
||||
});
|
||||
@@ -24,6 +25,7 @@ export function signedOut(queryClient: QueryClient) {
|
||||
// "me" first, in place: its watchers see it and route to sign-in. Clearing the
|
||||
// whole cache first would orphan them from the query they watch.
|
||||
queryClient.setQueryData(["me"], null);
|
||||
void clearOfflineData(); // mail saved for offline reading goes with the session
|
||||
queryClient.removeQueries({ predicate: (q) => q.queryKey[0] !== "me" });
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
// Offline access: whether the server can be reached right now, and the
|
||||
// per-device "keep recent mail on this device" setting that installs the
|
||||
// service worker (public/sw.js) which saves mail for reading without a
|
||||
// connection.
|
||||
//
|
||||
// The offline state is learned from requests themselves: a request that could
|
||||
// not reach the server, or an answer the service worker served from its saved
|
||||
// copy (marked `X-WM-Offline`), means offline; any answer from the server means
|
||||
// online. While offline, OfflineController probes the server to notice the
|
||||
// connection coming back.
|
||||
|
||||
import { useEffect, useState, useSyncExternalStore } from "react";
|
||||
|
||||
import { formatClock, getDateLocale } from "./timeFormat";
|
||||
|
||||
const ENABLED_KEY = "offline.enabled";
|
||||
const LAST_SYNC_KEY = "offline.lastSync";
|
||||
const DATA_CACHE = "wm-data-v1";
|
||||
const INDEX_KEY = "/__wm/offline-index";
|
||||
|
||||
// ---- online / offline ----------------------------------------------------------
|
||||
|
||||
let offline = false;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function emit() {
|
||||
for (const l of listeners) l();
|
||||
}
|
||||
|
||||
function subscribe(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
export function reportOffline(): void {
|
||||
if (offline) return;
|
||||
offline = true;
|
||||
document.documentElement.dataset.offline = "";
|
||||
emit();
|
||||
}
|
||||
|
||||
export function reportOnline(): void {
|
||||
if (!offline) return;
|
||||
offline = false;
|
||||
delete document.documentElement.dataset.offline;
|
||||
emit();
|
||||
}
|
||||
|
||||
export function isOffline(): boolean {
|
||||
return offline;
|
||||
}
|
||||
|
||||
export function useOffline(): boolean {
|
||||
return useSyncExternalStore(subscribe, isOffline, () => false);
|
||||
}
|
||||
|
||||
/** A response's verdict: served from the saved copy, or by the server. */
|
||||
export function noteResponse(resp: Response): void {
|
||||
if (resp.headers.has("X-WM-Offline")) reportOffline();
|
||||
else reportOnline();
|
||||
}
|
||||
|
||||
// ---- the per-device setting ----------------------------------------------------
|
||||
|
||||
/** Whether this browser can keep mail at all (service workers need HTTPS). */
|
||||
export function offlineSupported(): boolean {
|
||||
return "serviceWorker" in navigator && window.isSecureContext && "caches" in window;
|
||||
}
|
||||
|
||||
export function offlineEnabled(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(ENABLED_KEY) === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const settingListeners = new Set<() => void>();
|
||||
|
||||
function subscribeSetting(listener: () => void): () => void {
|
||||
settingListeners.add(listener);
|
||||
return () => {
|
||||
settingListeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
function emitSetting() {
|
||||
for (const l of settingListeners) l();
|
||||
}
|
||||
|
||||
export function useOfflineEnabled(): boolean {
|
||||
return useSyncExternalStore(subscribeSetting, offlineEnabled, () => false);
|
||||
}
|
||||
|
||||
/** When mail was last saved for offline reading (ms), or null. */
|
||||
export function lastSync(): number | null {
|
||||
try {
|
||||
const v = Number(localStorage.getItem(LAST_SYNC_KEY));
|
||||
return v > 0 ? v : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function useLastSync(): number | null {
|
||||
return useSyncExternalStore(subscribeSetting, lastSync, () => null);
|
||||
}
|
||||
|
||||
/** "14:32" today, "3 Oct, 14:32" before. */
|
||||
export function savedWhen(ms: number | null): string {
|
||||
if (!ms) return "";
|
||||
const d = new Date(ms);
|
||||
const sameDay = d.toDateString() === new Date().toDateString();
|
||||
if (sameDay) return formatClock(d);
|
||||
return `${d.toLocaleDateString(getDateLocale(), { day: "numeric", month: "short" })}, ${formatClock(d)}`;
|
||||
}
|
||||
|
||||
export function setLastSync(ms: number): void {
|
||||
try {
|
||||
localStorage.setItem(LAST_SYNC_KEY, String(ms));
|
||||
} catch {
|
||||
/* fine: only shown in the offline notes */
|
||||
}
|
||||
emitSetting();
|
||||
}
|
||||
|
||||
/** Install the service worker, if this device keeps mail (call on app start). */
|
||||
export async function startOffline(): Promise<void> {
|
||||
if (!("serviceWorker" in navigator)) return;
|
||||
if (offlineEnabled() && offlineSupported()) {
|
||||
try {
|
||||
await navigator.serviceWorker.register("/sw.js", { scope: "/" });
|
||||
} catch (err) {
|
||||
console.warn("offline access: service worker not installed", err);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Turned off (perhaps in another tab): make sure nothing is left behind.
|
||||
const regs = await navigator.serviceWorker.getRegistrations();
|
||||
if (regs.length) await disableOffline();
|
||||
}
|
||||
|
||||
/** Resolves once the service worker controls this page (it saves what we fetch). */
|
||||
export async function offlineReady(): Promise<boolean> {
|
||||
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 },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export async function enableOffline(): Promise<void> {
|
||||
localStorage.setItem(ENABLED_KEY, "1");
|
||||
emitSetting();
|
||||
await startOffline();
|
||||
}
|
||||
|
||||
export async function disableOffline(): Promise<void> {
|
||||
try {
|
||||
localStorage.removeItem(ENABLED_KEY);
|
||||
localStorage.removeItem(LAST_SYNC_KEY);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
emitSetting();
|
||||
if ("serviceWorker" in navigator) {
|
||||
for (const reg of await navigator.serviceWorker.getRegistrations()) await reg.unregister();
|
||||
}
|
||||
if ("caches" in window) {
|
||||
for (const name of await caches.keys()) if (name.startsWith("wm-")) await caches.delete(name);
|
||||
}
|
||||
}
|
||||
|
||||
/** Forget the saved mail (signed out); the setting itself stays on. */
|
||||
export async function clearOfflineData(): Promise<void> {
|
||||
try {
|
||||
localStorage.removeItem(LAST_SYNC_KEY);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
emitSetting();
|
||||
const sw = "serviceWorker" in navigator ? navigator.serviceWorker.controller : 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" });
|
||||
else if ("caches" in window) await caches.delete(DATA_CACHE);
|
||||
}
|
||||
|
||||
// ---- what is saved -------------------------------------------------------------
|
||||
|
||||
interface SavedIndex {
|
||||
threads: Record<string, { at: number; msgs: string[] }>;
|
||||
}
|
||||
|
||||
/** Ids of the conversations saved on this device (empty when none). */
|
||||
export async function savedThreadIds(): Promise<Set<string>> {
|
||||
if (!("caches" in window)) return new Set();
|
||||
try {
|
||||
const cache = await caches.open(DATA_CACHE);
|
||||
const hit = await cache.match(INDEX_KEY);
|
||||
if (!hit) return new Set();
|
||||
const ix = (await hit.json()) as SavedIndex;
|
||||
return new Set(Object.keys(ix.threads ?? {}));
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a response is already saved (so pre-loading can skip it). */
|
||||
export async function isSaved(url: string): Promise<boolean> {
|
||||
if (!("caches" in window)) return false;
|
||||
try {
|
||||
return Boolean(await (await caches.open(DATA_CACHE)).match(url, { ignoreVary: true }));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** While offline, the conversations saved here (null while online). */
|
||||
export function useSavedThreads(): Set<string> | null {
|
||||
const off = useOffline();
|
||||
const [saved, setSaved] = useState<Set<string> | null>(null);
|
||||
useEffect(() => {
|
||||
if (!off) {
|
||||
setSaved(null);
|
||||
return;
|
||||
}
|
||||
let live = true;
|
||||
void savedThreadIds().then((ids) => live && setSaved(ids));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [off]);
|
||||
return off ? saved : null;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// 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,
|
||||
// since new replies, flags and moves change it.
|
||||
|
||||
import { api } from "./api";
|
||||
import { isOffline, isSaved, offlineReady, setLastSync } from "./offline";
|
||||
|
||||
const INBOX = "folder:inbox";
|
||||
const COUNT = 50; // the list's page size, so the first page is saved as shown
|
||||
const PARALLEL = 3;
|
||||
const CID_RE = /\/api\/messages\/[0-9a-fA-F-]{36}\/cid\/[^"'\s)&]+/g;
|
||||
|
||||
let running: Promise<void> | null = null;
|
||||
|
||||
async function inPool<T>(items: T[], n: number, fn: (item: T) => Promise<void>): Promise<void> {
|
||||
let next = 0;
|
||||
const worker = async () => {
|
||||
while (next < items.length && !isOffline()) {
|
||||
const item = items[next++];
|
||||
try {
|
||||
await fn(item);
|
||||
} catch {
|
||||
/* one conversation failing must not stop the rest */
|
||||
}
|
||||
}
|
||||
};
|
||||
await Promise.all(Array.from({ length: n }, worker));
|
||||
}
|
||||
|
||||
async function fetchOnce(url: string): Promise<string | null> {
|
||||
if (await isSaved(url)) return null;
|
||||
const resp = await fetch(url, { credentials: "include" });
|
||||
return resp.ok ? resp.text() : null;
|
||||
}
|
||||
|
||||
async function run(): Promise<void> {
|
||||
if (!(await offlineReady()) || isOffline()) return;
|
||||
const settings = await api.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" });
|
||||
if (localStorage.getItem("mail.listOrder") === "oldest") {
|
||||
await api.threads(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);
|
||||
}
|
||||
}
|
||||
});
|
||||
if (!isOffline()) setLastSync(Date.now());
|
||||
}
|
||||
|
||||
/** Save the newest Inbox conversations; a no-op unless offline access is on. */
|
||||
export function prefetchRecent(): Promise<void> {
|
||||
running ??= run()
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
running = null;
|
||||
});
|
||||
return running;
|
||||
}
|
||||
@@ -24,12 +24,14 @@ import { Resizer } from "../components/Resizer";
|
||||
import { ThemeToggle } from "../components/ThemeToggle";
|
||||
import { EmptyIllustration } from "../components/EmptyIllustration";
|
||||
import { BackfillBanner } from "../components/BackfillBanner";
|
||||
import { NotSavedOffline, OfflineIndicator } from "../components/Offline";
|
||||
import { ThreadView } from "../components/thread/ThreadView";
|
||||
import { MailboxDisplay } from "../components/mail/MailboxDisplay";
|
||||
import { RightPanel, type PanelTab } from "../components/mail/RightPanel";
|
||||
import { useT } from "../i18n";
|
||||
import type { MessageKey } from "../i18n/en";
|
||||
import { api } from "../lib/api";
|
||||
import { api, isOfflineError } from "../lib/api";
|
||||
import { clearOfflineData, useOffline } from "../lib/offline";
|
||||
import { setTimeFormat } from "../lib/timeFormat";
|
||||
import type { QuotaOut, SavedSearch, ThreadSummary } from "../lib/types";
|
||||
import { useReadingPane } from "../lib/readingPane";
|
||||
@@ -71,6 +73,7 @@ export function MailLayout() {
|
||||
const match = useMatch("/mail/t/:threadId");
|
||||
const threadId = match?.params.threadId;
|
||||
const [view, setView] = useState<string>("folder:inbox");
|
||||
const offline = useOffline();
|
||||
// Smartphone layout: one column at a time — the list, or the reading pane full
|
||||
// screen — with the sidebar as a slide-in drawer. Desktop is untouched by it.
|
||||
const mobile = useIsMobile();
|
||||
@@ -789,12 +792,13 @@ export function MailLayout() {
|
||||
<Icon name={collapsed ? "show" : "hide"} size={16} />
|
||||
</button>
|
||||
)}
|
||||
<OfflineIndicator compact={collapsed || mobile} />
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
<div className={`pb-2 pt-2.5 ${collapsed ? "px-2" : "px-3"} ${mobile ? "hidden" : ""}`}>
|
||||
<button
|
||||
className="flex w-full items-center justify-center gap-2 rounded-lg bg-app-accent py-2 text-[15px] font-medium text-app-accent-fg shadow-sm hover:opacity-90"
|
||||
className="wm-online-only flex w-full items-center justify-center gap-2 rounded-lg bg-app-accent py-2 text-[15px] font-medium text-app-accent-fg shadow-sm hover:opacity-90"
|
||||
title={collapsed ? t("nav.compose") : undefined}
|
||||
onClick={() => accounts[0] && openComposer({ accountId: accounts[0].id, kind: "new" })}
|
||||
>
|
||||
@@ -903,12 +907,13 @@ export function MailLayout() {
|
||||
{!collapsed && t("nav.settings")}
|
||||
</Link>
|
||||
<button
|
||||
className={`flex w-full items-center gap-2.5 rounded-lg py-2 text-left text-app-muted hover:bg-app-hover hover:text-app-fg ${
|
||||
className={`wm-online-only flex w-full items-center gap-2.5 rounded-lg py-2 text-left text-app-muted hover:bg-app-hover hover:text-app-fg ${
|
||||
collapsed ? "justify-center px-0" : "px-3"
|
||||
}`}
|
||||
title={collapsed ? t("common.signOut") : undefined}
|
||||
onClick={async () => {
|
||||
await api.logout();
|
||||
await clearOfflineData();
|
||||
queryClient.clear();
|
||||
navigate("/login");
|
||||
}}
|
||||
@@ -973,8 +978,11 @@ export function MailLayout() {
|
||||
className={`w-full rounded-lg border border-app-border bg-app-bg px-3 py-1.5 text-[15px] text-app-fg placeholder:text-app-faint focus:border-app-accent focus:outline-none ${
|
||||
term ? "pr-14" : "pr-8"
|
||||
}`}
|
||||
disabled={offline}
|
||||
placeholder={
|
||||
currentFolder === "trash"
|
||||
offline
|
||||
? t("offline.searchPlaceholder")
|
||||
: currentFolder === "trash"
|
||||
? t("nav.searchInTrash")
|
||||
: currentFolder === "junk"
|
||||
? t("nav.searchInSpam")
|
||||
@@ -1077,7 +1085,7 @@ export function MailLayout() {
|
||||
<div className="-mr-1.5 ml-auto flex items-center gap-1">
|
||||
{(currentFolder === "trash" || currentFolder === "junk") && (
|
||||
<button
|
||||
className="rounded px-2 py-1 text-[13px] font-medium text-red-500 hover:bg-app-hover disabled:opacity-50"
|
||||
className="wm-online-only rounded px-2 py-1 text-[13px] font-medium text-red-500 hover:bg-app-hover disabled:opacity-50"
|
||||
onClick={emptyCurrentFolder}
|
||||
disabled={emptying}
|
||||
>
|
||||
@@ -1148,7 +1156,9 @@ export function MailLayout() {
|
||||
</div>
|
||||
)}
|
||||
<div className="min-h-0 flex-1">
|
||||
{isDrafts && !term.trim() ? (
|
||||
{isDrafts && !term.trim() && !draftsQuery.data && isOfflineError(draftsQuery.error) ? (
|
||||
<div className="p-6 text-[15px] text-app-faint">{t("offline.folderNotSaved")}</div>
|
||||
) : isDrafts && !term.trim() ? (
|
||||
<DraftsList
|
||||
drafts={draftsQuery.data ?? []}
|
||||
remoteDrafts={remoteDraftsQuery.data ?? []}
|
||||
@@ -1162,6 +1172,8 @@ export function MailLayout() {
|
||||
openDraftInPane(draftToInit(draft));
|
||||
}}
|
||||
/>
|
||||
) : !searching && !threadsQuery.data && isOfflineError(threadsQuery.error) ? (
|
||||
<div className="p-6 text-[15px] text-app-faint">{t("offline.folderNotSaved")}</div>
|
||||
) : (
|
||||
<MessageList
|
||||
threads={pageThreads}
|
||||
@@ -1190,7 +1202,7 @@ export function MailLayout() {
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
className={`-ml-1 rounded text-app-muted ${mobile ? "p-1.5" : "p-1"} hover:bg-app-hover hover:text-app-fg disabled:opacity-60`}
|
||||
className={`wm-online-only -ml-1 rounded text-app-muted ${mobile ? "p-1.5" : "p-1"} hover:bg-app-hover hover:text-app-fg disabled:opacity-60`}
|
||||
title={t("nav.refresh")}
|
||||
aria-label={t("nav.refresh")}
|
||||
onClick={doRefresh}
|
||||
@@ -1230,7 +1242,7 @@ export function MailLayout() {
|
||||
title={t("nav.compose")}
|
||||
aria-label={t("nav.compose")}
|
||||
onClick={() => accounts[0] && openComposer({ accountId: accounts[0].id, kind: "new" })}
|
||||
className="absolute bottom-[calc(4rem+env(safe-area-inset-bottom))] right-4 z-30 flex h-14 w-14 items-center justify-center rounded-2xl bg-app-accent text-app-accent-fg shadow-lg active:opacity-90"
|
||||
className="wm-online-only absolute bottom-[calc(4rem+env(safe-area-inset-bottom))] right-4 z-30 flex h-14 w-14 items-center justify-center rounded-2xl bg-app-accent text-app-accent-fg shadow-lg active:opacity-90"
|
||||
>
|
||||
<Icon name="compose" size={22} />
|
||||
</button>
|
||||
@@ -1369,6 +1381,27 @@ export function MailLayout() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : threadId && isOfflineError(detailQuery.error) ? (
|
||||
<div className="flex h-full flex-col">
|
||||
{single && (
|
||||
<div className={paneBarClass}>
|
||||
<button
|
||||
type="button"
|
||||
title={t("common.back")}
|
||||
aria-label={t("common.back")}
|
||||
onClick={() => navigate("/mail")}
|
||||
className="-ml-1 mr-1 rounded p-1.5 text-app-fg hover:bg-app-hover"
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M15 5l-7 7 7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="min-h-0 flex-1">
|
||||
<NotSavedOffline />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-6 text-app-faint">
|
||||
{!rightOpen && !panelToggleInList && (
|
||||
@@ -1599,7 +1632,7 @@ function ToolbarButton({
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
className={`flex items-center gap-1.5 rounded px-2.5 py-1 hover:bg-app-hover hover:text-app-fg ${
|
||||
className={`wm-online-only flex items-center gap-1.5 rounded px-2.5 py-1 hover:bg-app-hover hover:text-app-fg ${
|
||||
active ? "text-app-fg" : "text-app-muted"
|
||||
}`}
|
||||
onClick={onClick}
|
||||
|
||||
@@ -10,6 +10,16 @@ import { useT } from "../../i18n";
|
||||
import { api, ApiError } from "../../lib/api";
|
||||
import type { CalendarOut, ReminderPref } from "../../lib/types";
|
||||
import { useMe } from "../../lib/auth";
|
||||
import {
|
||||
disableOffline,
|
||||
enableOffline,
|
||||
offlineSupported,
|
||||
savedThreadIds,
|
||||
savedWhen,
|
||||
useLastSync,
|
||||
useOfflineEnabled,
|
||||
} from "../../lib/offline";
|
||||
import { prefetchRecent } from "../../lib/offlinePrefetch";
|
||||
import { useTheme } from "../../lib/theme";
|
||||
import { setTimeFormat } from "../../lib/timeFormat";
|
||||
import { PluginSlot } from "../../plugins/PluginSlot";
|
||||
@@ -213,6 +223,84 @@ function Row({ label, value }: { label: string; value: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function formatMB(bytes: number): string {
|
||||
return bytes < 1024 * 1024 ? `${Math.max(1, Math.round(bytes / 1024))} KB` : `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
/** Settings → Security: keep recent mail on this device for reading offline. */
|
||||
function OfflineAccessSetting() {
|
||||
const t = useT();
|
||||
const enabled = useOfflineEnabled();
|
||||
const last = useLastSync();
|
||||
const supported = offlineSupported();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [status, setStatus] = useState<{ count: number; bytes: number } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
setStatus(null);
|
||||
return;
|
||||
}
|
||||
let live = true;
|
||||
void (async () => {
|
||||
const ids = await savedThreadIds();
|
||||
const usage = (await navigator.storage?.estimate?.())?.usage ?? 0;
|
||||
if (live) setStatus({ count: ids.size, bytes: usage });
|
||||
})();
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [enabled, last]);
|
||||
|
||||
const toggle = async (on: boolean) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
if (on) {
|
||||
await enableOffline();
|
||||
await prefetchRecent();
|
||||
} else {
|
||||
await disableOffline();
|
||||
}
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-lg font-semibold">{t("offline.settingTitle")}</h2>
|
||||
<div className="flex gap-1 rounded-lg border border-app-border p-0.5 text-sm">
|
||||
{([true, false] as const).map((value) => (
|
||||
<button
|
||||
key={String(value)}
|
||||
disabled={!supported || busy}
|
||||
onClick={() => void toggle(value)}
|
||||
className={`rounded px-3 py-1 disabled:opacity-50 ${
|
||||
enabled === value ? "bg-app-active font-medium text-app-fg" : "text-app-muted hover:bg-app-hover"
|
||||
}`}
|
||||
>
|
||||
{t(value ? "settings.on" : "settings.off")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-app-muted">{t("offline.settingHelp")}</p>
|
||||
{!supported ? (
|
||||
<p className="text-xs text-app-faint">{t("offline.settingUnsupported")}</p>
|
||||
) : busy && enabled ? (
|
||||
<p className="text-xs text-app-faint">{t("offline.settingSaving")}</p>
|
||||
) : enabled && status ? (
|
||||
<p className="text-xs text-app-faint">
|
||||
{last
|
||||
? t("offline.settingStatus", { count: status.count, size: formatMB(status.bytes), when: savedWhen(last) })
|
||||
: t("offline.settingStatusNoTime", { count: status.count, size: formatMB(status.bytes) })}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SecuritySection() {
|
||||
const t = useT();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -351,6 +439,7 @@ function SecuritySection() {
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<OfflineAccessSetting />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user