diff --git a/docs/content/docs/guides/unibox.mdx b/docs/content/docs/guides/unibox.mdx index 9aea25488..2fba46421 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/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/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. */} + + ) : (