Offline compose: write mail offline, Scheduled folder, undo countdown fix

Compose, Reply and Forward work offline. Drafts are kept on the device
(IndexedDB, lib/scheduled.ts), with files attached offline; Send becomes
"Schedule" and puts the message in a new Scheduled folder. When the server
is reachable again, scheduled messages are sent through the normal outbox
(Undo offered for a single one) and offline drafts become server drafts —
files uploaded first, each step recorded so nothing is created or sent
twice, under a Web Lock so two tabs never send the same message. Drafts
written offline show in Drafts as "On this device". Sign-out sends what
is scheduled, then wipes the device's copy.

Also: offline failures from keyboard shortcuts show "Not available
offline"; no recipient suggestions are asked for offline; a reply no
longer quotes an error page when the original is not saved.

The undo countdown counted to the server's deadline on the browser's
clock, so a few seconds of skew showed (18 s instead of 10). /send now
also returns undo_seconds, and the composer counts down from that.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-05 21:11:41 +02:00
co-authored by Claude Opus 5.5
parent c9c24e7e2f
commit e1bda37188
14 changed files with 808 additions and 107 deletions
+53 -15
View File
@@ -1,11 +1,11 @@
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useCallback, useEffect, useRef, useState } from "react";
import { Fragment, useCallback, useEffect, useRef, useState } from "react";
import { Link, useMatch, useNavigate, useSearchParams } from "react-router-dom";
import signetLogo from "../../icons/ultimo-logo-signet.svg";
import { AppSwitcher } from "../components/AppSwitcher";
import { ComposeDock } from "../components/compose/ComposeDock";
import { Composer, type ComposerInit, draftToInit } from "../components/compose/Composer";
import { Composer, type ComposerInit, draftToInit, localToInit } from "../components/compose/Composer";
import { UndoSendToast } from "../components/compose/UndoSendToast";
import { usePendingSend } from "../components/compose/undoSend";
import {
@@ -17,14 +17,14 @@ import {
restoreWindow,
toggleExpand,
} from "../components/compose/windows";
import { DraftsList } from "../components/list/DraftsList";
import { DraftsList, ScheduledList } from "../components/list/DraftsList";
import { Icon } from "../components/Icon";
import { MessageList } from "../components/list/MessageList";
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 { Clock, 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";
@@ -32,6 +32,7 @@ import { useT } from "../i18n";
import type { MessageKey } from "../i18n/en";
import { api, isOfflineError } from "../lib/api";
import { clearOfflineData, useOffline } from "../lib/offline";
import { flushLocal, useLocalMessages, wipeLocal } from "../lib/scheduled";
import { setTimeFormat } from "../lib/timeFormat";
import type { QuotaOut, SavedSearch, ThreadSummary } from "../lib/types";
import { useReadingPane } from "../lib/readingPane";
@@ -217,6 +218,12 @@ export function MailLayout() {
useEffect(() => localStorage.setItem("mail.listOrder", listOrder), [listOrder]);
const isDrafts = view === "folder:drafts";
// Messages written offline: drafts kept on this device, and the Scheduled
// folder of ones sent offline (lib/scheduled.ts).
const isScheduled = view === "local:scheduled";
const localMessages = useLocalMessages();
const deviceDrafts = localMessages.filter((m) => m.state === "draft");
const scheduled = localMessages.filter((m) => m.state !== "draft");
// Which special-use folder the reading pane is showing (drives the toolbar).
const currentFolder = view.startsWith("folder:") ? view.slice("folder:".length) : "inbox";
const accountsQuery = useQuery({ queryKey: ["accounts"], queryFn: api.accounts });
@@ -233,7 +240,7 @@ export function MailLayout() {
}
return api.threads(view, { offset: page * PAGE_SIZE, limit: PAGE_SIZE, filter, order: orderParam });
},
enabled: !isDrafts && !searching,
enabled: !isDrafts && !isScheduled && !searching,
placeholderData: keepPreviousData, // keep the current page visible while the next loads
});
const draftsQuery = useQuery({
@@ -798,7 +805,7 @@ export function MailLayout() {
</div>
<div className={`pb-2 pt-2.5 ${collapsed ? "px-2" : "px-3"} ${mobile ? "hidden" : ""}`}>
<button
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"
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"
title={collapsed ? t("nav.compose") : undefined}
onClick={() => accounts[0] && openComposer({ accountId: accounts[0].id, kind: "new" })}
>
@@ -812,11 +819,11 @@ export function MailLayout() {
f.view === "folder:inbox"
? countsQuery.data?.inbox_unread
: f.view === "folder:drafts"
? countsQuery.data?.drafts
? (countsQuery.data?.drafts ?? 0) + deviceDrafts.length || undefined
: undefined;
return (
<Fragment key={f.view}>
<SidebarItem
key={f.view}
active={view === f.view}
collapsed={collapsed}
title={collapsed ? t(f.label) : undefined}
@@ -828,6 +835,24 @@ export function MailLayout() {
<span className="ml-auto shrink-0 tabular-nums text-[13px] text-app-muted">{badge}</span>
) : null}
</SidebarItem>
{/* Scheduled: shown while something waits to be sent (or is open). */}
{f.view === "folder:drafts" && (scheduled.length > 0 || isScheduled) && (
<SidebarItem
active={isScheduled}
collapsed={collapsed}
title={collapsed ? t("nav.scheduled") : undefined}
onClick={() => selectView("local:scheduled")}
>
<span className="flex w-[18px] justify-center">
<Clock size={18} />
</span>
{!collapsed && <span className="flex-1 truncate">{t("nav.scheduled")}</span>}
{!collapsed && scheduled.length > 0 && (
<span className="ml-auto shrink-0 tabular-nums text-[13px] text-app-muted">{scheduled.length}</span>
)}
</SidebarItem>
)}
</Fragment>
);
})}
{/* Per-account views are hidden entirely when collapsed. */}
@@ -912,8 +937,12 @@ export function MailLayout() {
}`}
title={collapsed ? t("common.signOut") : undefined}
onClick={async () => {
// Anything still waiting in Scheduled goes out first: what is
// kept on this device is wiped with the session.
await flushLocal().catch(() => []);
await api.logout();
await clearOfflineData();
await wipeLocal();
queryClient.clear();
navigate("/login");
}}
@@ -1036,7 +1065,7 @@ export function MailLayout() {
</button>
)}
</div>
{!isDrafts && (
{!isDrafts && !isScheduled && (
<div
className={`flex h-[52px] shrink-0 items-center gap-5 border-b border-app-border bg-app-bg text-[15px] ${
mobile ? "px-3" : "px-6"
@@ -1156,14 +1185,23 @@ export function MailLayout() {
</div>
)}
<div className="min-h-0 flex-1">
{isDrafts && !term.trim() && !draftsQuery.data && isOfflineError(draftsQuery.error) ? (
<div className="p-6 text-[15px] text-app-faint">{t("offline.folderNotSaved")}</div>
{isScheduled ? (
<ScheduledList
messages={scheduled}
selectedId={paneDraft?.localId}
onOpen={(msg) => openDraftInPane(localToInit(msg))}
/>
) : isDrafts && !term.trim() ? (
<DraftsList
drafts={draftsQuery.data ?? []}
remoteDrafts={remoteDraftsQuery.data ?? []}
selectedId={paneDraft?.draftId}
deviceDrafts={deviceDrafts}
note={
!draftsQuery.data && isOfflineError(draftsQuery.error) ? t("offline.draftsNotSaved") : undefined
}
selectedId={paneDraft?.localId ?? paneDraft?.draftId}
onOpen={(draft) => openDraftInPane(draftToInit(draft))}
onOpenDevice={(msg) => openDraftInPane(localToInit(msg))}
onOpenRemote={async (remote) => {
// Adopt the server draft into an editable local draft, then open it.
const draft = await api.adoptDraft(remote.message_id);
@@ -1210,7 +1248,7 @@ export function MailLayout() {
>
<RefreshIcon spinning={refreshing} size={mobile ? 22.5 : 15} />
</button>
{!isDrafts && total > 0 ? (
{!isDrafts && !isScheduled && total > 0 ? (
<div className="flex items-center gap-3">
<span>
{pageStart + 1}–{pageEnd} of {total}
@@ -1242,7 +1280,7 @@ export function MailLayout() {
title={t("nav.compose")}
aria-label={t("nav.compose")}
onClick={() => accounts[0] && openComposer({ accountId: accounts[0].id, kind: "new" })}
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"
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"
>
<Icon name="compose" size={22} />
</button>
@@ -1265,7 +1303,7 @@ export function MailLayout() {
// Keyed by draft: picking a different one must build a fresh editor,
// because RichTextEditor seeds its body exactly once.
<Composer
key={paneDraft.draftId}
key={paneDraft.localId ?? paneDraft.draftId}
init={paneDraft}
onClose={closePaneDraft}
fullWindow