Merge remote-tracking branch 'origin/main' into fix/unibox-refresh-load

This commit is contained in:
Matthew Meszaros
2026-10-01 22:37:48 -07:00
6 changed files with 260 additions and 11 deletions
+1 -1
View File
@@ -177,7 +177,7 @@ Archiving from a row files the whole conversation, not the message the row is sh
### Working on several at once
Each row has a tick box in its left gutter, in place of the unread dot. Hover a row to see it, or press **Select** in the list header, which shows every box at once and adds an **All** box that takes everything currently loaded. `x` ticks the row you are on from the keyboard, and holding `Shift` while ticking takes the whole run between that row and the last one you ticked.
Each row has a tick box in its left gutter, in place of the unread dot. Hover a row to see it, or press **Select** in the list header, which shows every box at once and adds an **All** box that takes everything currently loaded. The **×** next to **All** leaves select mode and drops anything ticked, which is the way out on a phone; `Escape` does the same on a keyboard. `x` ticks the row you are on from the keyboard, and holding `Shift` while ticking takes the whole run between that row and the last one you ticked.
With anything ticked, a bar appears along the bottom of the screen with the count and the actions that apply to all of them: **Mark read**, **Mark unread**, **Snooze**, **Archive** (or **Move to inbox**) and **Delete**. Each one is a single request, so filing a screenful is as quick as filing one. **Clear** drops the selection, and so does `Escape`; changing view drops it too, since the rows it applied to are no longer the rows on screen.
@@ -0,0 +1,128 @@
// Mark as unread has to stick: the open reader marks what it shows as read,
// so no path that marks the open conversation unread may be followed by a read.
// In a browser the URL follows the store inside a router transition, so the
// reader outlives the press; navigation is deferred here to reproduce that.
import React from "react";
import { describe, it, expect, vi, beforeAll, beforeEach } from "vitest";
import { screen, act, fireEvent, waitFor } from "@testing-library/react";
import type * as ReactRouterDom from "react-router-dom";
import {
installLayoutShims,
mount,
resetScrollTops,
ROWS,
setViewportWidth,
settle,
SUITE,
} from "./uniboxHarness";
type Call = { method?: string; url?: string; data?: Record<string, unknown> };
const calls = vi.hoisted((): Call[] => []);
beforeAll(() => {
installLayoutShims();
setViewportWidth(1512);
});
vi.mock("@/lib/api/client/Request", () => ({
default: async (cfg: Call) => {
calls.push({ method: cfg.method, url: cfg.url, data: cfg.data });
const { route } = await import("./uniboxHarness");
return route(String(cfg?.url ?? ""));
},
}));
vi.mock("react-router-dom", async (orig) => {
const actual = (await orig()) as typeof ReactRouterDom;
return {
...actual,
useNavigate: () => {
const navigate = actual.useNavigate();
return ((...args: Parameters<typeof navigate>) => {
setTimeout(() => void navigate(...args), 100);
}) as typeof navigate;
},
};
});
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 seenPatches = () =>
calls.filter((c) => c.method === "PATCH" && c.url === "/unibox/seen");
const rowFor = (i: number) =>
screen
.getAllByText(ROWS[i].subject)
.map((el) => el.closest<HTMLElement>('[role="button"]'))
.find((row) => row?.querySelector('[aria-label="Conversation actions"]'))!;
describe("unibox mark as unread", SUITE, () => {
beforeEach(() => {
calls.length = 0;
resetScrollTops();
setViewportWidth(1512);
});
it("keeps a conversation unread when the reader's button marks it", async () => {
const router = await mount(`/app/unibox/awaiting/${ROWS[0].thread_id}`);
await settle();
calls.length = 0;
await act(async () => {
fireEvent.click(screen.getAllByRole("button", { name: "Mark as unread" })[0]);
});
await settle();
await settle();
await waitFor(() => expect(router.state.location.pathname).toBe("/app/unibox/awaiting"));
const patches = seenPatches();
expect(patches.length).toBeGreaterThan(0);
expect(patches.every((p) => p.data?.seen === false)).toBe(true);
});
it("keeps the open conversation unread when its row menu marks it", async () => {
await mount(`/app/unibox/awaiting/${ROWS[0].thread_id}`);
await settle();
await act(async () => {
fireEvent.contextMenu(rowFor(0), { clientX: 120, clientY: 160 });
});
await settle();
calls.length = 0;
await act(async () => {
fireEvent.click(screen.getByRole("menuitem", { name: "Mark as unread" }));
});
await settle();
await settle();
const patches = seenPatches();
expect(patches.length).toBeGreaterThan(0);
expect(patches.every((p) => p.data?.seen === false)).toBe(true);
});
});
@@ -0,0 +1,99 @@
// Select mode on a phone: tapping Select by accident has to have a way back,
// because there is no Escape key to leave it with.
import React from "react";
import { describe, it, expect, vi, beforeAll, beforeEach } from "vitest";
import { screen, act, fireEvent } from "@testing-library/react";
import {
installLayoutShims,
mount,
resetScrollTops,
setViewportWidth,
settle,
SUITE,
} from "./uniboxHarness";
type Call = { method?: string; url?: string; data?: Record<string, unknown> };
const calls = vi.hoisted((): Call[] => []);
beforeAll(() => {
installLayoutShims();
setViewportWidth(390);
});
vi.mock("@/lib/api/client/Request", () => ({
default: async (cfg: Call) => {
calls.push({ method: cfg.method, url: cfg.url, data: cfg.data });
const { route } = await import("./uniboxHarness");
return route(String(cfg?.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: () => {},
};
});
async function tap(el: Element) {
await act(async () => {
fireEvent.click(el);
});
await settle();
}
describe("unibox select mode", SUITE, () => {
beforeEach(() => {
calls.length = 0;
resetScrollTops();
setViewportWidth(390);
});
it("leaves select mode from the header without ticking anything", async () => {
await mount("/app/unibox/inbox");
await settle();
await tap(screen.getByRole("button", { name: "Select" }));
expect(screen.getByLabelText("Select all loaded")).toBeTruthy();
await tap(screen.getByRole("button", { name: "Cancel selection" }));
expect(screen.queryByLabelText("Select all loaded")).toBeNull();
expect(screen.getByRole("button", { name: "Select" })).toBeTruthy();
});
it("clears a selection along with the mode", async () => {
await mount("/app/unibox/inbox");
await settle();
await tap(screen.getByRole("button", { name: "Select" }));
await tap(screen.getByLabelText("Select all loaded"));
expect(screen.getByRole("toolbar", { name: "Selection actions" })).toBeTruthy();
await tap(screen.getByRole("button", { name: "Cancel selection" }));
await settle();
expect(screen.queryByRole("toolbar", { name: "Selection actions" })).toBeNull();
expect(screen.getByRole("button", { name: "Select" })).toBeTruthy();
});
});
@@ -11,7 +11,7 @@
import React from "react";
import { AnimatePresence, motion } from "framer-motion";
import { CheckIcon, PanelLeftIcon, SearchIcon } from "lucide-react";
import { CheckIcon, PanelLeftIcon, SearchIcon, XIcon } from "lucide-react";
import { AnimatedRow } from "./AnimatedRow";
import { ConversationItem } from "./ConversationItem";
import { SelectionBar } from "./SelectionBar";
@@ -439,14 +439,26 @@ export function ConversationList({
device hovering a row already shows its box, but the control is
kept everywhere so the feature is discoverable at all. */}
{selecting ? (
<label className="h-7 px-2 rounded-md inline-flex items-center gap-1.5 text-[11.5px] text-slate-600 hover:bg-slate-100 cursor-pointer transition-colors shrink-0">
<Checkbox
checked={allSelected}
onChange={toggleAll}
aria-label={allSelected ? "Deselect all" : "Select all loaded"}
/>
All
</label>
<span className="inline-flex items-center shrink-0">
<label className="h-7 px-2 rounded-md inline-flex items-center gap-1.5 text-[11.5px] text-slate-600 hover:bg-slate-100 cursor-pointer transition-colors">
<Checkbox
checked={allSelected}
onChange={toggleAll}
aria-label={allSelected ? "Deselect all" : "Select all loaded"}
/>
All
</label>
{/* The way out on a phone, where there is no Escape. */}
<button
type="button"
onClick={clearSelection}
aria-label="Cancel selection"
title="Cancel selection (Esc)"
className="h-7 w-7 rounded-md inline-flex items-center justify-center text-slate-400 hover:text-slate-900 hover:bg-slate-100 transition-colors"
>
<XIcon className="w-3.5 h-3.5" />
</button>
</span>
) : (
<button
type="button"
+5 -1
View File
@@ -237,13 +237,17 @@ export function ThreadView({ threadId, emailId, onClose }: ThreadViewProps) {
// is seen the id list is empty and this no-ops, so it self-terminates.
const markSeen = useMarkSeen();
const markSeenMutate = markSeen.mutate;
// The reader outlives a close by a render (the URL follows the store in an
// effect), and reading back the patch Mark as unread just wrote undoes it.
const selected = useAppStore((s) => s.selectedThreadId === threadId);
React.useEffect(() => {
if (!selected) return;
const unseenIds = (q.data?.data ?? [])
.filter((m) => !m.seen)
.map((m) => m.id);
if (unseenIds.length === 0) return;
markSeenMutate({ ids: unseenIds, threadIds: [threadId] });
}, [threadId, q.data, markSeenMutate]);
}, [selected, threadId, q.data, markSeenMutate]);
// Header actions. Each one closes the thread: the effect above would
// otherwise re-mark an "unread" thread as seen on the next refetch, and a
+6
View File
@@ -25,6 +25,7 @@ import {
unsnoozeThreads,
} from "@/lib/api/client/app/unibox/snoozeThread";
import { SNOOZE_MAX_MS } from "@/lib/unibox/snooze";
import { useAppStore } from "@/stores";
// Filing copy, per destination. "Deleted" is deliberately not said anywhere:
// Delete moves the message to Trash, here and in the mailbox.
@@ -110,6 +111,11 @@ export function useConversationActions(): ConversationActions {
const setSeen = React.useCallback(
(threadIds: string[], seen: boolean) => {
if (threadIds.length === 0) return;
// An open reader marks what it shows as read, so unread closes it.
const store = useAppStore.getState();
if (!seen && store.selectedThreadId && threadIds.includes(store.selectedThreadId)) {
store.setSelectedThreadId(null);
}
markSeen.mutate({ threadIds, seen });
if (threadIds.length > 1) {
toast.success(