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:
@@ -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