From e1bda3718882b11380d77d1c9b3c8165930697ab Mon Sep 17 00:00:00 2001 From: Lukas Remis Date: Mon, 5 Oct 2026 21:11:41 +0200 Subject: [PATCH] Offline compose: write mail offline, Scheduled folder, undo countdown fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/app/api/compose.py | 7 +- backend/app/outbox/schemas.py | 3 + backend/tests/outbox/test_outbox.py | 2 + frontend/src/components/Offline.tsx | 136 ++++++--- frontend/src/components/compose/Composer.tsx | 268 ++++++++++++++--- .../src/components/compose/RecipientChips.tsx | 4 +- frontend/src/components/list/DraftsList.tsx | 78 ++++- frontend/src/components/thread/ThreadView.tsx | 4 +- frontend/src/i18n/en.ts | 13 + frontend/src/lib/auth.ts | 2 + frontend/src/lib/offlineNotice.ts | 54 ++++ frontend/src/lib/scheduled.ts | 274 ++++++++++++++++++ frontend/src/lib/types.ts | 2 + frontend/src/routes/MailLayout.tsx | 68 ++++- 14 files changed, 808 insertions(+), 107 deletions(-) create mode 100644 frontend/src/lib/offlineNotice.ts create mode 100644 frontend/src/lib/scheduled.ts diff --git a/backend/app/api/compose.py b/backend/app/api/compose.py index 8006690..598c318 100644 --- a/backend/app/api/compose.py +++ b/backend/app/api/compose.py @@ -3,7 +3,7 @@ from __future__ import annotations import uuid -from datetime import timedelta +from datetime import UTC, datetime, timedelta from typing import Annotated from fastapi import APIRouter, Depends, File, Query, UploadFile @@ -300,7 +300,10 @@ async def send(body: SendRequest, user: CurrentUser, db: DbSession) -> SendRespo str(outbox.id), _defer_by=timedelta(seconds=get_settings().undo_seconds), ) - return SendResponse(outbox_id=outbox.id, undo_until=outbox.send_after.isoformat()) + left = max(0.0, (outbox.send_after - datetime.now(UTC)).total_seconds()) + return SendResponse( + outbox_id=outbox.id, undo_until=outbox.send_after.isoformat(), undo_seconds=left + ) @router.post("/send/{outbox_id}/undo") diff --git a/backend/app/outbox/schemas.py b/backend/app/outbox/schemas.py index 0ed58a9..90f14d4 100644 --- a/backend/app/outbox/schemas.py +++ b/backend/app/outbox/schemas.py @@ -56,6 +56,9 @@ class SendRequest(BaseModel): class SendResponse(BaseModel): outbox_id: uuid.UUID undo_until: str + # The same deadline as seconds from now: the browser counts down from this, + # since its clock may be seconds off the server's. + undo_seconds: float class OutboxStateOut(BaseModel): diff --git a/backend/tests/outbox/test_outbox.py b/backend/tests/outbox/test_outbox.py index 4607f7b..7dd95ee 100644 --- a/backend/tests/outbox/test_outbox.py +++ b/backend/tests/outbox/test_outbox.py @@ -398,6 +398,8 @@ async def test_send_creates_outbox_and_defers(ctx: Ctx) -> None: resp = await ctx.client.post("/api/send", json={"draft_id": did}, headers=_hdr()) assert resp.status_code == 200 outbox_id = resp.json()["outbox_id"] + # The window as a duration too, so the browser's clock does not matter. + assert 0 < resp.json()["undo_seconds"] <= get_settings().undo_seconds ob = await ctx.session.get(Outbox, uuid.UUID(outbox_id)) assert ob is not None and ob.state == "undo_window" diff --git a/frontend/src/components/Offline.tsx b/frontend/src/components/Offline.tsx index d273299..2b6a17d 100644 --- a/frontend/src/components/Offline.tsx +++ b/frontend/src/components/Offline.tsx @@ -5,10 +5,11 @@ // - OfflineIndicator (the sidebar header): the amber "Offline" cloud, with an // explanation on click, and a short green "Back online" when it returns. -import { useQueryClient } from "@tanstack/react-query"; -import { useEffect, useRef, useState, useSyncExternalStore } from "react"; +import { type QueryClient, useQueryClient } from "@tanstack/react-query"; +import { useEffect, useRef, useState } from "react"; import { useT } from "../i18n"; +import { isOfflineError } from "../lib/api"; import { isOffline, offlineEnabled, @@ -19,40 +20,22 @@ import { useOffline, useOfflineEnabled, } from "../lib/offline"; +import { + clearOfflineNotice, + setBackOnline, + showOfflineNotice, + useBackOnline, + useOfflineNotice, +} from "../lib/offlineNotice"; import { prefetchRecent, serverAnswers } from "../lib/offlinePrefetch"; +import { flushLocal, type ScheduledSent } from "../lib/scheduled"; import { subscribeWs } from "../lib/ws"; +import { hasUndoHost, offerUndo } from "./compose/undoSend"; const PROBE_MS = 10_000; const BACK_ONLINE_MS = 3_000; const REFRESH_MS = 15 * 60_000; -// ---- the short-lived bits of UI the two share --------------------------------- - -type Notice = { key: "offline" | "offlineNotSaved" | "notAvailable"; seq: number } | null; - -let notice: Notice = null; -let backOnline = false; -const listeners = new Set<() => void>(); -const emit = () => listeners.forEach((l) => l()); -const subscribe = (l: () => void) => { - listeners.add(l); - return () => { - listeners.delete(l); - }; -}; -let noticeTimer: number | undefined; -let noticeSeq = 0; - -function showNotice(key: NonNullable["key"], ms: number) { - notice = { key, seq: ++noticeSeq }; - emit(); - window.clearTimeout(noticeTimer); - noticeTimer = window.setTimeout(() => { - notice = null; - emit(); - }, ms); -} - function CloudOff({ size = 16 }: { size?: number }) { return ( @@ -63,6 +46,15 @@ function CloudOff({ size = 16 }: { size?: number }) { ); } +export function Clock({ size = 16 }: { size?: number }) { + return ( + + + + + ); +} + function Check({ size = 16 }: { size?: number }) { return ( @@ -73,12 +65,33 @@ function Check({ size = 16 }: { size?: number }) { // ---- the controller ------------------------------------------------------------ +/** Hand messages written offline to the server; offer Undo for a single send. */ +async function sendScheduled(queryClient: QueryClient): Promise { + if (isOffline()) return; + let sent: ScheduledSent[]; + try { + sent = await flushLocal(); + } catch { + return; + } + for (const key of ["drafts", "drafts-remote", "folder-counts", "threads"]) { + void queryClient.invalidateQueries({ queryKey: [key] }); + } + if (!sent.length) return; + if (sent.length === 1 && hasUndoHost()) { + const [one] = sent; + offerUndo({ outboxId: one.outboxId, draftId: one.draftId, until: Date.now() + one.undoSeconds * 1000 }); + } else { + showOfflineNotice("sendingScheduled", 6000, { count: sent.length }); + } +} + export function OfflineController() { const t = useT(); const queryClient = useQueryClient(); const offline = useOffline(); const enabled = useOfflineEnabled(); - const current = useSyncExternalStore(subscribe, () => notice); + const current = useOfflineNotice(); const wasOffline = useRef(false); // Install (or remove) the service worker to match this device's setting. @@ -90,23 +103,35 @@ export function OfflineController() { useEffect(() => { if (offline) { wasOffline.current = true; - showNotice(offlineEnabled() ? "offline" : "offlineNotSaved", 6000); + showOfflineNotice(offlineEnabled() ? "offline" : "offlineNotSaved", 6000); return; } if (!wasOffline.current) return; wasOffline.current = false; - notice = null; - backOnline = true; - emit(); - const timer = window.setTimeout(() => { - backOnline = false; - emit(); - }, BACK_ONLINE_MS); + clearOfflineNotice(); + setBackOnline(true); + const timer = window.setTimeout(() => setBackOnline(false), BACK_ONLINE_MS); void queryClient.invalidateQueries(); + void sendScheduled(queryClient); void prefetchRecent(); return () => window.clearTimeout(timer); }, [offline, queryClient]); + // Messages written offline: sent (Scheduled) or made server drafts as soon + // as the server is there — on start, and checked again every minute. + useEffect(() => { + const start = window.setTimeout(() => void sendScheduled(queryClient), 2000); + const every = window.setInterval(() => void sendScheduled(queryClient), 60_000); + // A scheduled message was looked at and closed: no need to wait a minute. + const now = () => void sendScheduled(queryClient); + window.addEventListener("webmail:send-scheduled", now); + return () => { + window.clearTimeout(start); + window.clearInterval(every); + window.removeEventListener("webmail:send-scheduled", now); + }; + }, [queryClient]); + // While offline: ask the server now and then whether it is back. useEffect(() => { if (!offline) return; @@ -151,10 +176,21 @@ export function OfflineController() { if (!el) return; e.preventDefault(); e.stopPropagation(); - showNotice("notAvailable", 2500); + showOfflineNotice("notAvailable", 2500); + }; + // Actions started some other way (a keyboard shortcut, say) fail with + // "offline" somewhere unhandled: say so the same way, not as a page error. + const onRejection = (e: PromiseRejectionEvent) => { + if (!isOfflineError(e.reason)) return; + e.preventDefault(); + showOfflineNotice("notAvailable", 2500); }; document.addEventListener("click", onClick, true); - return () => document.removeEventListener("click", onClick, true); + window.addEventListener("unhandledrejection", onRejection); + return () => { + document.removeEventListener("click", onClick, true); + window.removeEventListener("unhandledrejection", onRejection); + }; }, []); if (!current) return null; @@ -164,14 +200,24 @@ export function OfflineController() { role="status" className="wm-offline-notice pointer-events-none fixed bottom-6 left-1/2 z-[70] flex max-w-[calc(100vw-2rem)] -translate-x-1/2 items-center gap-2.5 rounded-xl border border-app-border bg-app-elevated px-4 py-2.5 text-sm text-app-fg shadow-lg" > - - - + {current.key === "scheduled" || current.key === "sendingScheduled" ? ( + + + + ) : ( + + + + )} {current.key === "offline" ? t("offline.noticeSaved") : current.key === "offlineNotSaved" ? t("offline.noticeNotSaved") - : t("offline.notAvailable")} + : current.key === "scheduled" + ? t("offline.noticeScheduled") + : current.key === "sendingScheduled" + ? t("offline.noticeSendingScheduled", current.vars) + : t("offline.notAvailable")} ); } @@ -182,7 +228,7 @@ export function OfflineIndicator({ compact = false }: { compact?: boolean }) { const t = useT(); const offline = useOffline(); const enabled = useOfflineEnabled(); - const back = useSyncExternalStore(subscribe, () => backOnline); + const back = useBackOnline(); const last = useLastSync(); const when = savedWhen(last); const [open, setOpen] = useState(false); diff --git a/frontend/src/components/compose/Composer.tsx b/frontend/src/components/compose/Composer.tsx index 535aac3..f3bc916 100644 --- a/frontend/src/components/compose/Composer.tsx +++ b/frontend/src/components/compose/Composer.tsx @@ -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(null); const fromRef = useRef(null); - const [attachments, setAttachments] = useState([]); + const [attachments, setAttachments] = useState(init.attachments ?? []); + // Files attached while offline, kept on this device until they can be uploaded. + const [localFiles, setLocalFiles] = useState(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(init.draftId ?? null); + // This message's copy on this device, while there is one (lib/scheduled.ts). + const localId = useRef(init.localId ?? null); + const discardedRef = useRef(false); + // Kept as what it was opened as: a scheduled message edited offline stays scheduled. + const localState = useRef(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(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 => { + 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({ {/* Phone width: the icon alone. */} - {status === "sending" ? t("compose.sending") : t("compose.send")} + {status === "sending" ? t("compose.sending") : offline ? t("compose.sendLater") : t("compose.send")} fileInputRef.current?.click()} title={t("compose.attach")}> @@ -758,6 +928,7 @@ export function Composer({
{!minimized && status === "saving" && {t("compose.savingDraft")}} {!minimized && status === "saved" && {t("compose.draftSaved")}} + {!minimized && status === "savedLocal" && {t("compose.savedOnDevice")}} {!minimized && status === "error" && ( {t("compose.sendFailed")} )} @@ -793,7 +964,7 @@ export function Composer({ {!fullWindow && ( // No second browser window to pop out into on a phone.
- + @@ -898,7 +1069,7 @@ export function Composer({ }} /> - {(attachments.length > 0 || attachError) && ( + {(attachments.length > 0 || localFiles.length > 0 || attachError) && (
{attachments.map((att) => ( ))} + {localFiles.map((f) => ( + + + + + {f.name} + {formatBytes(f.size)} + + + ))} {attachError && {attachError}}
)} diff --git a/frontend/src/components/compose/RecipientChips.tsx b/frontend/src/components/compose/RecipientChips.tsx index 85ef97c..3613c48 100644 --- a/frontend/src/components/compose/RecipientChips.tsx +++ b/frontend/src/components/compose/RecipientChips.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react"; import { useT } from "../../i18n"; import { api } from "../../lib/api"; +import { isOffline } from "../../lib/offline"; import type { RecipientSuggestion } from "../../lib/types"; function splitList(value: string): string[] { @@ -76,7 +77,8 @@ export function RecipientChips({ const removeAt = (i: number) => onChange(chips.filter((_, idx) => idx !== i).join(", ")); useEffect(() => { - if (draft.trim().length < 2) { + // Suggestions come from the server: none offline (and no doomed request). + if (draft.trim().length < 2 || isOffline()) { setSuggestions([]); return; } diff --git a/frontend/src/components/list/DraftsList.tsx b/frontend/src/components/list/DraftsList.tsx index dea93c8..81b86aa 100644 --- a/frontend/src/components/list/DraftsList.tsx +++ b/frontend/src/components/list/DraftsList.tsx @@ -1,5 +1,6 @@ import { useT } from "../../i18n"; import { formatClock, getDateLocale } from "../../lib/timeFormat"; +import type { LocalMessage } from "../../lib/scheduled"; import type { DraftOut, RemoteDraftOut } from "../../lib/types"; function timeLabel(iso: string): string { @@ -15,6 +16,7 @@ function DraftRow({ preview, time, badge, + badgeTone, selected, onClick, }: { @@ -23,9 +25,16 @@ function DraftRow({ preview: string; time: string; badge?: string; + badgeTone?: "muted" | "accent" | "error"; selected: boolean; onClick: () => void; }) { + const tone = + badgeTone === "accent" + ? "bg-app-accent/15 text-app-accent" + : badgeTone === "error" + ? "bg-red-500/15 text-red-500" + : "bg-app-hover text-app-muted"; return ( {canReply && (
)}
- {!isDrafts && ( + {!isDrafts && !isScheduled && (
)}
- {isDrafts && !term.trim() && !draftsQuery.data && isOfflineError(draftsQuery.error) ? ( -
{t("offline.folderNotSaved")}
+ {isScheduled ? ( + openDraftInPane(localToInit(msg))} + /> ) : isDrafts && !term.trim() ? ( 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() { > - {!isDrafts && total > 0 ? ( + {!isDrafts && !isScheduled && total > 0 ? (
{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" > @@ -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.