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:
@@ -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")
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,13 +200,23 @@ export function OfflineController() {
|
||||
role="status"
|
||||
className="wm-offline-notice pointer-events-none fixed bottom-6 left-1/2 z-[70] flex max-w-[calc(100vw-2rem)] -translate-x-1/2 items-center gap-2.5 rounded-xl border border-app-border bg-app-elevated px-4 py-2.5 text-sm text-app-fg shadow-lg"
|
||||
>
|
||||
{current.key === "scheduled" || current.key === "sendingScheduled" ? (
|
||||
<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")
|
||||
: 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);
|
||||
|
||||
@@ -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;
|
||||
// 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 (draftId.current) {
|
||||
await api.updateDraft(draftId.current, payload());
|
||||
} else {
|
||||
draftId.current = (await api.createDraft(payload())).id;
|
||||
}
|
||||
if (isOffline()) throw new ApiError(0, "offline", "offline");
|
||||
await saveToServer();
|
||||
setStatus("saved");
|
||||
} catch (e) {
|
||||
if (!isOfflineError(e)) {
|
||||
setStatus("error");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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,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" });
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Fragment, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Link, useMatch, useNavigate, useSearchParams } from "react-router-dom";
|
||||
|
||||
import signetLogo from "../../icons/ultimo-logo-signet.svg";
|
||||
import { AppSwitcher } from "../components/AppSwitcher";
|
||||
import { ComposeDock } from "../components/compose/ComposeDock";
|
||||
import { Composer, type ComposerInit, draftToInit } from "../components/compose/Composer";
|
||||
import { Composer, type ComposerInit, draftToInit, localToInit } from "../components/compose/Composer";
|
||||
import { UndoSendToast } from "../components/compose/UndoSendToast";
|
||||
import { usePendingSend } from "../components/compose/undoSend";
|
||||
import {
|
||||
@@ -17,14 +17,14 @@ import {
|
||||
restoreWindow,
|
||||
toggleExpand,
|
||||
} from "../components/compose/windows";
|
||||
import { DraftsList } from "../components/list/DraftsList";
|
||||
import { DraftsList, ScheduledList } from "../components/list/DraftsList";
|
||||
import { Icon } from "../components/Icon";
|
||||
import { MessageList } from "../components/list/MessageList";
|
||||
import { Resizer } from "../components/Resizer";
|
||||
import { ThemeToggle } from "../components/ThemeToggle";
|
||||
import { EmptyIllustration } from "../components/EmptyIllustration";
|
||||
import { BackfillBanner } from "../components/BackfillBanner";
|
||||
import { NotSavedOffline, OfflineIndicator } from "../components/Offline";
|
||||
import { Clock, NotSavedOffline, OfflineIndicator } from "../components/Offline";
|
||||
import { ThreadView } from "../components/thread/ThreadView";
|
||||
import { MailboxDisplay } from "../components/mail/MailboxDisplay";
|
||||
import { RightPanel, type PanelTab } from "../components/mail/RightPanel";
|
||||
@@ -32,6 +32,7 @@ import { useT } from "../i18n";
|
||||
import type { MessageKey } from "../i18n/en";
|
||||
import { api, isOfflineError } from "../lib/api";
|
||||
import { clearOfflineData, useOffline } from "../lib/offline";
|
||||
import { flushLocal, useLocalMessages, wipeLocal } from "../lib/scheduled";
|
||||
import { setTimeFormat } from "../lib/timeFormat";
|
||||
import type { QuotaOut, SavedSearch, ThreadSummary } from "../lib/types";
|
||||
import { useReadingPane } from "../lib/readingPane";
|
||||
@@ -217,6 +218,12 @@ export function MailLayout() {
|
||||
useEffect(() => localStorage.setItem("mail.listOrder", listOrder), [listOrder]);
|
||||
|
||||
const isDrafts = view === "folder:drafts";
|
||||
// Messages written offline: drafts kept on this device, and the Scheduled
|
||||
// folder of ones sent offline (lib/scheduled.ts).
|
||||
const isScheduled = view === "local:scheduled";
|
||||
const localMessages = useLocalMessages();
|
||||
const deviceDrafts = localMessages.filter((m) => m.state === "draft");
|
||||
const scheduled = localMessages.filter((m) => m.state !== "draft");
|
||||
// Which special-use folder the reading pane is showing (drives the toolbar).
|
||||
const currentFolder = view.startsWith("folder:") ? view.slice("folder:".length) : "inbox";
|
||||
const accountsQuery = useQuery({ queryKey: ["accounts"], queryFn: api.accounts });
|
||||
@@ -233,7 +240,7 @@ export function MailLayout() {
|
||||
}
|
||||
return api.threads(view, { offset: page * PAGE_SIZE, limit: PAGE_SIZE, filter, order: orderParam });
|
||||
},
|
||||
enabled: !isDrafts && !searching,
|
||||
enabled: !isDrafts && !isScheduled && !searching,
|
||||
placeholderData: keepPreviousData, // keep the current page visible while the next loads
|
||||
});
|
||||
const draftsQuery = useQuery({
|
||||
@@ -798,7 +805,7 @@ export function MailLayout() {
|
||||
</div>
|
||||
<div className={`pb-2 pt-2.5 ${collapsed ? "px-2" : "px-3"} ${mobile ? "hidden" : ""}`}>
|
||||
<button
|
||||
className="wm-online-only flex w-full items-center justify-center gap-2 rounded-lg bg-app-accent py-2 text-[15px] font-medium text-app-accent-fg shadow-sm hover:opacity-90"
|
||||
className="flex w-full items-center justify-center gap-2 rounded-lg bg-app-accent py-2 text-[15px] font-medium text-app-accent-fg shadow-sm hover:opacity-90"
|
||||
title={collapsed ? t("nav.compose") : undefined}
|
||||
onClick={() => accounts[0] && openComposer({ accountId: accounts[0].id, kind: "new" })}
|
||||
>
|
||||
@@ -812,11 +819,11 @@ export function MailLayout() {
|
||||
f.view === "folder:inbox"
|
||||
? countsQuery.data?.inbox_unread
|
||||
: f.view === "folder:drafts"
|
||||
? countsQuery.data?.drafts
|
||||
? (countsQuery.data?.drafts ?? 0) + deviceDrafts.length || undefined
|
||||
: undefined;
|
||||
return (
|
||||
<Fragment key={f.view}>
|
||||
<SidebarItem
|
||||
key={f.view}
|
||||
active={view === f.view}
|
||||
collapsed={collapsed}
|
||||
title={collapsed ? t(f.label) : undefined}
|
||||
@@ -828,6 +835,24 @@ export function MailLayout() {
|
||||
<span className="ml-auto shrink-0 tabular-nums text-[13px] text-app-muted">{badge}</span>
|
||||
) : null}
|
||||
</SidebarItem>
|
||||
{/* Scheduled: shown while something waits to be sent (or is open). */}
|
||||
{f.view === "folder:drafts" && (scheduled.length > 0 || isScheduled) && (
|
||||
<SidebarItem
|
||||
active={isScheduled}
|
||||
collapsed={collapsed}
|
||||
title={collapsed ? t("nav.scheduled") : undefined}
|
||||
onClick={() => selectView("local:scheduled")}
|
||||
>
|
||||
<span className="flex w-[18px] justify-center">
|
||||
<Clock size={18} />
|
||||
</span>
|
||||
{!collapsed && <span className="flex-1 truncate">{t("nav.scheduled")}</span>}
|
||||
{!collapsed && scheduled.length > 0 && (
|
||||
<span className="ml-auto shrink-0 tabular-nums text-[13px] text-app-muted">{scheduled.length}</span>
|
||||
)}
|
||||
</SidebarItem>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
{/* Per-account views are hidden entirely when collapsed. */}
|
||||
@@ -912,8 +937,12 @@ export function MailLayout() {
|
||||
}`}
|
||||
title={collapsed ? t("common.signOut") : undefined}
|
||||
onClick={async () => {
|
||||
// Anything still waiting in Scheduled goes out first: what is
|
||||
// kept on this device is wiped with the session.
|
||||
await flushLocal().catch(() => []);
|
||||
await api.logout();
|
||||
await clearOfflineData();
|
||||
await wipeLocal();
|
||||
queryClient.clear();
|
||||
navigate("/login");
|
||||
}}
|
||||
@@ -1036,7 +1065,7 @@ export function MailLayout() {
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{!isDrafts && (
|
||||
{!isDrafts && !isScheduled && (
|
||||
<div
|
||||
className={`flex h-[52px] shrink-0 items-center gap-5 border-b border-app-border bg-app-bg text-[15px] ${
|
||||
mobile ? "px-3" : "px-6"
|
||||
@@ -1156,14 +1185,23 @@ export function MailLayout() {
|
||||
</div>
|
||||
)}
|
||||
<div className="min-h-0 flex-1">
|
||||
{isDrafts && !term.trim() && !draftsQuery.data && isOfflineError(draftsQuery.error) ? (
|
||||
<div className="p-6 text-[15px] text-app-faint">{t("offline.folderNotSaved")}</div>
|
||||
{isScheduled ? (
|
||||
<ScheduledList
|
||||
messages={scheduled}
|
||||
selectedId={paneDraft?.localId}
|
||||
onOpen={(msg) => openDraftInPane(localToInit(msg))}
|
||||
/>
|
||||
) : isDrafts && !term.trim() ? (
|
||||
<DraftsList
|
||||
drafts={draftsQuery.data ?? []}
|
||||
remoteDrafts={remoteDraftsQuery.data ?? []}
|
||||
selectedId={paneDraft?.draftId}
|
||||
deviceDrafts={deviceDrafts}
|
||||
note={
|
||||
!draftsQuery.data && isOfflineError(draftsQuery.error) ? t("offline.draftsNotSaved") : undefined
|
||||
}
|
||||
selectedId={paneDraft?.localId ?? paneDraft?.draftId}
|
||||
onOpen={(draft) => openDraftInPane(draftToInit(draft))}
|
||||
onOpenDevice={(msg) => openDraftInPane(localToInit(msg))}
|
||||
onOpenRemote={async (remote) => {
|
||||
// Adopt the server draft into an editable local draft, then open it.
|
||||
const draft = await api.adoptDraft(remote.message_id);
|
||||
@@ -1210,7 +1248,7 @@ export function MailLayout() {
|
||||
>
|
||||
<RefreshIcon spinning={refreshing} size={mobile ? 22.5 : 15} />
|
||||
</button>
|
||||
{!isDrafts && total > 0 ? (
|
||||
{!isDrafts && !isScheduled && total > 0 ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<span>
|
||||
{pageStart + 1}–{pageEnd} of {total}
|
||||
@@ -1242,7 +1280,7 @@ export function MailLayout() {
|
||||
title={t("nav.compose")}
|
||||
aria-label={t("nav.compose")}
|
||||
onClick={() => accounts[0] && openComposer({ accountId: accounts[0].id, kind: "new" })}
|
||||
className="wm-online-only absolute bottom-[calc(4rem+env(safe-area-inset-bottom))] right-4 z-30 flex h-14 w-14 items-center justify-center rounded-2xl bg-app-accent text-app-accent-fg shadow-lg active:opacity-90"
|
||||
className="absolute bottom-[calc(4rem+env(safe-area-inset-bottom))] right-4 z-30 flex h-14 w-14 items-center justify-center rounded-2xl bg-app-accent text-app-accent-fg shadow-lg active:opacity-90"
|
||||
>
|
||||
<Icon name="compose" size={22} />
|
||||
</button>
|
||||
@@ -1265,7 +1303,7 @@ export function MailLayout() {
|
||||
// Keyed by draft: picking a different one must build a fresh editor,
|
||||
// because RichTextEditor seeds its body exactly once.
|
||||
<Composer
|
||||
key={paneDraft.draftId}
|
||||
key={paneDraft.localId ?? paneDraft.draftId}
|
||||
init={paneDraft}
|
||||
onClose={closePaneDraft}
|
||||
fullWindow
|
||||
|
||||
Reference in New Issue
Block a user