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