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:
@@ -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} />
|
||||
|
||||
Reference in New Issue
Block a user