Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 38 additions & 1 deletion apps/web/src/composer.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,46 @@
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", () => {
expect(DRAFT_LOCAL_SAVE_MS).toBe(2_000);
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);
});
});
26 changes: 24 additions & 2 deletions apps/web/src/composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<DraftContent, "attachments" | "bodyText" | "recipients" | "subject">,
) {
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);
Expand Down Expand Up @@ -206,15 +217,21 @@ 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");
const remote = await checkpointDraft(accountId, saved.id);
setDraft(remote);
setStatus("saved");
return remote;
}, [accountId, saveLocal]);
}, [accountId, hasMeaningfulContent, saveLocal]);

useEffect(() => {
if (!editor || recovered.current || context.mode !== "new") return;
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -319,6 +337,10 @@ export function ComposePanel({
setStatus("conflict");
return;
}
if (!latest.current.draft && !hasMeaningfulContent()) {
onClose();
return;
}
try {
await checkpoint();
onClose();
Expand Down
12 changes: 11 additions & 1 deletion apps/web/tests/inbox.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
}
Expand Down
Loading