diff --git a/apps/web/src/composer.test.ts b/apps/web/src/composer.test.ts index e6de2bf..8807e71 100644 --- a/apps/web/src/composer.test.ts +++ b/apps/web/src/composer.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { DRAFT_LOCAL_SAVE_MS, DRAFT_REMOTE_CHECKPOINT_MS } from "./composer"; +import { + DRAFT_LOCAL_SAVE_MS, + DRAFT_REMOTE_CHECKPOINT_MS, + hasMeaningfulDraftContent, +} from "./composer"; describe("composer timing contract", () => { it("uses the documented local debounce and remote checkpoint interval", () => { @@ -7,3 +11,36 @@ describe("composer timing contract", () => { expect(DRAFT_REMOTE_CHECKPOINT_MS).toBe(15_000); }); }); + +describe("composer content guard", () => { + const emptyDraft = { + recipients: [], + attachments: [], + subject: "", + bodyText: "", + }; + + it("does not treat whitespace-only draft fields as meaningful", () => { + expect(hasMeaningfulDraftContent(emptyDraft)).toBe(false); + expect(hasMeaningfulDraftContent({ ...emptyDraft, subject: " ", bodyText: "\n" })).toBe(false); + }); + + it.each([ + { ...emptyDraft, recipients: [{ role: "to" as const, address: "person@example.test" }] }, + { ...emptyDraft, subject: "Subject" }, + { ...emptyDraft, bodyText: "Message" }, + { + ...emptyDraft, + attachments: [ + { + objectId: "abcdef0123456789abcdef0123456789", + filename: "file.txt", + mediaType: "text/plain", + sizeBytes: 4, + }, + ], + }, + ])("recognizes meaningful draft content", (draft) => { + expect(hasMeaningfulDraftContent(draft)).toBe(true); + }); +}); diff --git a/apps/web/src/composer.tsx b/apps/web/src/composer.tsx index dd8b529..15775e3 100644 --- a/apps/web/src/composer.tsx +++ b/apps/web/src/composer.tsx @@ -44,6 +44,17 @@ type EditorContent = { text: string; html: string }; export const DRAFT_LOCAL_SAVE_MS = 2_000; export const DRAFT_REMOTE_CHECKPOINT_MS = 15_000; +export function hasMeaningfulDraftContent( + draft: Pick, +) { + return ( + draft.recipients.length > 0 || + draft.attachments.length > 0 || + draft.subject.trim().length > 0 || + draft.bodyText.trim().length > 0 + ); +} + function InitialContentPlugin({ text }: { text: string }) { const [editor] = useLexicalComposerContext(); const initialized = useRef(false); @@ -206,7 +217,13 @@ export function ComposePanel({ } }, [accountId, content]); + const hasMeaningfulContent = useCallback( + () => hasMeaningfulDraftContent(content(latest.current.draft?.localRevision)), + [content], + ); + const checkpoint = useCallback(async () => { + if (!latest.current.draft && !hasMeaningfulContent()) return null; const saved = latest.current.dirty || !latest.current.draft ? await saveLocal() : latest.current.draft; if (!saved) throw new Error("draft_unavailable"); @@ -214,7 +231,7 @@ export function ComposePanel({ setDraft(remote); setStatus("saved"); return remote; - }, [accountId, saveLocal]); + }, [accountId, hasMeaningfulContent, saveLocal]); useEffect(() => { if (!editor || recovered.current || context.mode !== "new") return; @@ -277,12 +294,13 @@ export function ComposePanel({ // biome-ignore lint/correctness/useExhaustiveDependencies: content fields intentionally restart the two-second debounce. useEffect(() => { + if (!latest.current.draft && !hasMeaningfulContent()) return; const timer = window.setTimeout( () => void saveLocal().catch(() => undefined), DRAFT_LOCAL_SAVE_MS, ); return () => window.clearTimeout(timer); - }, [to, cc, bcc, subject, editorContent, attachments, saveLocal]); + }, [to, cc, bcc, subject, editorContent, attachments, hasMeaningfulContent, saveLocal]); useEffect(() => { const interval = window.setInterval( @@ -319,6 +337,10 @@ export function ComposePanel({ setStatus("conflict"); return; } + if (!latest.current.draft && !hasMeaningfulContent()) { + onClose(); + return; + } try { await checkpoint(); onClose(); diff --git a/apps/web/tests/inbox.spec.ts b/apps/web/tests/inbox.spec.ts index 59aa7b5..cc2b4de 100644 --- a/apps/web/tests/inbox.spec.ts +++ b/apps/web/tests/inbox.spec.ts @@ -12,7 +12,7 @@ const account = { test("live inbox virtualizes large account-scoped pages and preserves selection", async ({ page, }, testInfo) => { - testInfo.setTimeout(60_000); + testInfo.setTimeout(90_000); // Exercise the 100k acceptance target once; responsive projects use a // smaller page so the six-project suite does not duplicate a large fixture. const itemCount = testInfo.project.name === "desktop-large" ? 100_000 : 500; @@ -509,6 +509,16 @@ test("live inbox virtualizes large account-scoped pages and preserves selection" await expect(page.getByText("No messages here")).toBeVisible(); const composeButton = page.getByRole("button", { name: "Compose" }); + if (!(await composeButton.isVisible())) { + await page.getByRole("button", { name: "Toggle navigation" }).click(); + } + const draftsBeforeEmptyCompose = draftRequests.length; + await composeButton.click(); + await page.waitForTimeout(2_100); + await page.getByRole("button", { name: "Close", exact: true }).click(); + await expect(page.getByRole("region", { name: "New message" })).toHaveCount(0); + expect(draftRequests).toHaveLength(draftsBeforeEmptyCompose); + if (!(await composeButton.isVisible())) { await page.getByRole("button", { name: "Toggle navigation" }).click(); }