From a2c52f33a8473c57878301eec06b6715bacd4ccb Mon Sep 17 00:00:00 2001 From: Lukas Remis Date: Mon, 5 Oct 2026 18:54:17 +0200 Subject: [PATCH] Offline access: read recent mail without a connection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docker/nginx-app.conf | 7 + frontend/public/sw.js | 409 ++++++++++++++++++ frontend/src/App.tsx | 27 +- frontend/src/components/Offline.tsx | 264 +++++++++++ frontend/src/components/list/MessageList.tsx | 16 +- .../src/components/thread/MessageBody.tsx | 9 +- frontend/src/components/thread/ThreadView.tsx | 8 +- frontend/src/i18n/en.ts | 21 + frontend/src/index.css | 24 + frontend/src/lib/api.ts | 27 +- frontend/src/lib/auth.ts | 4 +- frontend/src/lib/offline.ts | 246 +++++++++++ frontend/src/lib/offlinePrefetch.ts | 73 ++++ frontend/src/routes/MailLayout.tsx | 51 ++- frontend/src/routes/settings/Settings.tsx | 89 ++++ 15 files changed, 1245 insertions(+), 30 deletions(-) create mode 100644 frontend/public/sw.js create mode 100644 frontend/src/components/Offline.tsx create mode 100644 frontend/src/lib/offline.ts create mode 100644 frontend/src/lib/offlinePrefetch.ts diff --git a/docker/nginx-app.conf b/docker/nginx-app.conf index 6c2f962..d44eccd 100644 --- a/docker/nginx-app.conf +++ b/docker/nginx-app.conf @@ -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; +} diff --git a/frontend/public/sw.js b/frontend/public/sw.js new file mode 100644 index 0000000..8fca783 --- /dev/null +++ b/frontend/public/sw.js @@ -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(); + })(), + ); +}); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7ac4235..2f6140d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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
Loading…
; } + if (isError && isOfflineError(error)) { + // No connection and nothing saved on this device: not a reason to sign out. + return ( +
+ {t("offline.cantReach")} + +
+ ); + } if (isError || !data) { return ; } - return children; + return ( + <> + + {children} + + ); } export function App() { diff --git a/frontend/src/components/Offline.tsx b/frontend/src/components/Offline.tsx new file mode 100644 index 0000000..9afa293 --- /dev/null +++ b/frontend/src/components/Offline.tsx @@ -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["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 ( + + + + + + ); +} + +function Check({ size = 16 }: { size?: number }) { + return ( + + + + ); +} + +// ---- 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 ( +
+ + + + {current.key === "offline" + ? t("offline.noticeSaved") + : current.key === "offlineNotSaved" + ? t("offline.noticeNotSaved") + : t("offline.notAvailable")} +
+ ); +} + +// ---- 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(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 ( + + + {!compact && t("offline.backOnline")} + + ); + } + if (!offline) return null; + + const explanation = enabled + ? when + ? t("offline.explainSaved", { when }) + : t("offline.explainSavedNoTime") + : t("offline.explainNotSaved"); + return ( +
+ + {open && ( +
+
+ + {t("offline.label")} +
+ {explanation} +
+ )} +
+ ); +} + +/** Shown in the reading pane for a conversation that was never saved here. */ +export function NotSavedOffline() { + const t = useT(); + return ( +
+ + + + {t("offline.threadNotSaved")} +
+ ); +} diff --git a/frontend/src/components/list/MessageList.tsx b/frontend/src/components/list/MessageList.tsx index 2090634..628ca96 100644 --- a/frontend/src/components/list/MessageList.tsx +++ b/frontend/src/components/list/MessageList.tsx @@ -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" : ""}`} >
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" } > @@ -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" > @@ -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" > @@ -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" > diff --git a/frontend/src/components/thread/MessageBody.tsx b/frontend/src/components/thread/MessageBody.tsx index 8aaad7a..d59f228 100644 --- a/frontend/src/components/thread/MessageBody.tsx +++ b/frontend/src/components/thread/MessageBody.tsx @@ -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({
)} {isLoading &&
{t("mail.loadingThread")}
} - {isError &&
{t("mail.loadFailed")}
} + {isError && ( +
+ {error.message === "offline" ? t("offline.notAvailable") : t("mail.loadFailed")} +
+ )} {data !== undefined && !isLoading && (