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
+5 -2
View File
@@ -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")
+3
View File
@@ -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):
+2
View File
@@ -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"
+91 -45
View File
@@ -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<Notice>["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 (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
@@ -63,6 +46,15 @@ function CloudOff({ size = 16 }: { size?: number }) {
);
}
export function Clock({ size = 16 }: { size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<circle cx="12" cy="12" r="8.5" />
<path d="M12 7.5V12l3 2" />
</svg>
);
}
function Check({ size = 16 }: { size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
@@ -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<void> {
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"
>
<span className="text-amber-500">
<CloudOff size={18} />
</span>
{current.key === "scheduled" || current.key === "sendingScheduled" ? (
<span className="text-app-accent">
<Clock size={18} />
</span>
) : (
<span className="text-amber-500">
<CloudOff size={18} />
</span>
)}
{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")}
</div>
);
}
@@ -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);
+230 -38
View File
@@ -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>
)}
@@ -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;
}
+74 -4
View File
@@ -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 (
<button
onClick={onClick}
@@ -36,7 +45,7 @@ function DraftRow({
<div className="flex items-center gap-2">
<span className="flex-1 truncate text-sm text-app-fg">{recipients}</span>
{badge && (
<span className="shrink-0 rounded bg-app-hover px-1.5 text-[12px] text-app-muted">
<span className={`shrink-0 rounded px-1.5 text-[12px] ${tone}`}>
{badge}
</span>
)}
@@ -53,20 +62,28 @@ function DraftRow({
export function DraftsList({
drafts,
remoteDrafts,
deviceDrafts = [],
note,
selectedId,
onOpen,
onOpenRemote,
onOpenDevice,
}: {
drafts: DraftOut[];
remoteDrafts: RemoteDraftOut[];
/** Drafts written offline, kept on this device until the server has them. */
deviceDrafts?: LocalMessage[];
/** A line above the list (offline: only this device's drafts are here). */
note?: string;
/** Id of the draft currently loaded in the reading pane. */
selectedId?: string;
onOpen: (draft: DraftOut) => void;
onOpenRemote: (draft: RemoteDraftOut) => void;
onOpenDevice?: (msg: LocalMessage) => void;
}) {
const t = useT();
if (drafts.length === 0 && remoteDrafts.length === 0) {
return <div className="p-6 text-sm text-app-faint">{t("compose.noDrafts")}</div>;
if (drafts.length === 0 && remoteDrafts.length === 0 && deviceDrafts.length === 0) {
return <div className="p-6 text-sm text-app-faint">{note ?? t("compose.noDrafts")}</div>;
}
// Merge local + remote drafts into one list sorted by date (newest first).
@@ -80,8 +97,20 @@ export function DraftsList({
selected: boolean;
onClick: () => void;
};
// A device draft continuing a server draft stands in for it.
const onDevice = new Set(deviceDrafts.map((m) => m.serverDraftId).filter(Boolean));
const rows: Row[] = [
...drafts.map((d) => ({
...deviceDrafts.map((m) => ({
key: `d-${m.id}`,
date: new Date(m.updatedAt).toISOString(),
recipients: m.input.payload.to.join(", ") || t("compose.noRecipient"),
subject: m.input.payload.subject || t("compose.noSubject"),
preview: m.input.payload.text.trim(),
badge: t("compose.onDevice"),
selected: m.id === selectedId,
onClick: () => onOpenDevice?.(m),
})),
...drafts.filter((d) => !onDevice.has(d.id)).map((d) => ({
key: `l-${d.id}`,
date: d.updated_at,
recipients: d.payload.to.join(", ") || t("compose.noRecipient"),
@@ -106,6 +135,7 @@ export function DraftsList({
return (
<div className="h-full overflow-y-auto" data-testid="drafts-list">
{note && <div className="border-b border-app-border px-6 py-2 text-xs text-app-faint">{note}</div>}
{rows.map((row) => (
<DraftRow
key={row.key}
@@ -121,3 +151,43 @@ export function DraftsList({
</div>
);
}
// The Scheduled folder: messages sent while offline, waiting for the connection.
export function ScheduledList({
messages,
selectedId,
onOpen,
}: {
messages: LocalMessage[];
selectedId?: string;
onOpen: (msg: LocalMessage) => void;
}) {
const t = useT();
if (messages.length === 0) {
return <div className="p-6 text-sm text-app-faint">{t("scheduled.empty")}</div>;
}
return (
<div className="h-full overflow-y-auto" data-testid="scheduled-list">
<div className="border-b border-app-border px-6 py-2 text-xs text-app-faint">{t("scheduled.help")}</div>
{messages.map((m) => (
<DraftRow
key={m.id}
recipients={m.input.payload.to.join(", ") || t("compose.noRecipient")}
subject={m.input.payload.subject || t("compose.noSubject")}
preview={m.state === "failed" && m.error ? m.error : m.input.payload.text.trim()}
time={timeLabel(new Date(m.updatedAt).toISOString())}
badge={
m.state === "failed"
? t("scheduled.failed")
: m.state === "sending"
? t("scheduled.sending")
: t("scheduled.waiting")
}
badgeTone={m.state === "failed" ? "error" : "accent"}
selected={m.id === selectedId}
onClick={() => onOpen(m)}
/>
))}
</div>
);
}
@@ -343,7 +343,7 @@ function MessageItem({
const [showDetails, setShowDetails] = useState(false);
const replyBtn = (kind: string, label: string, icon: string) => (
<button
className="wm-online-only flex items-center gap-1.5 rounded-md border border-app-border px-3 py-1.5 text-xs text-app-fg hover:bg-app-hover"
className="flex items-center gap-1.5 rounded-md border border-app-border px-3 py-1.5 text-xs text-app-fg hover:bg-app-hover"
onClick={() => onReply(kind)}
>
<Icon name={icon} size={14} />
@@ -442,7 +442,7 @@ function MessageItem({
</button>
{canReply && (
<button
className="wm-online-only flex items-center gap-1 text-app-muted hover:text-app-fg"
className="flex items-center gap-1 text-app-muted hover:text-app-fg"
onClick={() => onReply(multiRecipient ? "reply_all" : "reply")}
>
<Icon name={multiRecipient ? "reply-all" : "reply"} size={14} />
+13
View File
@@ -556,6 +556,19 @@ export const en = {
"offline.notAvailable": "Not available offline",
"offline.threadNotSaved": "This conversation isn't saved on this device.",
"offline.folderNotSaved": "This folder isn't saved on this device.",
"offline.noticeScheduled": "Saved to Scheduled — it will be sent when you're back online.",
"offline.noticeSendingScheduled": "Sending {count} scheduled messages…",
"offline.draftsNotSaved": "Only drafts written on this device are available offline.",
"compose.sendLater": "Schedule",
"compose.savedOnDevice": "Saved on this device",
"compose.attachOnDevice": "On this device — uploaded when you're back online",
"compose.onDevice": "On this device",
"nav.scheduled": "Scheduled",
"scheduled.help": "Sent automatically when you're back online.",
"scheduled.empty": "Nothing scheduled.",
"scheduled.waiting": "Waiting",
"scheduled.sending": "Sending…",
"scheduled.failed": "Not sent",
"offline.searchPlaceholder": "Search (offline)",
"offline.cantReach": "Can't reach the server.",
"offline.settingTitle": "Offline access",
+2
View File
@@ -2,6 +2,7 @@ import { type QueryClient, useQuery } from "@tanstack/react-query";
import { api, ApiError } from "./api";
import { clearOfflineData } from "./offline";
import { wipeLocal } from "./scheduled";
export function useMe() {
return useQuery({
@@ -26,6 +27,7 @@ export function signedOut(queryClient: QueryClient) {
// whole cache first would orphan them from the query they watch.
queryClient.setQueryData(["me"], null);
void clearOfflineData(); // mail saved for offline reading goes with the session
void wipeLocal(); // and so does anything written offline
queryClient.removeQueries({ predicate: (q) => q.queryKey[0] !== "me" });
}
+54
View File
@@ -0,0 +1,54 @@
// The short-lived offline messages (components/Offline.tsx shows them): the
// floating notice at the bottom, and the green "Back online" in the sidebar.
// Their own module so anything — the composer, the scheduled sender — can raise one.
import { useSyncExternalStore } from "react";
export type NoticeKey = "offline" | "offlineNotSaved" | "notAvailable" | "scheduled" | "sendingScheduled";
export interface Notice {
key: NoticeKey;
vars?: Record<string, string | number>;
seq: number;
}
let notice: Notice | null = 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;
export function showOfflineNotice(key: NoticeKey, ms: number, vars?: Notice["vars"]): void {
notice = { key, vars, seq: ++noticeSeq };
emit();
window.clearTimeout(noticeTimer);
noticeTimer = window.setTimeout(() => {
notice = null;
emit();
}, ms);
}
export function clearOfflineNotice(): void {
notice = null;
emit();
}
export function useOfflineNotice(): Notice | null {
return useSyncExternalStore(subscribe, () => notice);
}
export function setBackOnline(value: boolean): void {
backOnline = value;
emit();
}
export function useBackOnline(): boolean {
return useSyncExternalStore(subscribe, () => backOnline);
}
+274
View File
@@ -0,0 +1,274 @@
// Messages written while offline, kept on this device (IndexedDB) until the
// server can take them:
// - "draft": written offline and not sent — becomes a normal server draft
// once the connection is back;
// - "scheduled": Send was pressed offline — sent as soon as the connection is
// back (the "Scheduled" folder);
// - "sending": being handed to the server right now;
// - "failed": the server refused it (shown in Scheduled with the reason).
//
// Files attached offline are kept here too, and uploaded when the message goes
// to the server. Nothing here leaves this device until then.
import { useEffect, useSyncExternalStore } from "react";
import { api, ApiError, isOfflineError } from "./api";
import type { AttachmentOut, DraftInput } from "./types";
export type LocalState = "draft" | "scheduled" | "sending" | "failed";
export interface LocalFile {
id: string;
name: string;
type: string;
size: number;
}
export interface LocalMessage {
id: string;
state: LocalState;
/** The draft as the server takes it; attachment_ids are server attachments. */
input: DraftInput;
/** Those server attachments' names and sizes, to show them offline. */
attachments: AttachmentOut[];
/** Files attached offline, uploaded when the message reaches the server. */
files: LocalFile[];
/** The server draft this continues (opened from Drafts, then went offline). */
serverDraftId: string | null;
error?: string;
updatedAt: number;
}
export interface ScheduledSent {
outboxId: string;
draftId: string;
undoSeconds: number;
}
const DB_NAME = "wm-offline";
const MESSAGES = "messages";
const FILES = "files";
// ---- IndexedDB, promised ---------------------------------------------------------
let dbPromise: Promise<IDBDatabase> | null = null;
function db(): Promise<IDBDatabase> {
dbPromise ??= new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, 1);
req.onupgradeneeded = () => {
req.result.createObjectStore(MESSAGES, { keyPath: "id" });
req.result.createObjectStore(FILES, { keyPath: "id" });
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => {
dbPromise = null;
reject(req.error);
};
});
return dbPromise;
}
function done<T>(req: IDBRequest<T>): Promise<T> {
return new Promise((resolve, reject) => {
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
async function store(name: string, mode: IDBTransactionMode): Promise<IDBObjectStore> {
return (await db()).transaction(name, mode).objectStore(name);
}
// ---- change notification (this tab, and the others) -------------------------------
const listeners = new Set<() => void>();
const channel = "BroadcastChannel" in window ? new BroadcastChannel("wm-local-messages") : null;
let snapshot: LocalMessage[] = [];
let loaded = false;
async function reload(): Promise<void> {
try {
const all = (await done((await store(MESSAGES, "readonly")).getAll())) as LocalMessage[];
snapshot = all.sort((a, b) => b.updatedAt - a.updatedAt);
} catch {
snapshot = [];
}
loaded = true;
for (const l of listeners) l();
}
function changed(): void {
channel?.postMessage("changed");
void reload();
}
channel?.addEventListener("message", () => void reload());
/** Everything kept on this device, newest first (live). */
export function useLocalMessages(): LocalMessage[] {
useEffect(() => {
if (!loaded) void reload();
}, []);
return useSyncExternalStore(
(l) => {
listeners.add(l);
return () => {
listeners.delete(l);
};
},
() => snapshot,
);
}
// ---- reading and writing ---------------------------------------------------------
export function newLocalId(): string {
return `local-${crypto.randomUUID()}`;
}
export async function listLocal(): Promise<LocalMessage[]> {
return (await done((await store(MESSAGES, "readonly")).getAll())) as LocalMessage[];
}
export async function putLocal(msg: LocalMessage): Promise<void> {
await done((await store(MESSAGES, "readwrite")).put(msg));
changed();
}
export async function deleteLocal(id: string): Promise<void> {
const msg = (await done((await store(MESSAGES, "readonly")).get(id))) as LocalMessage | undefined;
if (msg) await deleteFiles(msg.files.map((f) => f.id));
await done((await store(MESSAGES, "readwrite")).delete(id));
changed();
}
/** Keep a file attached offline; returns what the composer shows for it. */
export async function putFile(file: File): Promise<LocalFile> {
const meta = { id: crypto.randomUUID(), name: file.name, type: file.type, size: file.size };
await done((await store(FILES, "readwrite")).put({ ...meta, blob: file }));
return meta;
}
export async function deleteFiles(ids: string[]): Promise<void> {
if (!ids.length) return;
const s = await store(FILES, "readwrite");
await Promise.all(ids.map((id) => done(s.delete(id))));
}
async function getFile(meta: LocalFile): Promise<File | null> {
const row = (await done((await store(FILES, "readonly")).get(meta.id))) as
| (LocalFile & { blob: Blob })
| undefined;
return row ? new File([row.blob], meta.name, { type: meta.type }) : null;
}
/** Upload files attached offline; returns the server attachments they became. */
export async function uploadFiles(files: LocalFile[]): Promise<AttachmentOut[]> {
const out: AttachmentOut[] = [];
for (const meta of files) {
const file = await getFile(meta);
if (file) out.push(await api.uploadComposeAttachment(file));
}
return out;
}
/** Forget everything kept here (signed out: it must not outlive the session). */
export async function wipeLocal(): Promise<void> {
try {
await done((await store(MESSAGES, "readwrite")).clear());
await done((await store(FILES, "readwrite")).clear());
} catch {
/* nothing kept */
}
changed();
}
// Composer windows holding a local message open: those save it themselves.
const openHere = new Set<string>();
export function holdOpen(id: string): () => void {
openHere.add(id);
return () => {
openHere.delete(id);
};
}
// ---- handing everything to the server -------------------------------------------
/** Make a local message a server draft (files uploaded first); returns its id. */
async function toServerDraft(msg: LocalMessage): Promise<string> {
if (msg.files.length) {
const uploaded = await uploadFiles(msg.files);
await deleteFiles(msg.files.map((f) => f.id));
msg.attachments = [...msg.attachments, ...uploaded];
msg.input = {
...msg.input,
payload: { ...msg.input.payload, attachment_ids: msg.attachments.map((a) => a.id) },
};
msg.files = [];
await putLocal(msg); // never upload the same files twice
}
if (msg.serverDraftId) {
await api.updateDraft(msg.serverDraftId, msg.input);
} else {
msg.serverDraftId = (await api.createDraft(msg.input)).id;
await putLocal(msg); // and never create the draft twice
}
return msg.serverDraftId;
}
async function flushOne(msg: LocalMessage, sent: ScheduledSent[]): Promise<void> {
if (msg.state === "draft") {
const id = await toServerDraft(msg);
void api.publishDraft(id).catch(() => {}); // like a composer closed online
await deleteLocal(msg.id);
return;
}
msg.state = "sending";
await putLocal(msg);
try {
const draftId = await toServerDraft(msg);
const res = await api.send(draftId);
await deleteLocal(msg.id);
sent.push({ outboxId: res.outbox_id, draftId, undoSeconds: res.undo_seconds });
} catch (err) {
msg.state = isOfflineError(err) ? "scheduled" : "failed";
msg.error = err instanceof ApiError ? err.message : String(err);
await putLocal(msg);
throw err;
}
}
let flushing: Promise<ScheduledSent[]> | null = null;
/**
* Hand everything kept here to the server: scheduled messages are sent, drafts
* become server drafts. Under a lock, so two open tabs never send one message
* twice. Stops at the first sign of being offline again.
*/
export function flushLocal(): Promise<ScheduledSent[]> {
const run = async (): Promise<ScheduledSent[]> => {
const sent: ScheduledSent[] = [];
const all = (await listLocal()).sort((a, b) => a.updatedAt - b.updatedAt);
for (const msg of all) {
if (msg.state === "failed") continue;
if (openHere.has(msg.id)) continue; // open in a composer here: it decides
try {
await flushOne(msg, sent);
} catch (err) {
if (isOfflineError(err)) break;
}
}
return sent;
};
flushing ??= (async () => {
try {
const locks = (navigator as Navigator & { locks?: LockManager }).locks;
return locks ? await locks.request("wm-local-flush", run) : await run();
} finally {
flushing = null;
}
})();
return flushing;
}
+2
View File
@@ -308,6 +308,8 @@ export interface DraftOut {
export interface SendResponse {
outbox_id: string;
undo_until: string;
/** Seconds left in the undo window (count down from this, not the clock). */
undo_seconds: number;
}
export interface RemoteDraftOut {
+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