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:
@@ -2,8 +2,22 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { useT } from "../../i18n";
|
||||
import { api, ApiError } from "../../lib/api";
|
||||
import { api, ApiError, isOfflineError } from "../../lib/api";
|
||||
import { isOffline, useOffline } from "../../lib/offline";
|
||||
import {
|
||||
deleteFiles,
|
||||
deleteLocal,
|
||||
holdOpen,
|
||||
type LocalFile,
|
||||
type LocalMessage,
|
||||
type LocalState,
|
||||
newLocalId,
|
||||
putFile,
|
||||
putLocal,
|
||||
uploadFiles,
|
||||
} from "../../lib/scheduled";
|
||||
import type { AccountOut, AttachmentOut, DraftInput, DraftOut } from "../../lib/types";
|
||||
import { showOfflineNotice } from "../../lib/offlineNotice";
|
||||
import { RecipientChips } from "./RecipientChips";
|
||||
import { RichTextEditor, type RichTextEditorHandle } from "./RichTextEditor";
|
||||
import { hasUndoHost, offerUndo } from "./undoSend";
|
||||
@@ -17,6 +31,7 @@ export interface ComposerInit {
|
||||
kind: string; // reply | reply_all | forward | new
|
||||
to?: string;
|
||||
cc?: string;
|
||||
bcc?: string;
|
||||
subject?: string;
|
||||
text?: string;
|
||||
html?: string; // initial body when reopening an existing draft
|
||||
@@ -25,6 +40,11 @@ export interface ComposerInit {
|
||||
// A reply/forward quotes the original: this attribution line sits above the
|
||||
// quoted message body (fetched from the original message's sanitized HTML).
|
||||
quote?: { attribution: string; messageId: string };
|
||||
// A message kept on this device (written offline; lib/scheduled.ts).
|
||||
localId?: string;
|
||||
localState?: LocalState; // "scheduled" when reopened from Scheduled
|
||||
attachments?: AttachmentOut[]; // known already: no need to ask the server
|
||||
localFiles?: LocalFile[];
|
||||
}
|
||||
|
||||
// Build the composer seed from a saved draft (used to reopen it in place or in a
|
||||
@@ -45,6 +65,29 @@ export function draftToInit(draft: DraftOut): ComposerInit {
|
||||
};
|
||||
}
|
||||
|
||||
// Reopen a message kept on this device (a draft written offline, or one in
|
||||
// Scheduled).
|
||||
export function localToInit(msg: LocalMessage): ComposerInit {
|
||||
const p = msg.input.payload;
|
||||
return {
|
||||
accountId: msg.input.account_id,
|
||||
threadId: msg.input.thread_id ?? null,
|
||||
replyToMessageId: msg.input.reply_to_message_id ?? null,
|
||||
kind: msg.input.kind,
|
||||
to: p.to.join(", "),
|
||||
cc: p.cc.join(", "),
|
||||
bcc: p.bcc.join(", "),
|
||||
subject: p.subject,
|
||||
text: p.text,
|
||||
html: p.html || undefined,
|
||||
draftId: msg.serverDraftId ?? undefined,
|
||||
localId: msg.id,
|
||||
localState: msg.state === "draft" ? "draft" : "scheduled", // "failed"/"sending" wait to be sent too
|
||||
attachments: msg.attachments,
|
||||
localFiles: msg.files,
|
||||
};
|
||||
}
|
||||
|
||||
function splitList(value: string): string[] {
|
||||
return value
|
||||
.split(",")
|
||||
@@ -80,11 +123,13 @@ function HeaderButton({
|
||||
onClick,
|
||||
title,
|
||||
active = false,
|
||||
className = "",
|
||||
children,
|
||||
}: {
|
||||
onClick?: () => void;
|
||||
title: string;
|
||||
active?: boolean;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
@@ -93,7 +138,7 @@ function HeaderButton({
|
||||
title={title}
|
||||
aria-label={title}
|
||||
onClick={onClick}
|
||||
className={`flex items-center gap-1 rounded-md px-2 py-1.5 text-sm ${
|
||||
className={`${className} flex items-center gap-1 rounded-md px-2 py-1.5 text-sm ${
|
||||
active ? "bg-app-active text-app-fg" : "text-app-muted hover:bg-app-hover hover:text-app-fg"
|
||||
}`}
|
||||
>
|
||||
@@ -150,8 +195,8 @@ export function Composer({
|
||||
const [accountId, setAccountId] = useState(init.accountId);
|
||||
const [to, setTo] = useState(init.to ?? "");
|
||||
const [cc, setCc] = useState(init.cc ?? "");
|
||||
const [bcc, setBcc] = useState("");
|
||||
const [showCc, setShowCc] = useState(Boolean(init.cc));
|
||||
const [bcc, setBcc] = useState(init.bcc ?? "");
|
||||
const [showCc, setShowCc] = useState(Boolean(init.cc || init.bcc));
|
||||
const [subject, setSubject] = useState(init.subject ?? "");
|
||||
const [bodyHtml, setBodyHtml] = useState(init.html ?? "");
|
||||
const [quoteHtml, setQuoteHtml] = useState("");
|
||||
@@ -176,9 +221,12 @@ export function Composer({
|
||||
});
|
||||
const moreRef = useRef<HTMLDivElement>(null);
|
||||
const fromRef = useRef<HTMLDivElement>(null);
|
||||
const [attachments, setAttachments] = useState<AttachmentOut[]>([]);
|
||||
const [attachments, setAttachments] = useState<AttachmentOut[]>(init.attachments ?? []);
|
||||
// Files attached while offline, kept on this device until they can be uploaded.
|
||||
const [localFiles, setLocalFiles] = useState<LocalFile[]>(init.localFiles ?? []);
|
||||
const [attachError, setAttachError] = useState("");
|
||||
const [status, setStatus] = useState<"" | "saving" | "saved" | "sending" | "error">("");
|
||||
const [status, setStatus] = useState<"" | "saving" | "saved" | "savedLocal" | "sending" | "error">("");
|
||||
const offline = useOffline();
|
||||
const [undo, setUndo] = useState<{ outboxId: string; until: number } | null>(null);
|
||||
const [undoLeft, setUndoLeft] = useState(0);
|
||||
// Tick the undo countdown while the window is open.
|
||||
@@ -199,6 +247,11 @@ export function Composer({
|
||||
onTitleRef.current?.(subject);
|
||||
}, [subject]);
|
||||
const draftId = useRef<string | null>(init.draftId ?? null);
|
||||
// This message's copy on this device, while there is one (lib/scheduled.ts).
|
||||
const localId = useRef<string | null>(init.localId ?? null);
|
||||
const discardedRef = useRef(false);
|
||||
// Kept as what it was opened as: a scheduled message edited offline stays scheduled.
|
||||
const localState = useRef<LocalState>(init.localState ?? "draft");
|
||||
const sentRef = useRef(false); // send initiated -> don't mirror to Drafts on close
|
||||
// Whether the user actually edited anything. Merely opening a draft to view it
|
||||
// must not re-save/re-mirror it (that spawned duplicate IMAP copies).
|
||||
@@ -249,6 +302,8 @@ export function Composer({
|
||||
(async () => {
|
||||
try {
|
||||
const resp = await fetch(api.messageHtmlUrl(quote.messageId), { credentials: "include" });
|
||||
// Offline, a message not saved here answers 503: quote nothing rather than that.
|
||||
if (!resp.ok) throw new Error(`quote: ${resp.status}`);
|
||||
const doc = new DOMParser().parseFromString(await resp.text(), "text/html");
|
||||
if (!cancelled) setQuoteHtml(doc.body?.innerHTML ?? "");
|
||||
} catch {
|
||||
@@ -265,7 +320,7 @@ export function Composer({
|
||||
// Resolve attachment metadata when reopening a draft that already has some.
|
||||
useEffect(() => {
|
||||
const ids = init.attachmentIds ?? [];
|
||||
if (ids.length === 0) return;
|
||||
if (ids.length === 0 || init.attachments) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
@@ -290,6 +345,12 @@ export function Composer({
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
if (isOffline()) {
|
||||
// Kept on this device, uploaded once the server is reachable.
|
||||
const kept = await putFile(file);
|
||||
setLocalFiles((prev) => [...prev, kept]);
|
||||
continue;
|
||||
}
|
||||
const att = await api.uploadComposeAttachment(file);
|
||||
setAttachments((prev) => [...prev, att]);
|
||||
} catch (e) {
|
||||
@@ -303,6 +364,12 @@ export function Composer({
|
||||
setAttachments((prev) => prev.filter((a) => a.id !== id));
|
||||
};
|
||||
|
||||
const removeLocalFile = (id: string) => {
|
||||
markDirty();
|
||||
setLocalFiles((prev) => prev.filter((f) => f.id !== id));
|
||||
void deleteFiles([id]);
|
||||
};
|
||||
|
||||
// Drag & drop: files dropped on the writing area insert inline (images) or
|
||||
// attach (other types); dropped anywhere above it, everything attaches.
|
||||
const editorAreaRef = useRef<HTMLDivElement>(null);
|
||||
@@ -393,7 +460,7 @@ export function Composer({
|
||||
|
||||
const bodyReady = quoteResolved && accountsQuery.data !== undefined;
|
||||
|
||||
const payload = (): DraftInput => {
|
||||
const payload = (atts: AttachmentOut[] = attachments): DraftInput => {
|
||||
const html = bodyHtml || initialHtml;
|
||||
return {
|
||||
account_id: accountId,
|
||||
@@ -407,31 +474,94 @@ export function Composer({
|
||||
subject,
|
||||
html,
|
||||
text: htmlToText(html),
|
||||
attachment_ids: attachments.map((a) => a.id),
|
||||
attachment_ids: atts.map((a) => a.id),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (undo) return;
|
||||
const handle = window.setTimeout(async () => {
|
||||
if (!dirtyRef.current) return; // nothing the user changed -> don't touch the draft
|
||||
if (!to && !subject && !bodyHtml) return;
|
||||
setStatus("saving");
|
||||
// Keep this message on the device (offline): as a draft, or for Scheduled.
|
||||
const saveLocal = async (state: LocalState) => {
|
||||
localId.current ??= newLocalId();
|
||||
const msg: LocalMessage = {
|
||||
id: localId.current,
|
||||
state,
|
||||
input: payload(),
|
||||
attachments,
|
||||
files: localFiles,
|
||||
serverDraftId: draftId.current,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
await putLocal(msg);
|
||||
};
|
||||
|
||||
// While a copy is kept here, other tabs' hand-over to the server leaves it be.
|
||||
const [heldId, setHeldId] = useState(localId.current);
|
||||
useEffect(() => (heldId ? holdOpen(heldId) : undefined), [heldId]);
|
||||
|
||||
// Save to the server; a copy kept on this device goes there first (its files
|
||||
// uploaded), then is dropped. Returns the attachments now on the server.
|
||||
const saveToServer = async (): Promise<AttachmentOut[]> => {
|
||||
let atts = attachments;
|
||||
if (localFiles.length) {
|
||||
const uploaded = await uploadFiles(localFiles);
|
||||
await deleteFiles(localFiles.map((f) => f.id));
|
||||
atts = [...attachments, ...uploaded];
|
||||
setAttachments(atts);
|
||||
setLocalFiles([]);
|
||||
}
|
||||
if (draftId.current) await api.updateDraft(draftId.current, payload(atts));
|
||||
else draftId.current = (await api.createDraft(payload(atts))).id;
|
||||
if (localId.current) {
|
||||
await deleteLocal(localId.current);
|
||||
localId.current = null;
|
||||
setHeldId(null);
|
||||
}
|
||||
localState.current = "draft"; // on the server now: a draft until Send
|
||||
return atts;
|
||||
};
|
||||
|
||||
const autosave = async () => {
|
||||
setStatus("saving");
|
||||
try {
|
||||
if (isOffline()) throw new ApiError(0, "offline", "offline");
|
||||
await saveToServer();
|
||||
setStatus("saved");
|
||||
} catch (e) {
|
||||
if (!isOfflineError(e)) {
|
||||
setStatus("error");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (draftId.current) {
|
||||
await api.updateDraft(draftId.current, payload());
|
||||
} else {
|
||||
draftId.current = (await api.createDraft(payload())).id;
|
||||
}
|
||||
setStatus("saved");
|
||||
await saveLocal(localState.current);
|
||||
setHeldId(localId.current);
|
||||
setStatus("savedLocal");
|
||||
} catch {
|
||||
setStatus("error");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (undo) return;
|
||||
const handle = window.setTimeout(() => {
|
||||
if (!dirtyRef.current) return; // nothing the user changed -> don't touch the draft
|
||||
if (!to && !subject && !bodyHtml) return;
|
||||
void autosave();
|
||||
}, 2000);
|
||||
return () => window.clearTimeout(handle);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [accountId, to, cc, bcc, subject, bodyHtml, attachments]);
|
||||
}, [accountId, to, cc, bcc, subject, bodyHtml, attachments, localFiles]);
|
||||
|
||||
// Back online with a draft kept here: hand it to the server now. (A
|
||||
// scheduled one is sent by the scheduled sender once this window closes.)
|
||||
useEffect(() => {
|
||||
if (!offline && localId.current && !undo && localState.current === "draft") void autosave();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [offline]);
|
||||
|
||||
// The latest way to save locally, for the unmount below (its closure is old).
|
||||
const saveLocalRef = useRef(saveLocal);
|
||||
saveLocalRef.current = saveLocal;
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
@@ -439,25 +569,57 @@ export function Composer({
|
||||
// On close, mirror the draft to the IMAP Drafts folder so it can be
|
||||
// continued on any mail client — but only if the user actually edited it
|
||||
// (skip if it was sent, discarded, or merely opened to view).
|
||||
if (draftId.current && !sentRef.current && dirtyRef.current) {
|
||||
if (localId.current && localState.current === "scheduled" && !sentRef.current && !discardedRef.current) {
|
||||
// A scheduled message looked at (or edited offline) and closed: still
|
||||
// scheduled — save any change, then let the sender have it.
|
||||
const save = dirtyRef.current ? saveLocalRef.current("scheduled") : Promise.resolve();
|
||||
void save.finally(() => window.dispatchEvent(new Event("webmail:send-scheduled")));
|
||||
return;
|
||||
}
|
||||
if (sentRef.current || discardedRef.current || !dirtyRef.current) return;
|
||||
if (isOffline()) {
|
||||
// Closed offline: keep it here as a draft (the server gets it later).
|
||||
void saveLocalRef.current("draft").catch(() => {});
|
||||
return;
|
||||
}
|
||||
if (draftId.current && !localId.current) {
|
||||
void api.publishDraft(draftId.current).catch(() => {});
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Offline (or the connection dropped while sending): into Scheduled, to go
|
||||
// out on its own once the server is reachable again.
|
||||
const schedule = async () => {
|
||||
try {
|
||||
await saveLocal("scheduled");
|
||||
sentRef.current = true;
|
||||
showOfflineNotice("scheduled", 5000);
|
||||
onClose();
|
||||
} catch {
|
||||
sentRef.current = false;
|
||||
setStatus("error");
|
||||
}
|
||||
};
|
||||
|
||||
const send = async () => {
|
||||
setStatus("sending");
|
||||
sentRef.current = true; // don't also mirror to Drafts on close
|
||||
if (isOffline()) {
|
||||
await schedule();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (!draftId.current) draftId.current = (await api.createDraft(payload())).id;
|
||||
else await api.updateDraft(draftId.current, payload());
|
||||
const res = await api.send(draftId.current);
|
||||
// Drive the countdown + auto-close off the server's undo deadline.
|
||||
const until = new Date(res.undo_until).getTime();
|
||||
await saveToServer();
|
||||
const sentDraft = draftId.current!;
|
||||
const res = await api.send(sentDraft);
|
||||
// Drive the countdown + auto-close off the server's undo window, as a
|
||||
// duration: this computer's clock may be seconds off the server's.
|
||||
const until = Date.now() + res.undo_seconds * 1000;
|
||||
// With a toast to carry the undo offer, the window closes right away.
|
||||
if (hasUndoHost()) {
|
||||
offerUndo({ outboxId: res.outbox_id, draftId: draftId.current, until });
|
||||
offerUndo({ outboxId: res.outbox_id, draftId: sentDraft, until });
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
@@ -466,6 +628,10 @@ export function Composer({
|
||||
setUndoLeft(Math.ceil(remaining / 1000));
|
||||
undoTimer.current = window.setTimeout(onClose, remaining);
|
||||
} catch (e) {
|
||||
if (isOfflineError(e)) {
|
||||
await schedule();
|
||||
return;
|
||||
}
|
||||
sentRef.current = false;
|
||||
setStatus("error");
|
||||
if (!(e instanceof ApiError)) throw e;
|
||||
@@ -486,7 +652,14 @@ export function Composer({
|
||||
};
|
||||
|
||||
const discard = async () => {
|
||||
if (draftId.current) {
|
||||
discardedRef.current = true;
|
||||
if (localId.current) {
|
||||
await deleteLocal(localId.current).catch(() => {});
|
||||
localId.current = null;
|
||||
} else if (localFiles.length) {
|
||||
await deleteFiles(localFiles.map((f) => f.id)).catch(() => {});
|
||||
}
|
||||
if (draftId.current && !isOffline()) {
|
||||
try {
|
||||
await api.deleteDraft(draftId.current);
|
||||
} catch {
|
||||
@@ -500,7 +673,7 @@ export function Composer({
|
||||
// X button: if there's a draft worth keeping, ask before closing; otherwise
|
||||
// (nothing typed, no draft) just close.
|
||||
const requestClose = () => {
|
||||
if (!dirtyRef.current && !draftId.current) {
|
||||
if (!dirtyRef.current && !draftId.current && !localId.current) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
@@ -513,10 +686,7 @@ export function Composer({
|
||||
const saveDraftAndClose = async () => {
|
||||
setShowClosePrompt(false);
|
||||
try {
|
||||
if (dirtyRef.current) {
|
||||
if (draftId.current) await api.updateDraft(draftId.current, payload());
|
||||
else draftId.current = (await api.createDraft(payload())).id;
|
||||
}
|
||||
if (dirtyRef.current) await autosave();
|
||||
} catch {
|
||||
/* the close-time IMAP mirror is best-effort too */
|
||||
}
|
||||
@@ -639,7 +809,7 @@ export function Composer({
|
||||
</svg>
|
||||
{/* Phone width: the icon alone. */}
|
||||
<span className="max-md:hidden">
|
||||
{status === "sending" ? t("compose.sending") : t("compose.send")}
|
||||
{status === "sending" ? t("compose.sending") : offline ? t("compose.sendLater") : t("compose.send")}
|
||||
</span>
|
||||
</button>
|
||||
<HeaderButton onClick={() => fileInputRef.current?.click()} title={t("compose.attach")}>
|
||||
@@ -758,6 +928,7 @@ export function Composer({
|
||||
<div className="ml-auto flex items-center gap-1 text-xs text-app-faint">
|
||||
{!minimized && status === "saving" && <span className="mr-1">{t("compose.savingDraft")}</span>}
|
||||
{!minimized && status === "saved" && <span className="mr-1">{t("compose.draftSaved")}</span>}
|
||||
{!minimized && status === "savedLocal" && <span className="mr-1">{t("compose.savedOnDevice")}</span>}
|
||||
{!minimized && status === "error" && (
|
||||
<span className="mr-1 text-red-500">{t("compose.sendFailed")}</span>
|
||||
)}
|
||||
@@ -793,7 +964,7 @@ export function Composer({
|
||||
{!fullWindow && (
|
||||
// No second browser window to pop out into on a phone.
|
||||
<div className="contents max-md:hidden">
|
||||
<HeaderButton onClick={popOut} title={t("compose.popOut")}>
|
||||
<HeaderButton onClick={popOut} title={t("compose.popOut")} className="wm-online-only">
|
||||
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M6 3H3v10h10v-3M9 3h4v4M13 3L7 9" />
|
||||
</svg>
|
||||
@@ -898,7 +1069,7 @@ export function Composer({
|
||||
}}
|
||||
/>
|
||||
|
||||
{(attachments.length > 0 || attachError) && (
|
||||
{(attachments.length > 0 || localFiles.length > 0 || attachError) && (
|
||||
<div className="flex flex-wrap items-center gap-2 border-b border-app-border px-3 py-2">
|
||||
{attachments.map((att) => (
|
||||
<span
|
||||
@@ -920,6 +1091,27 @@ export function Composer({
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
{localFiles.map((f) => (
|
||||
<span
|
||||
key={f.id}
|
||||
title={t("compose.attachOnDevice")}
|
||||
className="flex items-center gap-1.5 rounded border border-dashed border-app-border bg-app-hover px-2 py-1 text-xs text-app-fg"
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12.5 6.5l-5.2 5.2a2.5 2.5 0 0 1-3.5-3.5l5.4-5.4a1.6 1.6 0 0 1 2.3 2.3l-5.3 5.3a0.8 0.8 0 0 1-1.1-1.1l4.9-4.9" />
|
||||
</svg>
|
||||
<span className="max-w-[12rem] truncate">{f.name}</span>
|
||||
<span className="text-app-faint">{formatBytes(f.size)}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeLocalFile(f.id)}
|
||||
className="text-app-faint hover:text-app-fg"
|
||||
aria-label={`remove ${f.name}`}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
{attachError && <span className="text-xs text-red-500">{attachError}</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user