From 7841e34119fad7471aaecc602c82ba138084af49 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Thu, 1 Oct 2026 22:16:59 -0700 Subject: [PATCH 1/2] feat: keep a unibox conversation unread after Mark as unread by skipping the reader's auto-read once the store has moved off its thread, and close the reader when the selection bar marks the open conversation unread --- .../app/app/unibox/uniboxMarkUnread.test.tsx | 128 ++++++++++++++++++ web/src/components/app/unibox/ThreadView.tsx | 6 +- web/src/hooks/useConversationActions.tsx | 6 + 3 files changed, 139 insertions(+), 1 deletion(-) create mode 100644 web/src/app/app/unibox/uniboxMarkUnread.test.tsx diff --git a/web/src/app/app/unibox/uniboxMarkUnread.test.tsx b/web/src/app/app/unibox/uniboxMarkUnread.test.tsx new file mode 100644 index 000000000..354b7ce1a --- /dev/null +++ b/web/src/app/app/unibox/uniboxMarkUnread.test.tsx @@ -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 }; + +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) => { + 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; + 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('[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); + }); +}); diff --git a/web/src/components/app/unibox/ThreadView.tsx b/web/src/components/app/unibox/ThreadView.tsx index 9a54a2d13..2b6112e35 100644 --- a/web/src/components/app/unibox/ThreadView.tsx +++ b/web/src/components/app/unibox/ThreadView.tsx @@ -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 diff --git a/web/src/hooks/useConversationActions.tsx b/web/src/hooks/useConversationActions.tsx index ddb73597d..c745f50b2 100644 --- a/web/src/hooks/useConversationActions.tsx +++ b/web/src/hooks/useConversationActions.tsx @@ -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( From 839235069e3148d37ecd77793647aabb5152d733 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Thu, 1 Oct 2026 22:25:06 -0700 Subject: [PATCH 2/2] feat: add a Cancel selection button next to the All box in the unibox list header so Select mode can be left on a phone without ticking a row or switching folders --- docs/content/docs/guides/unibox.mdx | 2 +- .../app/app/unibox/uniboxSelectMode.test.tsx | 99 +++++++++++++++++++ .../app/unibox/ConversationList.tsx | 30 ++++-- 3 files changed, 121 insertions(+), 10 deletions(-) create mode 100644 web/src/app/app/unibox/uniboxSelectMode.test.tsx diff --git a/docs/content/docs/guides/unibox.mdx b/docs/content/docs/guides/unibox.mdx index 9ddd6ab8f..f98468bc6 100644 --- a/docs/content/docs/guides/unibox.mdx +++ b/docs/content/docs/guides/unibox.mdx @@ -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. diff --git a/web/src/app/app/unibox/uniboxSelectMode.test.tsx b/web/src/app/app/unibox/uniboxSelectMode.test.tsx new file mode 100644 index 000000000..8e3123fe2 --- /dev/null +++ b/web/src/app/app/unibox/uniboxSelectMode.test.tsx @@ -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 }; + +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; + 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(); + }); +}); diff --git a/web/src/components/app/unibox/ConversationList.tsx b/web/src/components/app/unibox/ConversationList.tsx index 8f53c8f3d..d16b0aff1 100644 --- a/web/src/components/app/unibox/ConversationList.tsx +++ b/web/src/components/app/unibox/ConversationList.tsx @@ -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 ? ( - + + + {/* The way out on a phone, where there is no Escape. */} + + ) : (