From 5e49d16e5dc088f04ce0a9b18d9ca676466d0167 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Thu, 24 Sep 2026 20:24:31 -0700 Subject: [PATCH] feat: animate unibox rows out in the direction of the action (archive, trash, back to inbox, snooze) with a labelled underlay and in with a fade or grow, keep the list mounted so bulk exits play and give re-entering keys a fresh presence key so a refilling list never strands archived rows, fade new pages in at full height so infinite scroll stops chaining every page, batch bulk archive/read/snooze requests under the 500-id cap, show an all-caught-up state and an animated selection bar, and page the conversation list from the last returned row so no conversation is skipped at a page boundary --- internal/repository/pg_unibox.go | 14 +- .../repository/unibox_pagination_live_test.go | 64 ++++ .../app/app/unibox/uniboxBulkArchive.test.tsx | 145 ++++++++ web/src/components/app/unibox/AnimatedRow.tsx | 69 ++++ .../app/unibox/ConversationList.tsx | 310 ++++++++++++------ .../components/app/unibox/SelectionBar.tsx | 200 ++++++----- web/src/hooks/useConversationActions.tsx | 10 +- .../lib/api/client/app/unibox/chunks.test.ts | 35 ++ web/src/lib/api/client/app/unibox/chunks.ts | 27 ++ web/src/lib/api/client/app/unibox/markSeen.ts | 32 +- .../lib/api/client/app/unibox/moveFolder.ts | 23 +- .../lib/api/client/app/unibox/snoozeThread.ts | 30 +- web/src/lib/api/hooks/app/unibox/listCache.ts | 5 + .../lib/api/hooks/app/unibox/useMoveFolder.ts | 4 +- web/src/lib/unibox/rowMotion.ts | 177 ++++++++++ 15 files changed, 921 insertions(+), 224 deletions(-) create mode 100644 internal/repository/unibox_pagination_live_test.go create mode 100644 web/src/app/app/unibox/uniboxBulkArchive.test.tsx create mode 100644 web/src/components/app/unibox/AnimatedRow.tsx create mode 100644 web/src/lib/api/client/app/unibox/chunks.test.ts create mode 100644 web/src/lib/api/client/app/unibox/chunks.ts create mode 100644 web/src/lib/unibox/rowMotion.ts diff --git a/internal/repository/pg_unibox.go b/internal/repository/pg_unibox.go index 22443c53c..b5717d64a 100644 --- a/internal/repository/pg_unibox.go +++ b/internal/repository/pg_unibox.go @@ -429,7 +429,7 @@ func (r *uniboxRepository) GetByThread(ctx context.Context, orgID, emailID uuid. cursorID, err := uuid.Parse(cursor) if err == nil { query += fmt.Sprintf(` - AND (internal_date, id) < ( + AND (internal_date, id) > ( SELECT internal_date, id FROM unibox_emails WHERE id = $%d )`, argPos) args = append(args, cursorID) @@ -996,9 +996,11 @@ func (r *uniboxRepository) queryPreviewList(ctx context.Context, query string, a var hasMore bool var nextCursor *string - if len(emails) > limit { + if limit > 0 && len(emails) > limit { hasMore = true - cursor := emails[limit].ID.String() + // The last row returned: the next page reads strictly past it, so + // pointing at the probe row instead would skip that row entirely. + cursor := emails[limit-1].ID.String() nextCursor = &cursor emails = emails[:limit] } @@ -1046,9 +1048,11 @@ func (r *uniboxRepository) queryThreadList(ctx context.Context, query string, ar var hasMore bool var nextCursor *string - if len(emails) > limit { + if limit > 0 && len(emails) > limit { hasMore = true - cursor := emails[limit].ID.String() + // The last row returned: the next page reads strictly past it, so + // pointing at the probe row instead would skip that row entirely. + cursor := emails[limit-1].ID.String() nextCursor = &cursor emails = emails[:limit] } diff --git a/internal/repository/unibox_pagination_live_test.go b/internal/repository/unibox_pagination_live_test.go new file mode 100644 index 000000000..fc9b828ab --- /dev/null +++ b/internal/repository/unibox_pagination_live_test.go @@ -0,0 +1,64 @@ +package repository + +import ( + "context" + "testing" + "time" + + "github.com/google/uuid" + + "github.com/warmbly/warmbly/internal/models" +) + +// Paging the conversation list visits every conversation exactly once: the +// cursor names the last row a page returned and the next page reads past it. +// +// WARMBLY_TEST_DB=postgres://warmbly:warmbly@localhost:15432/warmbly_dev?sslmode=disable \ +// go test ./internal/repository/ -run LiveUniboxSearchPaging -v +func TestLiveUniboxSearchPagingVisitsEveryConversationOnce(t *testing.T) { + handle := liveUniboxFolderDB(t) + f := newUniboxFolderFixture(t, handle.Pool) + repo := NewUniboxRepository(handle) + ctx := context.Background() + + const total = 7 + want := make(map[uuid.UUID]bool, total) + base := time.Now().UTC().Add(-time.Hour) + for i := 0; i < total; i++ { + id := f.message(t, repo, models.FolderInbox) + if _, err := handle.Pool.Exec(ctx, + `UPDATE unibox_emails SET internal_date = $2 WHERE id = $1`, + id, base.Add(time.Duration(i)*time.Minute)); err != nil { + t.Fatalf("date: %v", err) + } + want[id] = true + } + + folder := models.FolderInbox + seen := make(map[uuid.UUID]int, total) + cursor := "" + for page := 0; page < total+1; page++ { + res, err := repo.Search(ctx, f.org, &models.MailSearchParams{ + Folder: &folder, PageSize: 2, Cursor: cursor, + }) + if err != nil { + t.Fatalf("Search: %v", err) + } + for _, e := range res.Data { + seen[e.ID]++ + } + if !res.Pagination.HasMore { + break + } + cursor = *res.Pagination.NextCursor + } + + for id := range want { + if seen[id] != 1 { + t.Errorf("conversation %s listed %d times, want 1", id, seen[id]) + } + } + if len(seen) != total { + t.Errorf("listed %d conversations, want %d", len(seen), total) + } +} diff --git a/web/src/app/app/unibox/uniboxBulkArchive.test.tsx b/web/src/app/app/unibox/uniboxBulkArchive.test.tsx new file mode 100644 index 000000000..c5ff398df --- /dev/null +++ b/web/src/app/app/unibox/uniboxBulkArchive.test.tsx @@ -0,0 +1,145 @@ +// Select all, archive, and again: every round has to leave the list for good. +// +// The fake server below keeps real folder state and pages like the backend, so +// what the list shows after each round is what a reload would show. The rows +// are counted in the DOM, including any still mounted after their exit, which +// is how a stalled exit shows up: the archived rows stay on screen. + +import React from "react"; +import { describe, it, expect, vi, beforeAll, beforeEach } from "vitest"; +import { screen, act, fireEvent, within } from "@testing-library/react"; +import { installLayoutShims, mount, setViewportWidth, settle, SUITE } from "./uniboxHarness"; + +type Cfg = { method?: string; url?: string; data?: Record }; + +const server = vi.hoisted(() => { + const rows = Array.from({ length: 130 }, (_, i) => ({ + id: `msg-${i}`, + email_id: "mbox-1", + thread_id: `thread-${i}`, + from_addr: [`Sender ${i} `], + to_addr: ["me@warmbly.com"], + subject: `Subject ${i}`, + snippet: `Snippet ${i}`, + internal_date: new Date(Date.now() - i * 60e3).toISOString(), + seen: true, + message_count: 1, + has_unread: false, + labels: [], + folder: "inbox", + })); + return { rows, failNext: false }; +}); + +beforeAll(() => { + installLayoutShims(); + setViewportWidth(1512); +}); + +vi.mock("@/lib/api/client/Request", () => ({ + default: async (cfg: Cfg) => { + const url = String(cfg.url ?? ""); + await new Promise((r) => setTimeout(r, 30)); + if (cfg.method === "PATCH" && url === "/unibox/folder") { + if (server.failNext) { + server.failNext = false; + throw new Error("unavailable"); + } + const ids = (cfg.data?.thread_ids as string[]) ?? []; + for (const r of server.rows) if (ids.includes(r.thread_id)) r.folder = String(cfg.data?.folder); + return undefined; + } + if (url.startsWith("/unibox?")) { + const p = new URL(url, "https://t.local").searchParams; + const folder = p.get("folder"); + const limit = Number(p.get("limit") ?? 50); + let list = server.rows.filter((r) => !folder || r.folder === folder); + const cursor = p.get("cursor"); + if (cursor) list = list.slice(list.findIndex((r) => r.id === cursor) + 1); + const page = list.slice(0, limit); + const more = list.length > limit; + return { + data: page, + pagination: { has_more: more, next_cursor: more ? page[page.length - 1].id : null }, + }; + } + const { route } = await import("./uniboxHarness"); + return route(url); + }, +})); +vi.mock("@/lib/helper/getToken", () => ({ + default: () => ({ + access_token: "a", + refresh_token: "r", + access_token_expires_at: new Date(Date.now() + 3600e3).toISOString(), + refresh_token_expires_at: new Date(Date.now() + 3600e3).toISOString(), + }), +})); +vi.mock("@/hooks/SocketProvider", () => ({ + default: ({ children }: { children: React.ReactNode }) => <>{children}, +})); +vi.mock("@/hooks/context/socket", async (orig) => { + const actual = (await orig()) as Record; + return { + ...actual, + useSocket: () => ({ isConnected: false, subscribeToChannel: () => () => {}, pushToChannel: () => {}, socket: null, status: "closed" }), + useChannel: () => ({ state: "closed", push: () => {}, channel: null }), + useChannelEvent: () => {}, + useChannelSubscription: () => {}, + }; +}); + +const visible = () => + [...document.querySelectorAll("[data-thread-id]")].map((e) => e.getAttribute("data-thread-id")); + +async function archiveAll() { + const select = screen.queryByRole("button", { name: "Select" }); + if (select) await act(async () => { fireEvent.click(select); }); + await settle(); + await act(async () => { fireEvent.click(screen.getByLabelText("Select all loaded")); }); + await settle(); + const bar = screen.getByRole("toolbar", { name: "Selection actions" }); + await act(async () => { fireEvent.click(within(bar).getByTitle("Archive")); }); + for (let i = 0; i < 6; i++) await settle(); +} + +describe("bulk archive from the selection bar", SUITE, () => { + beforeEach(() => { + for (const r of server.rows) r.folder = "inbox"; + server.failNext = false; + }); + + it("clears each round of select all for good", async () => { + await mount("/app/unibox/inbox"); + for (let i = 0; i < 4; i++) await settle(); + const first = visible(); + expect(first).toHaveLength(50); + + await archiveAll(); + const second = visible(); + // The next fifty, and nothing from the first round left mounted. + expect(second).toHaveLength(50); + expect(second.filter((id) => first.includes(id))).toEqual([]); + + await archiveAll(); + const third = visible(); + expect(third).toHaveLength(30); + expect(third.filter((id) => first.includes(id) || second.includes(id))).toEqual([]); + + const archived = server.rows.filter((r) => r.folder === "archive").length; + expect(archived).toBe(100); + }); + + it("puts a failed round back exactly once", async () => { + await mount("/app/unibox/inbox"); + for (let i = 0; i < 4; i++) await settle(); + const before = visible(); + + server.failNext = true; + await archiveAll(); + const after = visible(); + expect(after).toHaveLength(before.length); + expect(new Set(after).size).toBe(after.length); + expect([...after].sort()).toEqual([...before].sort()); + }); +}); diff --git a/web/src/components/app/unibox/AnimatedRow.tsx b/web/src/components/app/unibox/AnimatedRow.tsx new file mode 100644 index 000000000..e3f61b03d --- /dev/null +++ b/web/src/components/app/unibox/AnimatedRow.tsx @@ -0,0 +1,69 @@ +// The motion shell around one conversation row. +// +// Three layers: the shell folds the row's height, the content slides out in +// the direction of the action, and the underlay is what the slide uncovers. +// The underlay's colour and label are picked by CSS from the `data-exit` +// attribute markRowExit stamps on the shell (see lib/unibox/rowMotion). + +import React from "react"; +import { motion } from "framer-motion"; +import { ArchiveIcon, InboxIcon, MoonIcon, TrashIcon } from "lucide-react"; + +import { + rowContent, + rowShell, + rowUnderlay, + type RowMotionCustom, +} from "@/lib/unibox/rowMotion"; + +const UNDERLAY = + "hidden absolute inset-0 items-center gap-1.5 text-[11.5px] font-medium"; + +export function AnimatedRow({ + motionKey, + custom, + children, +}: { + motionKey: string; + custom: RowMotionCustom; + children: React.ReactNode; +}) { + return ( + + + + + Archived + + + + Moved to Trash + + + + Moved to Inbox + + + + Snoozed + + + + {children} + + + ); +} diff --git a/web/src/components/app/unibox/ConversationList.tsx b/web/src/components/app/unibox/ConversationList.tsx index d0230bfe4..ce5ae37ba 100644 --- a/web/src/components/app/unibox/ConversationList.tsx +++ b/web/src/components/app/unibox/ConversationList.tsx @@ -11,7 +11,8 @@ import React from "react"; import { AnimatePresence, motion } from "framer-motion"; -import { PanelLeftIcon, PenLineIcon, SearchIcon } from "lucide-react"; +import { CheckIcon, PanelLeftIcon, PenLineIcon, SearchIcon } from "lucide-react"; +import { AnimatedRow } from "./AnimatedRow"; import { ConversationItem } from "./ConversationItem"; import { SelectionBar } from "./SelectionBar"; import { useConversationActions } from "@/hooks/useConversationActions"; @@ -39,6 +40,10 @@ const BUCKET_LABELS: Record = { earlier: "Earlier", }; +// Views where an empty list means the work is done rather than that nothing +// ever landed there. +const CAUGHT_UP_SCOPES = new Set(["folder:inbox", "unread", "awaiting", "all"]); + function bucketFor(d: Date): Bucket { const now = new Date(); const today = new Date( @@ -244,18 +249,70 @@ export function ConversationList({ fetchNextPage, ]); - // Group rows by time bucket. The server already orders newest to oldest so a - // single pass preserves both global order and group adjacency. - const grouped = React.useMemo(() => { - const groups: { bucket: Bucket; rows: typeof emails }[] = []; + // Rows and their time-bucket headers as one flat sequence, so a single + // AnimatePresence sees every entry and exit (a header leaves with the last + // row under it). The server orders newest first, so one pass keeps both + // the order and the grouping. + const items = React.useMemo(() => { + const out: ( + | { kind: "header"; key: string; bucket: Bucket } + | { kind: "row"; key: string; row: (typeof emails)[number] } + )[] = []; + let last: Bucket | null = null; for (const e of emails) { const b = bucketFor(new Date(e.internal_date)); - const tail = groups[groups.length - 1]; - if (tail && tail.bucket === b) tail.rows.push(e); - else groups.push({ bucket: b, rows: [e] }); + if (b !== last) out.push({ kind: "header", key: `bucket:${b}`, bucket: b }); + last = b; + out.push({ kind: "row", key: rowKey(e), row: e }); } - return groups; - }, [emails]); + return out; + }, [emails, rowKey]); + + // Which items were on screen last time, and a generation per key that + // left. A key that comes back gets a fresh presence key: AnimatePresence + // unmounts exiting children only when every one has finished, and one that + // re-enters mid-exit never reports, stranding the rest of the batch (a + // date header when the list refills, rows restored by a failed action). + const itemSig = React.useMemo(() => items.map((i) => i.key).join("\n"), [items]); + // A new result set remounts every row, so nothing in it counts as kept. + const resultSet = shownKey.current; + const [shown, setShown] = React.useState<{ + sig: string; + set: string; + prev: ReadonlySet; + gen: ReadonlyMap; + }>(() => ({ sig: "", set: resultSet, prev: new Set(), gen: new Map() })); + if (shown.sig !== itemSig || shown.set !== resultSet) { + const sameSet = shown.set === resultSet; + const prev = sameSet && shown.sig ? shown.sig.split("\n") : []; + const now = new Set(itemSig ? itemSig.split("\n") : []); + const gen = new Map(sameSet ? shown.gen : []); + for (const k of prev) if (!now.has(k)) gen.set(k, (gen.get(k) ?? 0) + 1); + setShown({ sig: itemSig, set: resultSet, prev: new Set(prev), gen }); + } + const presenceKey = (key: string) => { + const g = shown.gen.get(key); + return g ? `${key}~${g}` : key; + }; + + // How each new row should appear. One inserted above rows already on + // screen (an arrival, an Undo) grows into place; rows added below them (a + // page, a refill after a bulk action) fade up in a short stagger and take + // their full height at once, so the infinite-scroll sentinel is pushed out + // of range immediately instead of chaining every page in behind them. + const entering = React.useMemo(() => { + const keys = itemSig ? itemSig.split("\n") : []; + let lastKept = -1; + keys.forEach((k, i) => { + if (shown.prev.has(k)) lastKept = i; + }); + const out = new Map(); + let rank = 0; + keys.forEach((k, i) => { + if (!shown.prev.has(k)) out.set(k, { arrival: i < lastKept, rank: rank++ }); + }); + return out; + }, [itemSig, shown.prev]); // Keyboard navigation. We work off `emails` (flat order) so j/k moves across // bucket boundaries naturally. The keys themselves live in the global @@ -462,7 +519,8 @@ export function ConversationList({
0 && "opacity-50", )} aria-busy={stale || undefined} @@ -485,85 +543,82 @@ export function ConversationList({ Try again
- ) : emails.length === 0 ? ( -
-

- {filtering ? "No matches" : "Nothing here"} -

-

- {filtering - ? search.trim() - ? `Nothing in ${scopeLabel.toLowerCase()} matches "${search.trim()}". The search covers names, addresses, subjects and message bodies.` - : "Try a different search or clear the filters." - : "New mail shows up here as it arrives."} -

- {/* The commonest reason a search finds nothing is that the thing - is filed somewhere else. Offer the wider search rather than - quietly overriding the scope the reader chose. */} - {filtering && search.trim() && onSearchAllMail && ( - - )} -
) : ( - {grouped.map((g) => ( -
-
- - {BUCKET_LABELS[g.bucket]} - -
-
- - {g.rows.map((e) => ( - - - - ))} - -
-
- ))} + + {items.map((item) => + item.kind === "header" ? ( + + + {BUCKET_LABELS[item.bucket]} + + + ) : ( + + + + ), + )} + + {/* Mounted beside the rows rather than instead of them, so the last + ones can still animate out when an action empties the list. It + waits for them before it appears. */} + + {emails.length === 0 && !hasNextPage && ( + + + + )} + {hasNextPage && (
{isFetchingNextPage ? ( @@ -590,12 +645,79 @@ export function ConversationList({ )}
- + + {selectedIds.length > 0 && ( + + )} + + + ); +} + +function EmptyState({ + caughtUp, + filtering, + search, + scopeLabel, + onSearchAllMail, +}: { + caughtUp: boolean; + filtering: boolean; + search: string; + scopeLabel: string; + onSearchAllMail?: () => void; +}) { + if (caughtUp) { + return ( +
+ + + +

+ All caught up +

+

+ New mail shows up here as it arrives. +

+
+ ); + } + return ( +
+

+ {filtering ? "No matches" : "Nothing here"} +

+

+ {filtering + ? search.trim() + ? `Nothing in ${scopeLabel.toLowerCase()} matches "${search.trim()}". The search covers names, addresses, subjects and message bodies.` + : "Try a different search or clear the filters." + : "New mail shows up here as it arrives."} +

+ {/* The commonest reason a search finds nothing is that the thing is + filed somewhere else. Offer the wider search rather than quietly + overriding the scope the reader chose. */} + {filtering && search.trim() && onSearchAllMail && ( + + )}
); } diff --git a/web/src/components/app/unibox/SelectionBar.tsx b/web/src/components/app/unibox/SelectionBar.tsx index 70370dbc4..6bdc60184 100644 --- a/web/src/components/app/unibox/SelectionBar.tsx +++ b/web/src/components/app/unibox/SelectionBar.tsx @@ -5,6 +5,7 @@ // absolute, so it cannot park itself below the fold of a long list. import React from "react"; +import { AnimatePresence, motion } from "framer-motion"; import { ArchiveIcon, CheckIcon, @@ -51,99 +52,122 @@ export function SelectionBar({ [onClear], ); - if (count === 0) return null; - const filed = scope === "archive" || scope === "trash"; const snoozedScope = scope === "snoozed"; return ( -
-
- - {count.toLocaleString()} selected -
- - } - label="Mark read" - onClick={() => run(() => actions.setSeen(threadIds, true))} - /> - } - label="Mark unread" - onClick={() => run(() => actions.setSeen(threadIds, false))} - /> - - {snoozedScope ? ( - } - label="Un-snooze" - onClick={() => run(() => actions.unsnooze(threadIds))} - /> - ) : ( - - - - - - - Snooze {count.toLocaleString()} until - - {SNOOZE_PRESETS.map((p) => ( - run(() => actions.snooze(threadIds, p.until()))} - > - {p.label} - - ))} - - - )} - - - ) : ( - - ) - } - label={filed ? "Move to inbox" : "Archive"} - busy={actions.filing} - onClick={() => - run(() => actions.file(threadIds, filed ? "inbox" : "archive")) - } - /> - - {scope !== "trash" && ( - } - label="Delete" - danger - busy={actions.filing} - onClick={() => run(() => actions.file(threadIds, "trash"))} - /> - )} - -
- +
+ + {count.toLocaleString()} selected + {/* The count ticks rather than jumps when rows are added. */} + + + + + {count.toLocaleString()} + + + + selected + +
+ + } + label="Mark read" + onClick={() => run(() => actions.setSeen(threadIds, true))} + /> + } + label="Mark unread" + onClick={() => run(() => actions.setSeen(threadIds, false))} + /> + + {snoozedScope ? ( + } + label="Un-snooze" + onClick={() => run(() => actions.unsnooze(threadIds))} + /> + ) : ( + + + + + + + Snooze {count.toLocaleString()} until + + {SNOOZE_PRESETS.map((p) => ( + run(() => actions.snooze(threadIds, p.until()))} + > + {p.label} + + ))} + + + )} + + + ) : ( + + ) + } + label={filed ? "Move to inbox" : "Archive"} + busy={actions.filing} + onClick={() => + run(() => actions.file(threadIds, filed ? "inbox" : "archive")) + } + /> + + {scope !== "trash" && ( + } + label="Delete" + danger + busy={actions.filing} + onClick={() => run(() => actions.file(threadIds, "trash"))} + /> + )} + +
+ +
); } diff --git a/web/src/hooks/useConversationActions.tsx b/web/src/hooks/useConversationActions.tsx index 32a7688d5..501bf5cee 100644 --- a/web/src/hooks/useConversationActions.tsx +++ b/web/src/hooks/useConversationActions.tsx @@ -95,7 +95,13 @@ export function useConversationActions(): ConversationActions { if (folder === "inbox") toast.success(done); else offerUndo(done, threadIds); } catch { - toast.error(copy.failed); + // The rows left the list on click; the refetch is putting them + // back, and the toast has to say so or it reads as a glitch. + toast.error( + threadIds.length === 1 + ? `${copy.failed}. It's back in the list.` + : `${copy.failed} ${threadIds.length.toLocaleString()} conversations. They're back in the list.`, + ); } }, [moveFolder, offerUndo], @@ -130,7 +136,7 @@ export function useConversationActions(): ConversationActions { } // Gone from the list the moment it is snoozed; the refetch confirms // it, and on error that same refetch is the rollback. - await removeThreadsFromLists(queryClient, threadIds); + await removeThreadsFromLists(queryClient, threadIds, "snooze"); try { await snoozeThreads(threadIds, until); toast.success( diff --git a/web/src/lib/api/client/app/unibox/chunks.test.ts b/web/src/lib/api/client/app/unibox/chunks.test.ts new file mode 100644 index 000000000..21fad2721 --- /dev/null +++ b/web/src/lib/api/client/app/unibox/chunks.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; + +import { inChunks, pairedChunks, UNIBOX_BULK_MAX } from "./chunks"; + +describe("unibox bulk batching", () => { + it("keeps every request under the server's cap", async () => { + const ids = Array.from({ length: 1234 }, (_, i) => `t${i}`); + const sizes: number[] = []; + await inChunks(ids, UNIBOX_BULK_MAX, async (chunk) => { + sizes.push(chunk.length); + }); + expect(sizes).toEqual([500, 500, 234]); + }); + + it("stops at the first failed batch", async () => { + let sent = 0; + await expect( + inChunks(Array.from({ length: 1200 }), 500, async () => { + sent++; + if (sent === 2) throw new Error("boom"); + }), + ).rejects.toThrow("boom"); + expect(sent).toBe(2); + }); + + it("slices two lists in step and still sends one request for none", () => { + const threads = Array.from({ length: 612 }, (_, i) => `t${i}`); + const parts = pairedChunks([], threads, UNIBOX_BULK_MAX); + expect(parts.map((p) => [p.a.length, p.b.length])).toEqual([ + [0, 500], + [0, 112], + ]); + expect(pairedChunks([], [], UNIBOX_BULK_MAX)).toEqual([{ a: [], b: [] }]); + }); +}); diff --git a/web/src/lib/api/client/app/unibox/chunks.ts b/web/src/lib/api/client/app/unibox/chunks.ts new file mode 100644 index 000000000..4d5b7e4e9 --- /dev/null +++ b/web/src/lib/api/client/app/unibox/chunks.ts @@ -0,0 +1,27 @@ +// The unibox bulk endpoints refuse more than this many ids per request +// (errx.ErrSeenMax, SnoozeMaxThreads), so a large selection goes in batches. +export const UNIBOX_BULK_MAX = 500; + +/** Runs `send` once per slice, in order; the first failure stops the rest. */ +export async function inChunks( + items: T[], + size: number, + send: (chunk: T[]) => Promise, +): Promise { + for (let i = 0; i < items.length; i += size) { + await send(items.slice(i, i + size)); + } +} + +/** Slices two id lists in step, so each request stays under the cap for both. */ +export function pairedChunks( + a: string[], + b: string[], + size: number, +): { a: string[]; b: string[] }[] { + const n = Math.max(1, Math.ceil(a.length / size), Math.ceil(b.length / size)); + return Array.from({ length: n }, (_, i) => ({ + a: a.slice(i * size, (i + 1) * size), + b: b.slice(i * size, (i + 1) * size), + })); +} diff --git a/web/src/lib/api/client/app/unibox/markSeen.ts b/web/src/lib/api/client/app/unibox/markSeen.ts index 1cae27c9e..95b3b40b0 100644 --- a/web/src/lib/api/client/app/unibox/markSeen.ts +++ b/web/src/lib/api/client/app/unibox/markSeen.ts @@ -1,4 +1,5 @@ import Request from "../../Request"; +import { pairedChunks, UNIBOX_BULK_MAX } from "./chunks"; // PATCH /unibox/seen marks unibox emails seen/unseen. The backend body is // { email_ids, thread_ids, folder, seen } (models.MarkSeen); callers pass @@ -12,15 +13,24 @@ export default async function markSeen(data: { folder?: string; seen?: boolean; }): Promise { - return await Request({ - method: "PATCH", - url: `/unibox/seen`, - data: { - email_ids: data.ids ?? [], - thread_ids: data.threadIds ?? [], - folder: data.folder, - seen: data.seen ?? true, - }, - authorization: true, - }) + if (data.folder) { + return await Request({ + method: "PATCH", + url: `/unibox/seen`, + data: { email_ids: [], thread_ids: [], folder: data.folder, seen: data.seen ?? true }, + authorization: true, + }); + } + for (const part of pairedChunks(data.ids ?? [], data.threadIds ?? [], UNIBOX_BULK_MAX)) { + await Request({ + method: "PATCH", + url: `/unibox/seen`, + data: { + email_ids: part.a, + thread_ids: part.b, + seen: data.seen ?? true, + }, + authorization: true, + }); + } } diff --git a/web/src/lib/api/client/app/unibox/moveFolder.ts b/web/src/lib/api/client/app/unibox/moveFolder.ts index b00a3c126..31f5d03fd 100644 --- a/web/src/lib/api/client/app/unibox/moveFolder.ts +++ b/web/src/lib/api/client/app/unibox/moveFolder.ts @@ -1,4 +1,5 @@ import Request from "../../Request"; +import { pairedChunks, UNIBOX_BULK_MAX } from "./chunks"; // The three folders a user can file a conversation into. sent/drafts/spam are // verdicts the provider reaches, and the backend refuses them here. @@ -16,14 +17,16 @@ export default async function moveFolder(data: { threadIds?: string[]; folder: FilableFolder; }): Promise { - return await Request({ - method: "PATCH", - url: `/unibox/folder`, - data: { - email_ids: data.ids ?? [], - thread_ids: data.threadIds ?? [], - folder: data.folder, - }, - authorization: true, - }) + for (const part of pairedChunks(data.ids ?? [], data.threadIds ?? [], UNIBOX_BULK_MAX)) { + await Request({ + method: "PATCH", + url: `/unibox/folder`, + data: { + email_ids: part.a, + thread_ids: part.b, + folder: data.folder, + }, + authorization: true, + }); + } } diff --git a/web/src/lib/api/client/app/unibox/snoozeThread.ts b/web/src/lib/api/client/app/unibox/snoozeThread.ts index 81f295ee4..e6142008f 100644 --- a/web/src/lib/api/client/app/unibox/snoozeThread.ts +++ b/web/src/lib/api/client/app/unibox/snoozeThread.ts @@ -1,4 +1,5 @@ import Request from "../../Request"; +import { inChunks, UNIBOX_BULK_MAX } from "./chunks"; export interface SnoozeRequest { thread_id: string; @@ -27,23 +28,28 @@ export async function snoozeThreads( threadIds: string[], until: Date, ): Promise { - await Request<{ data: SnoozeResponse[] }>({ - method: "POST", - url: "/unibox/snooze", - authorization: true, - data: { thread_ids: threadIds, snoozed_until: until.toISOString() }, - }); + await inChunks(threadIds, UNIBOX_BULK_MAX, (chunk) => + Request<{ data: SnoozeResponse[] }>({ + method: "POST", + url: "/unibox/snooze", + authorization: true, + data: { thread_ids: chunk, snoozed_until: until.toISOString() }, + }), + ); } export async function unsnoozeThread(threadId: string): Promise { return await unsnoozeThreads([threadId]); } +// Ids travel in the query string here, so the batches are smaller than the +// server's cap to keep each URL a sane length. export async function unsnoozeThreads(threadIds: string[]): Promise { - const usp = new URLSearchParams({ thread_id: threadIds.join(",") }); - await Request({ - method: "DELETE", - url: `/unibox/snooze?${usp.toString()}`, - authorization: true, - }); + await inChunks(threadIds, 100, (chunk) => + Request({ + method: "DELETE", + url: `/unibox/snooze?${new URLSearchParams({ thread_id: chunk.join(",") }).toString()}`, + authorization: true, + }), + ); } diff --git a/web/src/lib/api/hooks/app/unibox/listCache.ts b/web/src/lib/api/hooks/app/unibox/listCache.ts index f9c43b9fc..7639fd8d1 100644 --- a/web/src/lib/api/hooks/app/unibox/listCache.ts +++ b/web/src/lib/api/hooks/app/unibox/listCache.ts @@ -7,6 +7,7 @@ import type { InfiniteData, QueryClient } from "@tanstack/react-query"; import type { UniboxListRow } from "@/lib/api/client/app/unibox/searchIncoming"; +import { markRowExit, type RowExitKind } from "@/lib/unibox/rowMotion"; interface SearchPage { data: UniboxListRow[]; @@ -16,8 +17,12 @@ interface SearchPage { export async function removeThreadsFromLists( queryClient: QueryClient, threadIds: string[], + /** Why they leave, which decides the direction the rows slide out. */ + exit?: RowExitKind, ): Promise { if (threadIds.length === 0) return; + // Measured now, while the rows are still where the user clicked them. + if (exit) markRowExit(threadIds, exit); // A refetch already in flight would put the row back. Only refetches are // cancelled: a first load left without data has nothing queued to retry. await queryClient.cancelQueries({ diff --git a/web/src/lib/api/hooks/app/unibox/useMoveFolder.ts b/web/src/lib/api/hooks/app/unibox/useMoveFolder.ts index db6648f55..fdf2d7580 100644 --- a/web/src/lib/api/hooks/app/unibox/useMoveFolder.ts +++ b/web/src/lib/api/hooks/app/unibox/useMoveFolder.ts @@ -20,8 +20,8 @@ export default function useMoveFolder() { mutationFn: ({ ids, folder, threadIds }: MoveFolderInput) => moveFolder({ ids, threadIds, folder }), // The rows go now; the refetch below confirms it. - onMutate: async ({ threadIds }) => { - if (threadIds?.length) await removeThreadsFromLists(queryClient, threadIds); + onMutate: async ({ threadIds, folder }) => { + if (threadIds?.length) await removeThreadsFromLists(queryClient, threadIds, folder); }, // A move changes which scopes the thread belongs to and every folder's // counts, so the whole unibox tree is re-read rather than patched. On diff --git a/web/src/lib/unibox/rowMotion.ts b/web/src/lib/unibox/rowMotion.ts new file mode 100644 index 000000000..69b60697a --- /dev/null +++ b/web/src/lib/unibox/rowMotion.ts @@ -0,0 +1,177 @@ +// How a conversation row enters and leaves the list. +// +// The cache edit that removes a row cannot tell the row why it is going, and +// the row's exit animation is resolved after it has already left the data. So +// the action records its intent here first, keyed by thread, and the exit +// variant reads it back at exit time. The row's node also gets `data-exit`, so +// the underlay's colour and label come from CSS: the exiting element keeps the +// markup of its last render, which predates the intent. + +import type { TargetAndTransition, Variants } from "framer-motion"; + +export type RowExitKind = "archive" | "trash" | "inbox" | "snooze"; + +interface ExitIntent { + kind: RowExitKind; + /** Position among the rows leaving that are on screen, for the stagger. */ + rank: number; + /** Off-screen rows leave at once; animating them only costs frames. */ + visible: boolean; + at: number; +} + +const intents = new Map(); +// Only has to outlive the gap between the action and the cache edit; short so +// a row brought back by Undo does not inherit its old exit. +const INTENT_TTL_MS = 2_000; + +const EASE_OUT = [0.16, 1, 0.3, 1] as const; +const EASE_IN = [0.4, 0, 1, 1] as const; +const STAGGER_S = 0.022; +const STAGGER_CAP = 14; + +function rowElement(id: string): HTMLElement | null { + if (typeof document === "undefined") return null; + const sel = + typeof CSS !== "undefined" && CSS.escape + ? CSS.escape(id) + : id.replace(/["\\]/g, "\\$&"); + return document.querySelector(`[data-thread-id="${sel}"]`); +} + +/** Record why these rows are about to leave. Call before the cache edit. */ +export function markRowExit(threadIds: string[], kind: RowExitKind): void { + const now = Date.now(); + for (const [id, intent] of intents) { + if (now - intent.at > INTENT_TTL_MS) intents.delete(id); + } + const viewport = + typeof window === "undefined" ? 0 : window.innerHeight || 0; + let rank = 0; + // Reads only, no writes in between, so this is one layout pass. + const found = threadIds.map((id) => ({ id, el: rowElement(id) })); + for (const { id, el } of found) { + const rect = el?.getBoundingClientRect(); + const visible = !!rect && rect.bottom > 0 && rect.top < viewport; + intents.set(id, { kind, rank: visible ? rank++ : 0, visible, at: now }); + } + for (const { el } of found) el?.setAttribute("data-exit", kind); +} + +function readIntent(id: string): ExitIntent | undefined { + const intent = intents.get(id); + if (!intent) return undefined; + if (Date.now() - intent.at > INTENT_TTL_MS) { + intents.delete(id); + return undefined; + } + return intent; +} + +export interface RowMotionCustom { + id: string; + /** Inserted above rows already shown (a new arrival, an undo). */ + arrival: boolean; + /** Position among the rows entering together, for the stagger. */ + rank: number; +} + +// The slide each action leaves in: archive right, anything going back or to +// Trash left, a snooze lifts away. +const SLIDE: Record = { + archive: { x: 56 }, + trash: { x: -56 }, + inbox: { x: -56 }, + snooze: { y: -6, scale: 0.985 }, +}; + +function exitDelay(intent: ExitIntent): number { + return Math.min(intent.rank, STAGGER_CAP) * STAGGER_S; +} + +/** The row's outer box: grows in on arrival, folds shut on the way out. */ +export const rowShell: Variants = { + enter: (c: RowMotionCustom) => + c.arrival ? { opacity: 0, height: 0 } : { opacity: 0, y: 6 }, + shown: (c: RowMotionCustom) => + c.arrival + ? { + opacity: 1, + height: "auto", + transition: { + height: { duration: 0.24, ease: EASE_OUT }, + opacity: { duration: 0.2, delay: 0.06 }, + }, + } + : { + opacity: 1, + y: 0, + transition: { + // Rows past the first screenful appear as they are. + duration: c.rank > 16 ? 0 : 0.26, + ease: EASE_OUT, + delay: Math.min(c.rank, 12) * 0.018, + }, + }, + exit: (c: RowMotionCustom) => { + const intent = readIntent(c.id); + if (!intent) { + // Filed elsewhere (a teammate, a refetch): fade and fold. + return { + opacity: 0, + height: 0, + transition: { + opacity: { duration: 0.15 }, + height: { duration: 0.22, ease: EASE_OUT, delay: 0.05 }, + }, + }; + } + if (!intent.visible) { + return { opacity: 0, height: 0, transition: { duration: 0 } }; + } + const d = exitDelay(intent); + return { + height: 0, + opacity: 0, + transition: { + height: { duration: 0.24, ease: EASE_OUT, delay: d + 0.2 }, + opacity: { duration: 0.2, delay: d + 0.24 }, + }, + }; + }, +}; + +/** The row's content: slides out in the direction of the action. */ +export const rowContent: Variants = { + enter: {}, + shown: { x: 0, y: 0, scale: 1, opacity: 1 }, + exit: (c: RowMotionCustom) => { + const intent = readIntent(c.id); + if (!intent) return {}; + if (!intent.visible) return { opacity: 0, transition: { duration: 0 } }; + const d = exitDelay(intent); + return { + ...SLIDE[intent.kind], + opacity: 0, + transition: { + default: { duration: 0.22, ease: EASE_IN, delay: d }, + opacity: { duration: 0.2, delay: d + 0.06 }, + }, + }; + }, +}; + +/** What the slide uncovers: a tint and the action's name. */ +export const rowUnderlay: Variants = { + enter: { opacity: 0 }, + shown: { opacity: 0 }, + exit: (c: RowMotionCustom) => { + const intent = readIntent(c.id); + if (!intent || !intent.visible) return { opacity: 0, transition: { duration: 0 } }; + return { + opacity: 1, + transition: { duration: 0.12, delay: exitDelay(intent) }, + }; + }, +}; +