diff --git a/src/app/smoke-tests/SmokeHarness.tsx b/src/app/smoke-tests/SmokeHarness.tsx index 207ac79..9cb5fdc 100644 --- a/src/app/smoke-tests/SmokeHarness.tsx +++ b/src/app/smoke-tests/SmokeHarness.tsx @@ -17,11 +17,13 @@ import { import { prepareHtml } from "@/lib/emailHtml"; import { buildForwardedHtml } from "@/lib/composeHtml"; import { dispatchUnreadCountEvent } from "@/lib/unreadCount"; +import { notifyMailboxMove } from "@/lib/mailboxMove"; import type { Email, EmailBodyPart } from "@/lib/types"; import type { MailPanelData } from "@/lib/jmap"; export type SmokePanel = | "inbox" + | "mailbox-move" | "reply" | "forward" | "attachments" @@ -260,6 +262,48 @@ function TabIndicatorSmokePanel() { ); } +function MailboxMoveSmokePanel() { + const notice = { + emailIds: ["email-maya"], + sourceMailboxId: "mailbox-inbox", + targetMailboxId: "mailbox-trash", + }; + + return ( +
+
+ + +
+
+ +
+
+ ); +} + export default function SmokeHarness({ panel }: { panel: SmokePanel }) { const [autoSyncEmails, setAutoSyncEmails] = useState(fixtureEmails); const runAutoSyncCheck = useCallback(async () => { @@ -316,6 +360,8 @@ export default function SmokeHarness({ panel }: { panel: SmokePanel }) { /> )} + {panel === "mailbox-move" && } + {panel === "auto-sync" && ( ([ "inbox", + "mailbox-move", "reply", "forward", "attachments", diff --git a/src/components/EmailListPanel.tsx b/src/components/EmailListPanel.tsx index 1f348e6..83580ae 100644 --- a/src/components/EmailListPanel.tsx +++ b/src/components/EmailListPanel.tsx @@ -63,6 +63,12 @@ import { } from "@/lib/mailbox"; import type { MailPanelData } from "@/lib/jmap"; import useMailboxMove from "@/components/useMailboxMove"; +import { + applyMailboxMoveNotice, + MAILBOX_MOVE_EVENT, + reconcileMailboxMoveIds, + type MailboxMoveNotice, +} from "@/lib/mailboxMove"; interface Props { initialData: MailPanelData; @@ -315,14 +321,13 @@ export default function EmailListPanel({ useEffect(() => { setExtraUnreads([]); setExtraReads([]); + setArchivedIds(new Set()); }, [view]); useEffect(() => { const fresh = [...currentUnreads, ...currentReads, ...pinnedList]; setExtraUnreads((prev) => mergeEmailUpdates(prev, fresh)); setExtraReads((prev) => mergeEmailUpdates(prev, fresh)); - // Server state is authoritative after a refresh; clear optimistic removals - setArchivedIds(new Set()); }, [currentUnreads, currentReads, pinnedList]); // ------------------------------------------------------------------------- @@ -349,6 +354,17 @@ export default function EmailListPanel({ } | null>(null); const suppressLinkClick = useRef(null); + useEffect(() => { + function onMailboxMove(event: Event) { + const notice = (event as CustomEvent).detail; + if (!notice || notice.sourceMailboxId !== currentMailboxId) return; + setArchivedIds((current) => applyMailboxMoveNotice(current, notice)); + } + + window.addEventListener(MAILBOX_MOVE_EVENT, onMailboxMove); + return () => window.removeEventListener(MAILBOX_MOVE_EVENT, onMailboxMove); + }, [currentMailboxId]); + // ------------------------------------------------------------------------- // Refresh state // ------------------------------------------------------------------------- @@ -627,6 +643,13 @@ export default function EmailListPanel({ return result; }, [pinnedList, allUnreads, allReads, view, inboxId]); + useEffect(() => { + const sourceIds = new Set( + (isInSearchMode ? searchResults : allInboxEmails).map((email) => email.id), + ); + setArchivedIds((current) => reconcileMailboxMoveIds(current, sourceIds)); + }, [allInboxEmails, isInSearchMode, searchResults]); + const visibleEmails = useMemo(() => { const base = isInSearchMode ? searchResults : allInboxEmails; if (!archivedIds.size) return base; diff --git a/src/components/useMailboxMove.ts b/src/components/useMailboxMove.ts index 80adf0d..f7aecc7 100644 --- a/src/components/useMailboxMove.ts +++ b/src/components/useMailboxMove.ts @@ -4,6 +4,7 @@ import { useCallback, useState } from "react"; import { useRouter } from "next/navigation"; import { bulkMoveToMailbox } from "@/app/(inbox)/actions"; import { useToast } from "@/components/ToastProvider"; +import { notifyMailboxMove } from "@/lib/mailboxMove"; interface MoveEmail { id: string; @@ -44,7 +45,15 @@ export default function useMailboxMove() { setMovingTo(targetMailboxId); onOptimistic?.(); + const emailIds = emails.map((email) => email.id); const movePromise = bulkMoveToMailbox(emails, targetMailboxId); + notifyMailboxMove({ + emailIds, + sourceMailboxId, + targetMailboxId, + phase: "move", + }); + if (navigateTo) router.replace(navigateTo); showToast({ message: successMessage, @@ -60,6 +69,12 @@ export default function useMailboxMove() { sourceMailboxId, ); onRevert?.(); + notifyMailboxMove({ + emailIds, + sourceMailboxId, + targetMailboxId, + phase: "revert", + }); router.refresh(); } catch { showToast({ @@ -72,11 +87,16 @@ export default function useMailboxMove() { try { await movePromise; - if (navigateTo) router.replace(navigateTo); router.refresh(); return true; } catch { onRevert?.(); + notifyMailboxMove({ + emailIds, + sourceMailboxId, + targetMailboxId, + phase: "revert", + }); showToast({ message: failureMessage, tone: "error" }); return false; } finally { diff --git a/src/lib/__tests__/mailboxMove.test.ts b/src/lib/__tests__/mailboxMove.test.ts new file mode 100644 index 0000000..d2cf369 --- /dev/null +++ b/src/lib/__tests__/mailboxMove.test.ts @@ -0,0 +1,38 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + applyMailboxMoveNotice, + reconcileMailboxMoveIds, +} from "../mailboxMove"; + +describe("optimistic mailbox moves", () => { + it("hides moved messages and restores them on rollback", () => { + const moved = applyMailboxMoveNotice(new Set(["existing"]), { + emailIds: ["email-1", "email-2"], + phase: "move", + }); + assert.deepEqual([...moved].sort(), ["email-1", "email-2", "existing"]); + + const reverted = applyMailboxMoveNotice(moved, { + emailIds: ["email-1", "email-2"], + phase: "revert", + }); + assert.deepEqual([...reverted], ["existing"]); + }); + + it("keeps a row hidden through unrelated refreshes", () => { + const reconciled = reconcileMailboxMoveIds( + new Set(["moved-email"]), + new Set(["moved-email", "other-email"]), + ); + assert.deepEqual([...reconciled], ["moved-email"]); + }); + + it("drops the marker after refreshed source data reflects the move", () => { + const reconciled = reconcileMailboxMoveIds( + new Set(["moved-email"]), + new Set(["other-email"]), + ); + assert.equal(reconciled.size, 0); + }); +}); diff --git a/src/lib/mailboxMove.ts b/src/lib/mailboxMove.ts new file mode 100644 index 0000000..fd7008a --- /dev/null +++ b/src/lib/mailboxMove.ts @@ -0,0 +1,42 @@ +export const MAILBOX_MOVE_EVENT = "mailbox-move-optimistic"; + +export interface MailboxMoveNotice { + emailIds: string[]; + sourceMailboxId: string; + targetMailboxId: string; + phase: "move" | "revert"; +} + +export function applyMailboxMoveNotice( + current: ReadonlySet, + notice: Pick, +): Set { + const next = new Set(current); + for (const emailId of notice.emailIds) { + if (notice.phase === "move") next.add(emailId); + else next.delete(emailId); + } + return next; +} + +/** + * Drop completed optimistic markers once refreshed source data no longer + * contains those messages. Markers still present in source data remain active + * so an unrelated partial refresh cannot make a moved row reappear. + */ +export function reconcileMailboxMoveIds( + current: ReadonlySet, + sourceEmailIds: ReadonlySet, +): Set { + const next = new Set(); + for (const emailId of current) { + if (sourceEmailIds.has(emailId)) next.add(emailId); + } + return next; +} + +export function notifyMailboxMove(notice: MailboxMoveNotice): void { + window.dispatchEvent( + new CustomEvent(MAILBOX_MOVE_EVENT, { detail: notice }), + ); +} diff --git a/tests/smoke/mail.spec.ts b/tests/smoke/mail.spec.ts index 7bb5dc4..680ba65 100644 --- a/tests/smoke/mail.spec.ts +++ b/tests/smoke/mail.spec.ts @@ -113,6 +113,21 @@ test("opens a conversation from a desktop click", async ({ page }) => { ).toBeVisible(); }); +test("removes a moved message from the list without a refresh", async ({ + page, +}) => { + await page.goto("/smoke-tests?panel=mailbox-move"); + + const movedConversation = page.getByText("Quarterly plan", { exact: true }); + await expect(movedConversation).toBeVisible(); + + await page.getByRole("button", { name: "Move fixture to Trash" }).click(); + await expect(movedConversation).toHaveCount(0); + + await page.getByRole("button", { name: "Roll back fixture move" }).click(); + await expect(page.getByText("Quarterly plan", { exact: true })).toBeVisible(); +}); + test("mail quick actions overlay text instead of reserving row space", async ({ page, }) => {