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 from __future__ import annotations
import uuid import uuid
from datetime import timedelta from datetime import UTC, datetime, timedelta
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Depends, File, Query, UploadFile 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), str(outbox.id),
_defer_by=timedelta(seconds=get_settings().undo_seconds), _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") @router.post("/send/{outbox_id}/undo")
+3
View File
@@ -56,6 +56,9 @@ class SendRequest(BaseModel):
class SendResponse(BaseModel): class SendResponse(BaseModel):
outbox_id: uuid.UUID outbox_id: uuid.UUID
undo_until: str 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): 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()) resp = await ctx.client.post("/api/send", json={"draft_id": did}, headers=_hdr())
assert resp.status_code == 200 assert resp.status_code == 200
outbox_id = resp.json()["outbox_id"] 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)) ob = await ctx.session.get(Outbox, uuid.UUID(outbox_id))
assert ob is not None and ob.state == "undo_window" assert ob is not None and ob.state == "undo_window"
+87 -41
View File
@@ -5,10 +5,11 @@
// - OfflineIndicator (the sidebar header): the amber "Offline" cloud, with an // - OfflineIndicator (the sidebar header): the amber "Offline" cloud, with an
// explanation on click, and a short green "Back online" when it returns. // explanation on click, and a short green "Back online" when it returns.
import { useQueryClient } from "@tanstack/react-query"; import { type QueryClient, useQueryClient } from "@tanstack/react-query";
import { useEffect, useRef, useState, useSyncExternalStore } from "react"; import { useEffect, useRef, useState } from "react";
import { useT } from "../i18n"; import { useT } from "../i18n";
import { isOfflineError } from "../lib/api";
import { import {
isOffline, isOffline,
offlineEnabled, offlineEnabled,
@@ -19,40 +20,22 @@ import {
useOffline, useOffline,
useOfflineEnabled, useOfflineEnabled,
} from "../lib/offline"; } from "../lib/offline";
import {
clearOfflineNotice,
setBackOnline,
showOfflineNotice,
useBackOnline,
useOfflineNotice,
} from "../lib/offlineNotice";
import { prefetchRecent, serverAnswers } from "../lib/offlinePrefetch"; import { prefetchRecent, serverAnswers } from "../lib/offlinePrefetch";
import { flushLocal, type ScheduledSent } from "../lib/scheduled";
import { subscribeWs } from "../lib/ws"; import { subscribeWs } from "../lib/ws";
import { hasUndoHost, offerUndo } from "./compose/undoSend";
const PROBE_MS = 10_000; const PROBE_MS = 10_000;
const BACK_ONLINE_MS = 3_000; const BACK_ONLINE_MS = 3_000;
const REFRESH_MS = 15 * 60_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 }) { function CloudOff({ size = 16 }: { size?: number }) {
return ( return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round" aria-hidden> <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 }) { function Check({ size = 16 }: { size?: number }) {
return ( return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden> <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 ------------------------------------------------------------ // ---- 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() { export function OfflineController() {
const t = useT(); const t = useT();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const offline = useOffline(); const offline = useOffline();
const enabled = useOfflineEnabled(); const enabled = useOfflineEnabled();
const current = useSyncExternalStore(subscribe, () => notice); const current = useOfflineNotice();
const wasOffline = useRef(false); const wasOffline = useRef(false);
// Install (or remove) the service worker to match this device's setting. // Install (or remove) the service worker to match this device's setting.
@@ -90,23 +103,35 @@ export function OfflineController() {
useEffect(() => { useEffect(() => {
if (offline) { if (offline) {
wasOffline.current = true; wasOffline.current = true;
showNotice(offlineEnabled() ? "offline" : "offlineNotSaved", 6000); showOfflineNotice(offlineEnabled() ? "offline" : "offlineNotSaved", 6000);
return; return;
} }
if (!wasOffline.current) return; if (!wasOffline.current) return;
wasOffline.current = false; wasOffline.current = false;
notice = null; clearOfflineNotice();
backOnline = true; setBackOnline(true);
emit(); const timer = window.setTimeout(() => setBackOnline(false), BACK_ONLINE_MS);
const timer = window.setTimeout(() => {
backOnline = false;
emit();
}, BACK_ONLINE_MS);
void queryClient.invalidateQueries(); void queryClient.invalidateQueries();
void sendScheduled(queryClient);
void prefetchRecent(); void prefetchRecent();
return () => window.clearTimeout(timer); return () => window.clearTimeout(timer);
}, [offline, queryClient]); }, [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. // While offline: ask the server now and then whether it is back.
useEffect(() => { useEffect(() => {
if (!offline) return; if (!offline) return;
@@ -151,10 +176,21 @@ export function OfflineController() {
if (!el) return; if (!el) return;
e.preventDefault(); e.preventDefault();
e.stopPropagation(); 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); 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; if (!current) return null;
@@ -164,13 +200,23 @@ export function OfflineController() {
role="status" 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" 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" ? (
<span className="text-app-accent">
<Clock size={18} />
</span>
) : (
<span className="text-amber-500"> <span className="text-amber-500">
<CloudOff size={18} /> <CloudOff size={18} />
</span> </span>
)}
{current.key === "offline" {current.key === "offline"
? t("offline.noticeSaved") ? t("offline.noticeSaved")
: current.key === "offlineNotSaved" : current.key === "offlineNotSaved"
? t("offline.noticeNotSaved") ? t("offline.noticeNotSaved")
: current.key === "scheduled"
? t("offline.noticeScheduled")
: current.key === "sendingScheduled"
? t("offline.noticeSendingScheduled", current.vars)
: t("offline.notAvailable")} : t("offline.notAvailable")}
</div> </div>
); );
@@ -182,7 +228,7 @@ export function OfflineIndicator({ compact = false }: { compact?: boolean }) {
const t = useT(); const t = useT();
const offline = useOffline(); const offline = useOffline();
const enabled = useOfflineEnabled(); const enabled = useOfflineEnabled();
const back = useSyncExternalStore(subscribe, () => backOnline); const back = useBackOnline();
const last = useLastSync(); const last = useLastSync();
const when = savedWhen(last); const when = savedWhen(last);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
+228 -36
View File
@@ -2,8 +2,22 @@ import { useQuery } from "@tanstack/react-query";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useT } from "../../i18n"; 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 type { AccountOut, AttachmentOut, DraftInput, DraftOut } from "../../lib/types";
import { showOfflineNotice } from "../../lib/offlineNotice";
import { RecipientChips } from "./RecipientChips"; import { RecipientChips } from "./RecipientChips";
import { RichTextEditor, type RichTextEditorHandle } from "./RichTextEditor"; import { RichTextEditor, type RichTextEditorHandle } from "./RichTextEditor";
import { hasUndoHost, offerUndo } from "./undoSend"; import { hasUndoHost, offerUndo } from "./undoSend";
@@ -17,6 +31,7 @@ export interface ComposerInit {
kind: string; // reply | reply_all | forward | new kind: string; // reply | reply_all | forward | new
to?: string; to?: string;
cc?: string; cc?: string;
bcc?: string;
subject?: string; subject?: string;
text?: string; text?: string;
html?: string; // initial body when reopening an existing draft 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 // A reply/forward quotes the original: this attribution line sits above the
// quoted message body (fetched from the original message's sanitized HTML). // quoted message body (fetched from the original message's sanitized HTML).
quote?: { attribution: string; messageId: string }; 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 // 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[] { function splitList(value: string): string[] {
return value return value
.split(",") .split(",")
@@ -80,11 +123,13 @@ function HeaderButton({
onClick, onClick,
title, title,
active = false, active = false,
className = "",
children, children,
}: { }: {
onClick?: () => void; onClick?: () => void;
title: string; title: string;
active?: boolean; active?: boolean;
className?: string;
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
@@ -93,7 +138,7 @@ function HeaderButton({
title={title} title={title}
aria-label={title} aria-label={title}
onClick={onClick} 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" 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 [accountId, setAccountId] = useState(init.accountId);
const [to, setTo] = useState(init.to ?? ""); const [to, setTo] = useState(init.to ?? "");
const [cc, setCc] = useState(init.cc ?? ""); const [cc, setCc] = useState(init.cc ?? "");
const [bcc, setBcc] = useState(""); const [bcc, setBcc] = useState(init.bcc ?? "");
const [showCc, setShowCc] = useState(Boolean(init.cc)); const [showCc, setShowCc] = useState(Boolean(init.cc || init.bcc));
const [subject, setSubject] = useState(init.subject ?? ""); const [subject, setSubject] = useState(init.subject ?? "");
const [bodyHtml, setBodyHtml] = useState(init.html ?? ""); const [bodyHtml, setBodyHtml] = useState(init.html ?? "");
const [quoteHtml, setQuoteHtml] = useState(""); const [quoteHtml, setQuoteHtml] = useState("");
@@ -176,9 +221,12 @@ export function Composer({
}); });
const moreRef = useRef<HTMLDivElement>(null); const moreRef = useRef<HTMLDivElement>(null);
const fromRef = 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 [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 [undo, setUndo] = useState<{ outboxId: string; until: number } | null>(null);
const [undoLeft, setUndoLeft] = useState(0); const [undoLeft, setUndoLeft] = useState(0);
// Tick the undo countdown while the window is open. // Tick the undo countdown while the window is open.
@@ -199,6 +247,11 @@ export function Composer({
onTitleRef.current?.(subject); onTitleRef.current?.(subject);
}, [subject]); }, [subject]);
const draftId = useRef<string | null>(init.draftId ?? null); 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 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 // 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). // must not re-save/re-mirror it (that spawned duplicate IMAP copies).
@@ -249,6 +302,8 @@ export function Composer({
(async () => { (async () => {
try { try {
const resp = await fetch(api.messageHtmlUrl(quote.messageId), { credentials: "include" }); 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"); const doc = new DOMParser().parseFromString(await resp.text(), "text/html");
if (!cancelled) setQuoteHtml(doc.body?.innerHTML ?? ""); if (!cancelled) setQuoteHtml(doc.body?.innerHTML ?? "");
} catch { } catch {
@@ -265,7 +320,7 @@ export function Composer({
// Resolve attachment metadata when reopening a draft that already has some. // Resolve attachment metadata when reopening a draft that already has some.
useEffect(() => { useEffect(() => {
const ids = init.attachmentIds ?? []; const ids = init.attachmentIds ?? [];
if (ids.length === 0) return; if (ids.length === 0 || init.attachments) return;
let cancelled = false; let cancelled = false;
(async () => { (async () => {
try { try {
@@ -290,6 +345,12 @@ export function Composer({
continue; continue;
} }
try { 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); const att = await api.uploadComposeAttachment(file);
setAttachments((prev) => [...prev, att]); setAttachments((prev) => [...prev, att]);
} catch (e) { } catch (e) {
@@ -303,6 +364,12 @@ export function Composer({
setAttachments((prev) => prev.filter((a) => a.id !== id)); 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 // Drag & drop: files dropped on the writing area insert inline (images) or
// attach (other types); dropped anywhere above it, everything attaches. // attach (other types); dropped anywhere above it, everything attaches.
const editorAreaRef = useRef<HTMLDivElement>(null); const editorAreaRef = useRef<HTMLDivElement>(null);
@@ -393,7 +460,7 @@ export function Composer({
const bodyReady = quoteResolved && accountsQuery.data !== undefined; const bodyReady = quoteResolved && accountsQuery.data !== undefined;
const payload = (): DraftInput => { const payload = (atts: AttachmentOut[] = attachments): DraftInput => {
const html = bodyHtml || initialHtml; const html = bodyHtml || initialHtml;
return { return {
account_id: accountId, account_id: accountId,
@@ -407,31 +474,94 @@ export function Composer({
subject, subject,
html, html,
text: htmlToText(html), text: htmlToText(html),
attachment_ids: attachments.map((a) => a.id), attachment_ids: atts.map((a) => a.id),
}, },
}; };
}; };
useEffect(() => { // Keep this message on the device (offline): as a draft, or for Scheduled.
if (undo) return; const saveLocal = async (state: LocalState) => {
const handle = window.setTimeout(async () => { localId.current ??= newLocalId();
if (!dirtyRef.current) return; // nothing the user changed -> don't touch the draft const msg: LocalMessage = {
if (!to && !subject && !bodyHtml) return; 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"); setStatus("saving");
try { try {
if (draftId.current) { if (isOffline()) throw new ApiError(0, "offline", "offline");
await api.updateDraft(draftId.current, payload()); await saveToServer();
} else {
draftId.current = (await api.createDraft(payload())).id;
}
setStatus("saved"); setStatus("saved");
} catch (e) {
if (!isOfflineError(e)) {
setStatus("error");
return;
}
try {
await saveLocal(localState.current);
setHeldId(localId.current);
setStatus("savedLocal");
} catch { } catch {
setStatus("error"); 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); }, 2000);
return () => window.clearTimeout(handle); return () => window.clearTimeout(handle);
// eslint-disable-next-line react-hooks/exhaustive-deps // 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( useEffect(
() => () => { () => () => {
@@ -439,25 +569,57 @@ export function Composer({
// On close, mirror the draft to the IMAP Drafts folder so it can be // 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 // continued on any mail client — but only if the user actually edited it
// (skip if it was sent, discarded, or merely opened to view). // (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(() => {}); 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 () => { const send = async () => {
setStatus("sending"); setStatus("sending");
sentRef.current = true; // don't also mirror to Drafts on close sentRef.current = true; // don't also mirror to Drafts on close
if (isOffline()) {
await schedule();
return;
}
try { try {
if (!draftId.current) draftId.current = (await api.createDraft(payload())).id; await saveToServer();
else await api.updateDraft(draftId.current, payload()); const sentDraft = draftId.current!;
const res = await api.send(draftId.current); const res = await api.send(sentDraft);
// Drive the countdown + auto-close off the server's undo deadline. // Drive the countdown + auto-close off the server's undo window, as a
const until = new Date(res.undo_until).getTime(); // 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. // With a toast to carry the undo offer, the window closes right away.
if (hasUndoHost()) { if (hasUndoHost()) {
offerUndo({ outboxId: res.outbox_id, draftId: draftId.current, until }); offerUndo({ outboxId: res.outbox_id, draftId: sentDraft, until });
onClose(); onClose();
return; return;
} }
@@ -466,6 +628,10 @@ export function Composer({
setUndoLeft(Math.ceil(remaining / 1000)); setUndoLeft(Math.ceil(remaining / 1000));
undoTimer.current = window.setTimeout(onClose, remaining); undoTimer.current = window.setTimeout(onClose, remaining);
} catch (e) { } catch (e) {
if (isOfflineError(e)) {
await schedule();
return;
}
sentRef.current = false; sentRef.current = false;
setStatus("error"); setStatus("error");
if (!(e instanceof ApiError)) throw e; if (!(e instanceof ApiError)) throw e;
@@ -486,7 +652,14 @@ export function Composer({
}; };
const discard = async () => { 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 { try {
await api.deleteDraft(draftId.current); await api.deleteDraft(draftId.current);
} catch { } catch {
@@ -500,7 +673,7 @@ export function Composer({
// X button: if there's a draft worth keeping, ask before closing; otherwise // X button: if there's a draft worth keeping, ask before closing; otherwise
// (nothing typed, no draft) just close. // (nothing typed, no draft) just close.
const requestClose = () => { const requestClose = () => {
if (!dirtyRef.current && !draftId.current) { if (!dirtyRef.current && !draftId.current && !localId.current) {
onClose(); onClose();
return; return;
} }
@@ -513,10 +686,7 @@ export function Composer({
const saveDraftAndClose = async () => { const saveDraftAndClose = async () => {
setShowClosePrompt(false); setShowClosePrompt(false);
try { try {
if (dirtyRef.current) { if (dirtyRef.current) await autosave();
if (draftId.current) await api.updateDraft(draftId.current, payload());
else draftId.current = (await api.createDraft(payload())).id;
}
} catch { } catch {
/* the close-time IMAP mirror is best-effort too */ /* the close-time IMAP mirror is best-effort too */
} }
@@ -639,7 +809,7 @@ export function Composer({
</svg> </svg>
{/* Phone width: the icon alone. */} {/* Phone width: the icon alone. */}
<span className="max-md:hidden"> <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> </span>
</button> </button>
<HeaderButton onClick={() => fileInputRef.current?.click()} title={t("compose.attach")}> <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"> <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 === "saving" && <span className="mr-1">{t("compose.savingDraft")}</span>}
{!minimized && status === "saved" && <span className="mr-1">{t("compose.draftSaved")}</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" && ( {!minimized && status === "error" && (
<span className="mr-1 text-red-500">{t("compose.sendFailed")}</span> <span className="mr-1 text-red-500">{t("compose.sendFailed")}</span>
)} )}
@@ -793,7 +964,7 @@ export function Composer({
{!fullWindow && ( {!fullWindow && (
// No second browser window to pop out into on a phone. // No second browser window to pop out into on a phone.
<div className="contents max-md:hidden"> <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"> <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" /> <path d="M6 3H3v10h10v-3M9 3h4v4M13 3L7 9" />
</svg> </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"> <div className="flex flex-wrap items-center gap-2 border-b border-app-border px-3 py-2">
{attachments.map((att) => ( {attachments.map((att) => (
<span <span
@@ -920,6 +1091,27 @@ export function Composer({
</button> </button>
</span> </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>} {attachError && <span className="text-xs text-red-500">{attachError}</span>}
</div> </div>
)} )}
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react";
import { useT } from "../../i18n"; import { useT } from "../../i18n";
import { api } from "../../lib/api"; import { api } from "../../lib/api";
import { isOffline } from "../../lib/offline";
import type { RecipientSuggestion } from "../../lib/types"; import type { RecipientSuggestion } from "../../lib/types";
function splitList(value: string): string[] { function splitList(value: string): string[] {
@@ -76,7 +77,8 @@ export function RecipientChips({
const removeAt = (i: number) => onChange(chips.filter((_, idx) => idx !== i).join(", ")); const removeAt = (i: number) => onChange(chips.filter((_, idx) => idx !== i).join(", "));
useEffect(() => { useEffect(() => {
if (draft.trim().length < 2) { // Suggestions come from the server: none offline (and no doomed request).
if (draft.trim().length < 2 || isOffline()) {
setSuggestions([]); setSuggestions([]);
return; return;
} }
+74 -4
View File
@@ -1,5 +1,6 @@
import { useT } from "../../i18n"; import { useT } from "../../i18n";
import { formatClock, getDateLocale } from "../../lib/timeFormat"; import { formatClock, getDateLocale } from "../../lib/timeFormat";
import type { LocalMessage } from "../../lib/scheduled";
import type { DraftOut, RemoteDraftOut } from "../../lib/types"; import type { DraftOut, RemoteDraftOut } from "../../lib/types";
function timeLabel(iso: string): string { function timeLabel(iso: string): string {
@@ -15,6 +16,7 @@ function DraftRow({
preview, preview,
time, time,
badge, badge,
badgeTone,
selected, selected,
onClick, onClick,
}: { }: {
@@ -23,9 +25,16 @@ function DraftRow({
preview: string; preview: string;
time: string; time: string;
badge?: string; badge?: string;
badgeTone?: "muted" | "accent" | "error";
selected: boolean; selected: boolean;
onClick: () => void; 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 ( return (
<button <button
onClick={onClick} onClick={onClick}
@@ -36,7 +45,7 @@ function DraftRow({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="flex-1 truncate text-sm text-app-fg">{recipients}</span> <span className="flex-1 truncate text-sm text-app-fg">{recipients}</span>
{badge && ( {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} {badge}
</span> </span>
)} )}
@@ -53,20 +62,28 @@ function DraftRow({
export function DraftsList({ export function DraftsList({
drafts, drafts,
remoteDrafts, remoteDrafts,
deviceDrafts = [],
note,
selectedId, selectedId,
onOpen, onOpen,
onOpenRemote, onOpenRemote,
onOpenDevice,
}: { }: {
drafts: DraftOut[]; drafts: DraftOut[];
remoteDrafts: RemoteDraftOut[]; 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. */ /** Id of the draft currently loaded in the reading pane. */
selectedId?: string; selectedId?: string;
onOpen: (draft: DraftOut) => void; onOpen: (draft: DraftOut) => void;
onOpenRemote: (draft: RemoteDraftOut) => void; onOpenRemote: (draft: RemoteDraftOut) => void;
onOpenDevice?: (msg: LocalMessage) => void;
}) { }) {
const t = useT(); const t = useT();
if (drafts.length === 0 && remoteDrafts.length === 0) { if (drafts.length === 0 && remoteDrafts.length === 0 && deviceDrafts.length === 0) {
return <div className="p-6 text-sm text-app-faint">{t("compose.noDrafts")}</div>; 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). // Merge local + remote drafts into one list sorted by date (newest first).
@@ -80,8 +97,20 @@ export function DraftsList({
selected: boolean; selected: boolean;
onClick: () => void; 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[] = [ 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}`, key: `l-${d.id}`,
date: d.updated_at, date: d.updated_at,
recipients: d.payload.to.join(", ") || t("compose.noRecipient"), recipients: d.payload.to.join(", ") || t("compose.noRecipient"),
@@ -106,6 +135,7 @@ export function DraftsList({
return ( return (
<div className="h-full overflow-y-auto" data-testid="drafts-list"> <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) => ( {rows.map((row) => (
<DraftRow <DraftRow
key={row.key} key={row.key}
@@ -121,3 +151,43 @@ export function DraftsList({
</div> </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 [showDetails, setShowDetails] = useState(false);
const replyBtn = (kind: string, label: string, icon: string) => ( const replyBtn = (kind: string, label: string, icon: string) => (
<button <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)} onClick={() => onReply(kind)}
> >
<Icon name={icon} size={14} /> <Icon name={icon} size={14} />
@@ -442,7 +442,7 @@ function MessageItem({
</button> </button>
{canReply && ( {canReply && (
<button <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")} onClick={() => onReply(multiRecipient ? "reply_all" : "reply")}
> >
<Icon name={multiRecipient ? "reply-all" : "reply"} size={14} /> <Icon name={multiRecipient ? "reply-all" : "reply"} size={14} />
+13
View File
@@ -556,6 +556,19 @@ export const en = {
"offline.notAvailable": "Not available offline", "offline.notAvailable": "Not available offline",
"offline.threadNotSaved": "This conversation isn't saved on this device.", "offline.threadNotSaved": "This conversation isn't saved on this device.",
"offline.folderNotSaved": "This folder 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.searchPlaceholder": "Search (offline)",
"offline.cantReach": "Can't reach the server.", "offline.cantReach": "Can't reach the server.",
"offline.settingTitle": "Offline access", "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 { api, ApiError } from "./api";
import { clearOfflineData } from "./offline"; import { clearOfflineData } from "./offline";
import { wipeLocal } from "./scheduled";
export function useMe() { export function useMe() {
return useQuery({ return useQuery({
@@ -26,6 +27,7 @@ export function signedOut(queryClient: QueryClient) {
// whole cache first would orphan them from the query they watch. // whole cache first would orphan them from the query they watch.
queryClient.setQueryData(["me"], null); queryClient.setQueryData(["me"], null);
void clearOfflineData(); // mail saved for offline reading goes with the session 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" }); 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 { export interface SendResponse {
outbox_id: string; outbox_id: string;
undo_until: string; undo_until: string;
/** Seconds left in the undo window (count down from this, not the clock). */
undo_seconds: number;
} }
export interface RemoteDraftOut { export interface RemoteDraftOut {
+53 -15
View File
@@ -1,11 +1,11 @@
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 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 { Link, useMatch, useNavigate, useSearchParams } from "react-router-dom";
import signetLogo from "../../icons/ultimo-logo-signet.svg"; import signetLogo from "../../icons/ultimo-logo-signet.svg";
import { AppSwitcher } from "../components/AppSwitcher"; import { AppSwitcher } from "../components/AppSwitcher";
import { ComposeDock } from "../components/compose/ComposeDock"; 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 { UndoSendToast } from "../components/compose/UndoSendToast";
import { usePendingSend } from "../components/compose/undoSend"; import { usePendingSend } from "../components/compose/undoSend";
import { import {
@@ -17,14 +17,14 @@ import {
restoreWindow, restoreWindow,
toggleExpand, toggleExpand,
} from "../components/compose/windows"; } from "../components/compose/windows";
import { DraftsList } from "../components/list/DraftsList"; import { DraftsList, ScheduledList } from "../components/list/DraftsList";
import { Icon } from "../components/Icon"; import { Icon } from "../components/Icon";
import { MessageList } from "../components/list/MessageList"; import { MessageList } from "../components/list/MessageList";
import { Resizer } from "../components/Resizer"; import { Resizer } from "../components/Resizer";
import { ThemeToggle } from "../components/ThemeToggle"; import { ThemeToggle } from "../components/ThemeToggle";
import { EmptyIllustration } from "../components/EmptyIllustration"; import { EmptyIllustration } from "../components/EmptyIllustration";
import { BackfillBanner } from "../components/BackfillBanner"; 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 { ThreadView } from "../components/thread/ThreadView";
import { MailboxDisplay } from "../components/mail/MailboxDisplay"; import { MailboxDisplay } from "../components/mail/MailboxDisplay";
import { RightPanel, type PanelTab } from "../components/mail/RightPanel"; import { RightPanel, type PanelTab } from "../components/mail/RightPanel";
@@ -32,6 +32,7 @@ import { useT } from "../i18n";
import type { MessageKey } from "../i18n/en"; import type { MessageKey } from "../i18n/en";
import { api, isOfflineError } from "../lib/api"; import { api, isOfflineError } from "../lib/api";
import { clearOfflineData, useOffline } from "../lib/offline"; import { clearOfflineData, useOffline } from "../lib/offline";
import { flushLocal, useLocalMessages, wipeLocal } from "../lib/scheduled";
import { setTimeFormat } from "../lib/timeFormat"; import { setTimeFormat } from "../lib/timeFormat";
import type { QuotaOut, SavedSearch, ThreadSummary } from "../lib/types"; import type { QuotaOut, SavedSearch, ThreadSummary } from "../lib/types";
import { useReadingPane } from "../lib/readingPane"; import { useReadingPane } from "../lib/readingPane";
@@ -217,6 +218,12 @@ export function MailLayout() {
useEffect(() => localStorage.setItem("mail.listOrder", listOrder), [listOrder]); useEffect(() => localStorage.setItem("mail.listOrder", listOrder), [listOrder]);
const isDrafts = view === "folder:drafts"; 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). // Which special-use folder the reading pane is showing (drives the toolbar).
const currentFolder = view.startsWith("folder:") ? view.slice("folder:".length) : "inbox"; const currentFolder = view.startsWith("folder:") ? view.slice("folder:".length) : "inbox";
const accountsQuery = useQuery({ queryKey: ["accounts"], queryFn: api.accounts }); 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 }); 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 placeholderData: keepPreviousData, // keep the current page visible while the next loads
}); });
const draftsQuery = useQuery({ const draftsQuery = useQuery({
@@ -798,7 +805,7 @@ export function MailLayout() {
</div> </div>
<div className={`pb-2 pt-2.5 ${collapsed ? "px-2" : "px-3"} ${mobile ? "hidden" : ""}`}> <div className={`pb-2 pt-2.5 ${collapsed ? "px-2" : "px-3"} ${mobile ? "hidden" : ""}`}>
<button <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} title={collapsed ? t("nav.compose") : undefined}
onClick={() => accounts[0] && openComposer({ accountId: accounts[0].id, kind: "new" })} onClick={() => accounts[0] && openComposer({ accountId: accounts[0].id, kind: "new" })}
> >
@@ -812,11 +819,11 @@ export function MailLayout() {
f.view === "folder:inbox" f.view === "folder:inbox"
? countsQuery.data?.inbox_unread ? countsQuery.data?.inbox_unread
: f.view === "folder:drafts" : f.view === "folder:drafts"
? countsQuery.data?.drafts ? (countsQuery.data?.drafts ?? 0) + deviceDrafts.length || undefined
: undefined; : undefined;
return ( return (
<Fragment key={f.view}>
<SidebarItem <SidebarItem
key={f.view}
active={view === f.view} active={view === f.view}
collapsed={collapsed} collapsed={collapsed}
title={collapsed ? t(f.label) : undefined} 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> <span className="ml-auto shrink-0 tabular-nums text-[13px] text-app-muted">{badge}</span>
) : null} ) : null}
</SidebarItem> </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. */} {/* Per-account views are hidden entirely when collapsed. */}
@@ -912,8 +937,12 @@ export function MailLayout() {
}`} }`}
title={collapsed ? t("common.signOut") : undefined} title={collapsed ? t("common.signOut") : undefined}
onClick={async () => { 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 api.logout();
await clearOfflineData(); await clearOfflineData();
await wipeLocal();
queryClient.clear(); queryClient.clear();
navigate("/login"); navigate("/login");
}} }}
@@ -1036,7 +1065,7 @@ export function MailLayout() {
</button> </button>
)} )}
</div> </div>
{!isDrafts && ( {!isDrafts && !isScheduled && (
<div <div
className={`flex h-[52px] shrink-0 items-center gap-5 border-b border-app-border bg-app-bg text-[15px] ${ 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" mobile ? "px-3" : "px-6"
@@ -1156,14 +1185,23 @@ export function MailLayout() {
</div> </div>
)} )}
<div className="min-h-0 flex-1"> <div className="min-h-0 flex-1">
{isDrafts && !term.trim() && !draftsQuery.data && isOfflineError(draftsQuery.error) ? ( {isScheduled ? (
<div className="p-6 text-[15px] text-app-faint">{t("offline.folderNotSaved")}</div> <ScheduledList
messages={scheduled}
selectedId={paneDraft?.localId}
onOpen={(msg) => openDraftInPane(localToInit(msg))}
/>
) : isDrafts && !term.trim() ? ( ) : isDrafts && !term.trim() ? (
<DraftsList <DraftsList
drafts={draftsQuery.data ?? []} drafts={draftsQuery.data ?? []}
remoteDrafts={remoteDraftsQuery.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))} onOpen={(draft) => openDraftInPane(draftToInit(draft))}
onOpenDevice={(msg) => openDraftInPane(localToInit(msg))}
onOpenRemote={async (remote) => { onOpenRemote={async (remote) => {
// Adopt the server draft into an editable local draft, then open it. // Adopt the server draft into an editable local draft, then open it.
const draft = await api.adoptDraft(remote.message_id); const draft = await api.adoptDraft(remote.message_id);
@@ -1210,7 +1248,7 @@ export function MailLayout() {
> >
<RefreshIcon spinning={refreshing} size={mobile ? 22.5 : 15} /> <RefreshIcon spinning={refreshing} size={mobile ? 22.5 : 15} />
</button> </button>
{!isDrafts && total > 0 ? ( {!isDrafts && !isScheduled && total > 0 ? (
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span> <span>
{pageStart + 1}–{pageEnd} of {total} {pageStart + 1}–{pageEnd} of {total}
@@ -1242,7 +1280,7 @@ export function MailLayout() {
title={t("nav.compose")} title={t("nav.compose")}
aria-label={t("nav.compose")} aria-label={t("nav.compose")}
onClick={() => accounts[0] && openComposer({ accountId: accounts[0].id, kind: "new" })} 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} /> <Icon name="compose" size={22} />
</button> </button>
@@ -1265,7 +1303,7 @@ export function MailLayout() {
// Keyed by draft: picking a different one must build a fresh editor, // Keyed by draft: picking a different one must build a fresh editor,
// because RichTextEditor seeds its body exactly once. // because RichTextEditor seeds its body exactly once.
<Composer <Composer
key={paneDraft.draftId} key={paneDraft.localId ?? paneDraft.draftId}
init={paneDraft} init={paneDraft}
onClose={closePaneDraft} onClose={closePaneDraft}
fullWindow fullWindow