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