Merge pull request #689 from warmbly/fix/inbox-bulk-archive-reappearing

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
This commit is contained in:
Matthew Meszaros
2026-09-25 03:36:49 +00:00
committed by GitHub
15 changed files with 921 additions and 224 deletions
+9 -5
View File
@@ -456,7 +456,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)
@@ -1034,9 +1034,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]
}
@@ -1084,9 +1086,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]
}
@@ -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)
}
}
@@ -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<string, unknown> };
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} <s${i}@example.com>`],
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<string, unknown>;
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());
});
});
@@ -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 (
<motion.div
data-thread-id={motionKey}
custom={custom}
variants={rowShell}
initial="enter"
animate="shown"
exit="exit"
className="group/row relative overflow-hidden border-b border-slate-100"
>
<motion.div
aria-hidden
custom={custom}
variants={rowUnderlay}
className="absolute inset-0 pointer-events-none"
>
<span className={`${UNDERLAY} pl-5 bg-sky-50 text-sky-700 group-data-[exit=archive]/row:flex`}>
<ArchiveIcon className="w-3.5 h-3.5" />
Archived
</span>
<span className={`${UNDERLAY} justify-end pr-5 bg-rose-50 text-rose-600 group-data-[exit=trash]/row:flex`}>
<TrashIcon className="w-3.5 h-3.5" />
Moved to Trash
</span>
<span className={`${UNDERLAY} justify-end pr-5 bg-emerald-50 text-emerald-700 group-data-[exit=inbox]/row:flex`}>
<InboxIcon className="w-3.5 h-3.5" />
Moved to Inbox
</span>
<span className={`${UNDERLAY} pl-5 bg-amber-50 text-amber-700 group-data-[exit=snooze]/row:flex`}>
<MoonIcon className="w-3.5 h-3.5" />
Snoozed
</span>
</motion.div>
<motion.div custom={custom} variants={rowContent} className="relative bg-white">
{children}
</motion.div>
</motion.div>
);
}
@@ -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<Bucket, string> = {
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<string>;
gen: ReadonlyMap<string, number>;
}>(() => ({ sig: "", set: resultSet, prev: new Set<string>(), 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<string, { arrival: boolean; rank: number }>();
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({
<div
ref={listRef}
className={cn(
"flex-1 overflow-y-auto transition-opacity duration-200",
// The x clip keeps a row sliding out from drawing a scrollbar.
"flex-1 overflow-y-auto overflow-x-hidden transition-opacity duration-200",
stale && emails.length > 0 && "opacity-50",
)}
aria-busy={stale || undefined}
@@ -485,85 +543,82 @@ export function ConversationList({
Try again
</button>
</div>
) : emails.length === 0 ? (
<div className="px-5 py-16 text-center">
<p className="text-[12.5px] text-slate-700 font-medium mb-1">
{filtering ? "No matches" : "Nothing here"}
</p>
<p className="text-[11.5px] text-slate-400 max-w-[32ch] mx-auto leading-relaxed">
{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."}
</p>
{/* 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 && (
<button
type="button"
onClick={onSearchAllMail}
className="mt-3 h-7 px-2.5 rounded-md bg-slate-900 hover:bg-slate-800 text-white text-[11.5px] font-medium inline-flex items-center gap-1.5 transition-colors"
>
<SearchIcon className="w-3 h-3" />
Search all mail
</button>
)}
</div>
) : (
<React.Fragment key={shownKey.current}>
{grouped.map((g) => (
<section key={g.bucket}>
<div className="sticky top-0 z-10 px-4 h-7 bg-white/95 backdrop-blur-sm flex items-center">
<span className="text-[10.5px] uppercase tracking-[0.12em] text-slate-400 font-medium">
{BUCKET_LABELS[g.bucket]}
</span>
</div>
<div className="divide-y divide-slate-100">
<AnimatePresence initial={false}>
{g.rows.map((e) => (
<motion.div
key={e.thread_id || e.id}
data-thread-id={e.thread_id || e.id}
// A row that arrives fades in; one that is filed,
// snoozed or deleted folds away instead of vanishing.
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.18, ease: [0.16, 1, 0.3, 1] }}
style={{ overflow: "hidden" }}
>
<ConversationItem
// The row's actions read the scope: Archive and Trash
// offer the way back rather than the way out.
scope={rowScope}
selected={picked.has(e.thread_id || e.id)}
selecting={selecting}
onToggleSelect={toggleSelect}
actions={actions}
email={{
id: e.id,
from: e.from_addr?.[0] ?? "",
to: e.to_addr?.[0] ?? "",
subject: e.subject,
snippet: e.snippet,
date: new Date(e.internal_date),
// Bold the whole conversation when any message in
// the thread is unread.
is_seen: !e.has_unread,
thread_id: e.thread_id,
account_id: e.email_id,
message_count: e.message_count,
labels: e.labels,
}}
/>
</motion.div>
))}
</AnimatePresence>
</div>
</section>
))}
<AnimatePresence>
{items.map((item) =>
item.kind === "header" ? (
<motion.div
key={presenceKey(item.key)}
initial={{ opacity: 0 }}
animate={{ opacity: 1, height: 28, transition: { duration: 0.2 } }}
// Leaves after the rows under it have slid away.
exit={{ opacity: 0, height: 0, transition: { duration: 0.2, delay: 0.24 } }}
className="sticky top-0 z-10 px-4 h-7 bg-white/95 backdrop-blur-sm flex items-center overflow-hidden"
>
<span className="text-[10.5px] uppercase tracking-[0.12em] text-slate-400 font-medium">
{BUCKET_LABELS[item.bucket]}
</span>
</motion.div>
) : (
<AnimatedRow
key={presenceKey(item.key)}
motionKey={item.key}
custom={{
id: item.key,
arrival: entering.get(item.key)?.arrival ?? false,
rank: entering.get(item.key)?.rank ?? 0,
}}
>
<ConversationItem
// The row's actions read the scope: Archive and Trash
// offer the way back rather than the way out.
scope={rowScope}
selected={picked.has(item.key)}
selecting={selecting}
onToggleSelect={toggleSelect}
actions={actions}
email={{
id: item.row.id,
from: item.row.from_addr?.[0] ?? "",
to: item.row.to_addr?.[0] ?? "",
subject: item.row.subject,
snippet: item.row.snippet,
date: new Date(item.row.internal_date),
// Bold the whole conversation when any message in
// the thread is unread.
is_seen: !item.row.has_unread,
thread_id: item.row.thread_id,
account_id: item.row.email_id,
message_count: item.row.message_count,
labels: item.row.labels,
}}
/>
</AnimatedRow>
),
)}
</AnimatePresence>
{/* 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. */}
<AnimatePresence>
{emails.length === 0 && !hasNextPage && (
<motion.div
key="empty"
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0, transition: { duration: 0.28, delay: 0.3, ease: [0.16, 1, 0.3, 1] } }}
exit={{ opacity: 0, transition: { duration: 0.1 } }}
>
<EmptyState
caughtUp={!filtering && CAUGHT_UP_SCOPES.has(scopeKey)}
filtering={filtering}
search={search}
scopeLabel={scopeLabel}
onSearchAllMail={onSearchAllMail}
/>
</motion.div>
)}
</AnimatePresence>
{hasNextPage && (
<div ref={setSentinel}>
{isFetchingNextPage ? (
@@ -590,12 +645,79 @@ export function ConversationList({
)}
</div>
<SelectionBar
threadIds={selectedIds}
actions={actions}
scope={rowScope}
onClear={clearSelection}
/>
<AnimatePresence>
{selectedIds.length > 0 && (
<SelectionBar
key="selection"
threadIds={selectedIds}
actions={actions}
scope={rowScope}
onClear={clearSelection}
/>
)}
</AnimatePresence>
</div>
);
}
function EmptyState({
caughtUp,
filtering,
search,
scopeLabel,
onSearchAllMail,
}: {
caughtUp: boolean;
filtering: boolean;
search: string;
scopeLabel: string;
onSearchAllMail?: () => void;
}) {
if (caughtUp) {
return (
<div className="px-5 py-16 text-center">
<motion.div
initial={{ scale: 0.6, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ type: "spring", stiffness: 380, damping: 22, delay: 0.36 }}
className="mx-auto mb-3 size-9 rounded-full bg-emerald-50 text-emerald-600 inline-flex items-center justify-center"
>
<CheckIcon className="w-4 h-4" strokeWidth={2.25} />
</motion.div>
<p className="text-[12.5px] text-slate-700 font-medium mb-1">
All caught up
</p>
<p className="text-[11.5px] text-slate-400 max-w-[32ch] mx-auto leading-relaxed">
New mail shows up here as it arrives.
</p>
</div>
);
}
return (
<div className="px-5 py-16 text-center">
<p className="text-[12.5px] text-slate-700 font-medium mb-1">
{filtering ? "No matches" : "Nothing here"}
</p>
<p className="text-[11.5px] text-slate-400 max-w-[32ch] mx-auto leading-relaxed">
{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."}
</p>
{/* 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 && (
<button
type="button"
onClick={onSearchAllMail}
className="mt-3 h-7 px-2.5 rounded-md bg-slate-900 hover:bg-slate-800 text-white text-[11.5px] font-medium inline-flex items-center gap-1.5 transition-colors"
>
<SearchIcon className="w-3 h-3" />
Search all mail
</button>
)}
</div>
);
}
+112 -88
View File
@@ -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 (
<div
role="toolbar"
aria-label="Selection actions"
className="fixed bottom-4 left-1/2 -translate-x-1/2 z-30 flex items-center max-w-[calc(100vw-16px)] flex-wrap justify-center md:max-w-none md:flex-nowrap gap-1.5 rounded-md border border-slate-200 bg-white shadow-[0_6px_20px_-4px_rgba(15,23,42,0.12),0_2px_4px_rgba(15,23,42,0.04)] px-2 py-1.5"
>
<div className="inline-flex items-center gap-1.5 px-2 h-7 rounded bg-sky-50 text-sky-700 text-[12px] font-medium">
<CheckIcon className="w-3 h-3" />
<span>{count.toLocaleString()} selected</span>
</div>
<BarButton
icon={<MailCheckIcon className="w-3 h-3" />}
label="Mark read"
onClick={() => run(() => actions.setSeen(threadIds, true))}
/>
<BarButton
icon={<MailOpenIcon className="w-3 h-3" />}
label="Mark unread"
onClick={() => run(() => actions.setSeen(threadIds, false))}
/>
{snoozedScope ? (
<BarButton
icon={<MoonIcon className="w-3 h-3" />}
label="Un-snooze"
onClick={() => run(() => actions.unsnooze(threadIds))}
/>
) : (
<PopoverMenu side="top" align="center">
<PopoverMenuTrigger asChild>
<button
type="button"
className="h-7 px-2.5 rounded text-[12px] text-slate-700 hover:text-slate-900 hover:bg-slate-100 font-medium inline-flex items-center gap-1.5 transition-colors"
>
<MoonIcon className="w-3 h-3" />
<span className="hidden sm:inline">Snooze</span>
</button>
</PopoverMenuTrigger>
<PopoverMenuContent>
<PopoverMenuLabel>
Snooze {count.toLocaleString()} until
</PopoverMenuLabel>
{SNOOZE_PRESETS.map((p) => (
<PopoverMenuItem
key={p.label}
onSelect={() => run(() => actions.snooze(threadIds, p.until()))}
>
{p.label}
</PopoverMenuItem>
))}
</PopoverMenuContent>
</PopoverMenu>
)}
<BarButton
icon={
filed ? (
<InboxIcon className="w-3 h-3" />
) : (
<ArchiveIcon className="w-3 h-3" />
)
}
label={filed ? "Move to inbox" : "Archive"}
busy={actions.filing}
onClick={() =>
run(() => actions.file(threadIds, filed ? "inbox" : "archive"))
}
/>
{scope !== "trash" && (
<BarButton
icon={<TrashIcon className="w-3 h-3" />}
label="Delete"
danger
busy={actions.filing}
onClick={() => run(() => actions.file(threadIds, "trash"))}
/>
)}
<div className="h-4 w-px bg-slate-200" />
<button
type="button"
onClick={onClear}
className="h-7 px-2.5 rounded text-[12px] text-slate-500 hover:text-slate-900 transition-colors"
// Centred by a full-width track rather than a translate, which the
// motion transform would overwrite.
<div className="fixed inset-x-0 bottom-4 z-30 flex justify-center pointer-events-none">
<motion.div
role="toolbar"
aria-label="Selection actions"
initial={{ opacity: 0, y: 16, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 12, scale: 0.97, transition: { duration: 0.14 } }}
transition={{ type: "spring", stiffness: 520, damping: 34 }}
className="pointer-events-auto flex items-center max-w-[calc(100vw-16px)] flex-wrap justify-center md:max-w-none md:flex-nowrap gap-1.5 rounded-md border border-slate-200 bg-white shadow-[0_6px_20px_-4px_rgba(15,23,42,0.12),0_2px_4px_rgba(15,23,42,0.04)] px-2 py-1.5"
>
Clear
</button>
<div className="inline-flex items-center gap-1.5 px-2 h-7 rounded bg-sky-50 text-sky-700 text-[12px] font-medium">
<CheckIcon className="w-3 h-3" />
<span className="sr-only">{count.toLocaleString()} selected</span>
{/* The count ticks rather than jumps when rows are added. */}
<span aria-hidden className="inline-flex items-center gap-1">
<span className="relative inline-flex overflow-hidden tabular-nums">
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={count}
initial={{ y: 8, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: -8, opacity: 0 }}
transition={{ duration: 0.16, ease: [0.16, 1, 0.3, 1] }}
>
{count.toLocaleString()}
</motion.span>
</AnimatePresence>
</span>
selected
</span>
</div>
<BarButton
icon={<MailCheckIcon className="w-3 h-3" />}
label="Mark read"
onClick={() => run(() => actions.setSeen(threadIds, true))}
/>
<BarButton
icon={<MailOpenIcon className="w-3 h-3" />}
label="Mark unread"
onClick={() => run(() => actions.setSeen(threadIds, false))}
/>
{snoozedScope ? (
<BarButton
icon={<MoonIcon className="w-3 h-3" />}
label="Un-snooze"
onClick={() => run(() => actions.unsnooze(threadIds))}
/>
) : (
<PopoverMenu side="top" align="center">
<PopoverMenuTrigger asChild>
<button
type="button"
className="h-7 px-2.5 rounded text-[12px] text-slate-700 hover:text-slate-900 hover:bg-slate-100 font-medium inline-flex items-center gap-1.5 transition-colors"
>
<MoonIcon className="w-3 h-3" />
<span className="hidden sm:inline">Snooze</span>
</button>
</PopoverMenuTrigger>
<PopoverMenuContent>
<PopoverMenuLabel>
Snooze {count.toLocaleString()} until
</PopoverMenuLabel>
{SNOOZE_PRESETS.map((p) => (
<PopoverMenuItem
key={p.label}
onSelect={() => run(() => actions.snooze(threadIds, p.until()))}
>
{p.label}
</PopoverMenuItem>
))}
</PopoverMenuContent>
</PopoverMenu>
)}
<BarButton
icon={
filed ? (
<InboxIcon className="w-3 h-3" />
) : (
<ArchiveIcon className="w-3 h-3" />
)
}
label={filed ? "Move to inbox" : "Archive"}
busy={actions.filing}
onClick={() =>
run(() => actions.file(threadIds, filed ? "inbox" : "archive"))
}
/>
{scope !== "trash" && (
<BarButton
icon={<TrashIcon className="w-3 h-3" />}
label="Delete"
danger
busy={actions.filing}
onClick={() => run(() => actions.file(threadIds, "trash"))}
/>
)}
<div className="h-4 w-px bg-slate-200" />
<button
type="button"
onClick={onClear}
className="h-7 px-2.5 rounded text-[12px] text-slate-500 hover:text-slate-900 transition-colors"
>
Clear
</button>
</motion.div>
</div>
);
}
+8 -2
View File
@@ -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(
@@ -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: [] }]);
});
});
@@ -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<T>(
items: T[],
size: number,
send: (chunk: T[]) => Promise<unknown>,
): Promise<void> {
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),
}));
}
+21 -11
View File
@@ -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<void> {
return await Request<void>({
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<void>({
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<void>({
method: "PATCH",
url: `/unibox/seen`,
data: {
email_ids: part.a,
thread_ids: part.b,
seen: data.seen ?? true,
},
authorization: true,
});
}
}
+13 -10
View File
@@ -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<void> {
return await Request<void>({
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<void>({
method: "PATCH",
url: `/unibox/folder`,
data: {
email_ids: part.a,
thread_ids: part.b,
folder: data.folder,
},
authorization: true,
});
}
}
@@ -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<void> {
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<void> {
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<void> {
const usp = new URLSearchParams({ thread_id: threadIds.join(",") });
await Request<void>({
method: "DELETE",
url: `/unibox/snooze?${usp.toString()}`,
authorization: true,
});
await inChunks(threadIds, 100, (chunk) =>
Request<void>({
method: "DELETE",
url: `/unibox/snooze?${new URLSearchParams({ thread_id: chunk.join(",") }).toString()}`,
authorization: true,
}),
);
}
@@ -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<void> {
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({
@@ -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
+177
View File
@@ -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<string, ExitIntent>();
// 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<HTMLElement>(`[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<RowExitKind, TargetAndTransition> = {
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) },
};
},
};