From a4b85ebcf902174e4236d3cd79cd765d40eef4e7 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Wed, 30 Sep 2026 20:39:10 -0700 Subject: [PATCH] feat: add a right-click menu to unibox conversation rows (open in new tab, read state, snooze, labels, archive or move to inbox, delete) that acts on the whole selection from a ticked row, closes the open conversation when it files or marks it unread, and share useAnchoredMenu and the label panel with the scope rail and reader --- docs/content/docs/guides/unibox.mdx | 6 +- .../app/app/unibox/uniboxContextMenu.test.tsx | 220 ++++++++++++++++++ .../app/unibox/ConversationItem.tsx | 203 ++++++++++++---- .../app/unibox/ConversationList.tsx | 8 + web/src/components/app/unibox/ScopeRail.tsx | 24 +- .../components/app/unibox/ThreadLabelMenu.tsx | 92 +++++--- web/src/hooks/useAnchoredMenu.ts | 31 +++ .../hooks/app/unibox/useSetThreadLabels.ts | 5 + 8 files changed, 481 insertions(+), 108 deletions(-) create mode 100644 web/src/app/app/unibox/uniboxContextMenu.test.tsx create mode 100644 web/src/hooks/useAnchoredMenu.ts diff --git a/docs/content/docs/guides/unibox.mdx b/docs/content/docs/guides/unibox.mdx index 8e3d1bc45..30d007f84 100644 --- a/docs/content/docs/guides/unibox.mdx +++ b/docs/content/docs/guides/unibox.mdx @@ -168,7 +168,9 @@ Archive and Delete both offer **Undo** on the confirmation toast. Open the Trash ### From the list, without opening anything -You do not have to open a conversation to deal with it. Hover a row (or just look at it on a phone, where they are always shown) and the right of the first line carries **Archive** and a three-dot menu with **Mark as read / unread**, **Snooze**, **Archive** or **Move to inbox**, and **Delete**. In Archive and Trash the same controls offer the way back rather than the way out. +You do not have to open a conversation to deal with it. Hover a row (or just look at it on a phone, where they are always shown) and the right of the first line carries **Archive** and a three-dot menu with **Open in new tab**, **Mark as read / unread**, **Snooze**, **Labels**, **Archive** or **Move to inbox**, and **Delete**. Right-clicking a row (or pressing the menu key, or `Shift` + `F10`, on a focused row) opens the same menu where you clicked. In Archive and Trash the same controls offer the way back rather than the way out. + +Using one of these on the conversation you have open closes it, as the conversation's own buttons do. That includes **Mark as unread**, since an open conversation counts as read. Archiving from a row files the whole conversation, not the message the row is showing, so it really does leave the view you filed it from. @@ -178,6 +180,8 @@ Each row has a tick box in its left gutter, in place of the unread dot. Hover a 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. +Right-clicking one of the ticked rows opens a menu for the whole selection, with the same actions as the bar, and it drops the selection once the action is done. Right-clicking a row you have not ticked acts on that row alone and leaves the selection as it was. + ### Filing in the mailbox too Like read state, filing travels to the mailbox itself, so a conversation you clean up here is cleaned up in Gmail, Outlook or your IMAP client as well: diff --git a/web/src/app/app/unibox/uniboxContextMenu.test.tsx b/web/src/app/app/unibox/uniboxContextMenu.test.tsx new file mode 100644 index 000000000..7282e19ff --- /dev/null +++ b/web/src/app/app/unibox/uniboxContextMenu.test.tsx @@ -0,0 +1,220 @@ +// The conversation row's right-click menu: the row's own actions at the +// pointer, and the whole selection when the row is one of several ticked. + +import React from "react"; +import { describe, it, expect, vi, beforeAll, beforeEach } from "vitest"; +import { screen, act, fireEvent, waitFor, within } from "@testing-library/react"; +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("@/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 patches = (path: string) => + calls.filter((c) => c.method === "PATCH" && c.url === path); + +// The open reader repeats the subject, so take the copy that sits in a row. +const rowFor = (i: number) => + screen + .getAllByText(ROWS[i].subject) + .map((el) => el.closest('[role="button"]')) + .find((row) => row?.querySelector('[aria-label="Conversation actions"]'))!; + +async function rightClick(i: number) { + await act(async () => { + fireEvent.contextMenu(rowFor(i), { clientX: 120, clientY: 160 }); + }); + await settle(); +} + +async function choose(name: string) { + await act(async () => { + fireEvent.click(screen.getByRole("menuitem", { name })); + }); + await settle(); +} + +async function tick(i: number) { + await act(async () => { + fireEvent.click(rowFor(i).querySelector('input[type="checkbox"]')!); + }); +} + +describe("unibox conversation context menu", SUITE, () => { + beforeEach(() => { + calls.length = 0; + resetScrollTops(); + setViewportWidth(1512); + }); + + // The same actions the row's "…" offers, and the row stays closed. + it("opens the row's menu on a right-click without opening the conversation", async () => { + const router = await mount("/app/unibox/awaiting"); + await settle(); + + await rightClick(0); + + const menu = screen.getByRole("menu"); + for (const name of ["Open in new tab", "Mark as unread", "Snooze…", "Labels…", "Archive", "Delete"]) { + expect(within(menu).getByRole("menuitem", { name })).toBeTruthy(); + } + expect(router.state.location.pathname).toBe("/app/unibox/awaiting"); + }); + + it("archives the right-clicked conversation", async () => { + await mount("/app/unibox/awaiting"); + await settle(); + + await rightClick(2); + calls.length = 0; + await choose("Archive"); + + const [filed] = patches("/unibox/folder"); + expect(filed).toBeTruthy(); + expect(filed.data).toMatchObject({ folder: "archive", thread_ids: [ROWS[2].thread_id] }); + }); + + it("offers the way back in Trash", async () => { + await mount("/app/unibox/trash"); + await settle(); + + await rightClick(0); + + const menu = screen.getByRole("menu"); + expect(within(menu).getByRole("menuitem", { name: "Move to inbox" })).toBeTruthy(); + expect(within(menu).queryByRole("menuitem", { name: "Delete" })).toBeNull(); + }); + + // A right-click on one of several ticked rows means all of them, as in a + // file list, and the ticks go once it is done. + it("acts on the whole selection when a ticked row is right-clicked", async () => { + await mount("/app/unibox/awaiting"); + await settle(); + await tick(0); + await tick(1); + await settle(); + + await rightClick(1); + expect(screen.getByText("2 conversations")).toBeTruthy(); + expect(screen.queryByRole("menuitem", { name: "Open in new tab" })).toBeNull(); + calls.length = 0; + await choose("Archive"); + + const filed = patches("/unibox/folder"); + expect(filed).toHaveLength(1); + expect(filed[0].data).toMatchObject({ + folder: "archive", + thread_ids: [ROWS[0].thread_id, ROWS[1].thread_id], + }); + await waitFor(() => expect(screen.queryByText("2 selected")).toBeNull()); + }); + + it("acts on just that row when an unticked row is right-clicked", async () => { + await mount("/app/unibox/awaiting"); + await settle(); + await tick(0); + await tick(1); + await settle(); + + await rightClick(4); + calls.length = 0; + await choose("Archive"); + + const [filed] = patches("/unibox/folder"); + expect(filed.data).toMatchObject({ thread_ids: [ROWS[4].thread_id] }); + expect(screen.getByText("2 selected")).toBeTruthy(); + }); + + it("opens the conversation in a new tab at its own address", async () => { + const open = vi.spyOn(window, "open").mockImplementation(() => null); + await mount("/app/unibox/awaiting"); + await settle(); + + await rightClick(3); + await choose("Open in new tab"); + + expect(open).toHaveBeenCalledWith( + `/app/unibox/awaiting/${ROWS[3].thread_id}`, + "_blank", + "noopener", + ); + open.mockRestore(); + }); + + // The reader marks whatever it shows as read, so leaving it open would + // undo the click. + it("closes the open conversation when it is marked unread", async () => { + const router = await mount(`/app/unibox/awaiting/${ROWS[0].thread_id}`); + await settle(); + + await rightClick(0); + calls.length = 0; + await choose("Mark as unread"); + + const [seen] = patches("/unibox/seen"); + expect(seen.data).toMatchObject({ seen: false, thread_ids: [ROWS[0].thread_id] }); + await waitFor(() => expect(router.state.location.pathname).toBe("/app/unibox/awaiting")); + }); + + it("labels from the menu without opening the conversation", async () => { + const router = await mount("/app/unibox/awaiting"); + await settle(); + + await rightClick(0); + await act(async () => { + fireEvent.click(screen.getByRole("menuitem", { name: "Labels…" })); + }); + await settle(); + + expect(screen.getByPlaceholderText("Label conversation…")).toBeTruthy(); + expect(router.state.location.pathname).toBe("/app/unibox/awaiting"); + }); +}); diff --git a/web/src/components/app/unibox/ConversationItem.tsx b/web/src/components/app/unibox/ConversationItem.tsx index 9081b8f68..dbdc9b521 100644 --- a/web/src/components/app/unibox/ConversationItem.tsx +++ b/web/src/components/app/unibox/ConversationItem.tsx @@ -8,20 +8,24 @@ // has more than one, quietly at the end of the preview. // // Triage happens on the row: Archive and a three-dot menu sit where the -// timestamp is, so clearing a conversation never means opening it first. The -// row is a div rather than a button because those controls nest inside it and -// nested buttons are invalid HTML; each of them stops propagation so acting on -// a row never also opens it. +// timestamp is, and a right-click opens the same menu at the pointer, so +// clearing a conversation never means opening it first. The row is a div +// rather than a button because those controls nest inside it and nested +// buttons are invalid HTML; each of them stops propagation so acting on a row +// never also opens it. import React from "react"; +import { useParams, useSearchParams } from "react-router-dom"; import { AnimatePresence, motion } from "framer-motion"; import { ArchiveIcon, + ExternalLinkIcon, InboxIcon, MailCheckIcon, MailOpenIcon, MoonIcon, MoreHorizontalIcon, + TagIcon, TrashIcon, } from "lucide-react"; @@ -30,6 +34,8 @@ import { useAppStore } from "@/stores"; import { useResourceViewers } from "@/hooks/PresenceProvider"; import type { ConversationActions } from "@/hooks/useConversationActions"; import { SNOOZE_PRESETS } from "@/lib/unibox/snooze"; +import { useAnchoredMenu } from "@/hooks/useAnchoredMenu"; +import { ThreadLabelPanel } from "./ThreadLabelMenu"; import { PopoverMenu, PopoverMenuContent, @@ -75,6 +81,13 @@ interface ConversationItemProps { selecting?: boolean; /** Shift extends from the last row ticked, the way a file list does. */ onToggleSelect?: (threadId: string, next: boolean, extend: boolean) => void; + /** + * Every ticked conversation, set while this row is one of several ticked. + * A right-click on it then acts on all of them, as a file list does. + */ + selection?: string[]; + /** Called once a right-click acted on `selection`. */ + onSelectionDone?: () => void; /** * Filing, read state and snooze. Passed in rather than taken from the hook * here so a long list holds one set of mutations, not one per row. @@ -88,6 +101,8 @@ export function ConversationItem({ selected = false, selecting = false, onToggleSelect, + selection, + onSelectionDone, actions, }: ConversationItemProps) { const selectedThreadId = useAppStore((s) => s.selectedThreadId); @@ -122,13 +137,22 @@ export function ConversationItem({ setSelectedAccountId(email.account_id ?? null); }; - + const menu = useAnchoredMenu(); return (
{ + // React bubbles a right-click inside the portaled menu up to here; + // that one should leave the menu where it is. + if (!e.currentTarget.contains(e.target as Node)) { + e.preventDefault(); + return; + } + menu.onContextMenu(e); + }} onKeyDown={(e) => { // Only the row itself activates: keydown from a nested control bubbles // here, and Enter on the three-dot button must open its menu. @@ -148,7 +172,9 @@ export function ConversationItem({ ? "bg-sky-100/60" : isSelected ? "bg-sky-50" - : "hover:bg-slate-50", + : menu.open + ? "bg-slate-50" + : "hover:bg-slate-50", )} > {/* Gutter: the tick box, or the unread dot when nothing is being @@ -289,6 +315,9 @@ export function ConversationItem({ unread={unread} scope={scope} actions={actions} + menu={menu} + selection={selection} + onSelectionDone={onSelectionDone} />
); @@ -296,25 +325,73 @@ export function ConversationItem({ // The row's own triage controls: Archive inline, everything else one click // deeper. Shown on hover from md up and always on touch, where there is no -// hover to reveal them with. +// hover to reveal them with. The menu is also the row's right-click menu. function RowActions({ threadId, unread, scope, actions, + menu, + selection, + onSelectionDone, }: { threadId: string; unread: boolean; scope?: string; actions: ConversationActions; + menu: ReturnType; + selection?: string[]; + onSelectionDone?: () => void; }) { - const [menuOpen, setMenuOpen] = React.useState(false); - const [snoozeMode, setSnoozeMode] = React.useState(false); + const [mode, setMode] = React.useState<"actions" | "snooze" | "labels">("actions"); + const openThreadId = useAppStore((s) => s.selectedThreadId); + const setSelectedThreadId = useAppStore((s) => s.setSelectedThreadId); + const setSelectedAccountId = useAppStore((s) => s.setSelectedAccountId); + const { scope: urlScope } = useParams<{ scope?: string }>(); + const [searchParams] = useSearchParams(); const filed = scope === "archive" || scope === "trash"; const snoozedScope = scope === "snoozed"; + // The "…" button acts on its own row; a right-click on a ticked row acts on + // the whole selection. + const bulk = menu.fromPointer && selection && selection.length > 1 ? selection : null; + const targets = bulk ?? [threadId]; + + // Anything that takes the conversation out of the list closes it in the + // reader too, as the reader's own buttons do. Marking it unread has to as + // well, or the open reader marks it read again straight away. + const run = (fn: () => void | Promise, closesReader = true) => { + if (closesReader && openThreadId && targets.includes(openThreadId)) { + setSelectedThreadId(null); + setSelectedAccountId(null); + } + const done = Promise.resolve(fn()); + if (bulk) void done.finally(() => onSelectionDone?.()); + }; + + const openInNewTab = () => { + const ref = searchParams.get("ref"); + let href = `/app/unibox/${urlScope ?? "inbox"}/${encodeURIComponent(threadId)}`; + if (ref) href += `?ref=${encodeURIComponent(ref)}`; + window.open(href, "_blank", "noopener"); + }; + + // A submenu replaces the item that opened it, so hand the keyboard to its + // first item rather than leaving it on the page. + const focusFirstItem = React.useCallback((el: HTMLDivElement | null) => { + const active = document.activeElement; + if (!el || (active && active !== document.body)) return; + el.querySelector('[role="menuitem"]')?.focus({ preventScroll: true }); + }, []); + const stop = (e: React.MouseEvent) => e.stopPropagation(); + const fade = { + initial: { opacity: 0 }, + animate: { opacity: 1 }, + exit: { opacity: 0 }, + transition: { duration: 0.12, ease: [0.16, 1, 0.3, 1] as const }, + }; return (
actions.file([threadId], filed ? "inbox" : "archive")} + onClick={() => run(() => actions.file([threadId], filed ? "inbox" : "archive"))} disabled={actions.filing} // Touch gets the menu only: two always-on buttons would crowd the row // at the width a phone has. @@ -341,10 +418,10 @@ function RowActions({ { - setMenuOpen(o); - if (!o) setSnoozeMode(false); + menu.menuProps.onOpenChange(o); + if (!o) setMode("actions"); }} > @@ -356,75 +433,105 @@ function RowActions({ - + - {snoozeMode ? ( - + {mode === "snooze" ? ( + Snooze until {SNOOZE_PRESETS.map((p) => ( actions.snooze([threadId], p.until())} + onSelect={() => run(() => actions.snooze(targets, p.until()))} > {p.label} ))} setSnoozeMode(false)} + onSelect={() => setMode("actions")} closeOnSelect={false} > Back + ) : mode === "labels" ? ( + + + ) : ( - - - ) : ( - - ) - } - onSelect={() => actions.setSeen([threadId], unread)} - > - {unread ? "Mark as read" : "Mark as unread"} - + + {bulk ? ( + <> + + {bulk.length.toLocaleString()} conversations + + } + onSelect={() => run(() => actions.setSeen(targets, true), false)} + > + Mark as read + + } + onSelect={() => run(() => actions.setSeen(targets, false))} + > + Mark as unread + + + ) : ( + <> + } + onSelect={openInNewTab} + > + Open in new tab + + + + ) : ( + + ) + } + onSelect={() => run(() => actions.setSeen(targets, unread), !unread)} + > + {unread ? "Mark as read" : "Mark as unread"} + + + )} {snoozedScope ? ( } - onSelect={() => actions.unsnooze([threadId])} + onSelect={() => run(() => actions.unsnooze(targets))} > Un-snooze now ) : ( } - onSelect={() => setSnoozeMode(true)} + onSelect={() => setMode("snooze")} closeOnSelect={false} > Snooze… )} + {!bulk && ( + } + onSelect={() => setMode("labels")} + closeOnSelect={false} + > + Labels… + + )} {filed ? ( } disabled={actions.filing} - onSelect={() => actions.file([threadId], "inbox")} + onSelect={() => run(() => actions.file(targets, "inbox"))} > Move to inbox @@ -432,7 +539,7 @@ function RowActions({ } disabled={actions.filing} - onSelect={() => actions.file([threadId], "archive")} + onSelect={() => run(() => actions.file(targets, "archive"))} > Archive @@ -442,7 +549,7 @@ function RowActions({ danger icon={} disabled={actions.filing} - onSelect={() => actions.file([threadId], "trash")} + onSelect={() => run(() => actions.file(targets, "trash"))} > Delete diff --git a/web/src/components/app/unibox/ConversationList.tsx b/web/src/components/app/unibox/ConversationList.tsx index 4d3579fbe..8f53c8f3d 100644 --- a/web/src/components/app/unibox/ConversationList.tsx +++ b/web/src/components/app/unibox/ConversationList.tsx @@ -568,6 +568,14 @@ export function ConversationList({ selected={picked.has(item.key)} selecting={selecting} onToggleSelect={toggleSelect} + // Only ticked rows get it, so a change in the selection + // re-renders those and not the whole list. + selection={ + picked.has(item.key) && selectedIds.length > 1 + ? selectedIds + : undefined + } + onSelectionDone={clearSelection} actions={actions} email={{ id: item.row.id, diff --git a/web/src/components/app/unibox/ScopeRail.tsx b/web/src/components/app/unibox/ScopeRail.tsx index 63eed4452..d87fbf2db 100644 --- a/web/src/components/app/unibox/ScopeRail.tsx +++ b/web/src/components/app/unibox/ScopeRail.tsx @@ -93,6 +93,7 @@ import { isAutomaticTag, tagMeaning } from "@/lib/unibox/tagMeanings"; import { UNIBOX_VIEWS, viewCategories, type UniboxViewId } from "@/lib/unibox/views"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { shortcutLabel } from "@/components/ui/shortcut-tooltip"; +import { useAnchoredMenu } from "@/hooks/useAnchoredMenu"; export type UniboxScope = | { kind: "all" } @@ -738,29 +739,6 @@ interface RowContext { startRenaming: () => void; } -// A menu that opens from its "…" button or, on right-click, at the pointer. -function useAnchoredMenu() { - const [open, setOpen] = React.useState(false); - const [point, setPoint] = React.useState<{ x: number; y: number } | null>(null); - const onContextMenu = (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - // A keyboard context-menu key reports 0,0: open under the element instead. - const r = e.currentTarget.getBoundingClientRect(); - setPoint(e.clientX || e.clientY ? { x: e.clientX, y: e.clientY } : { x: r.left + 12, y: r.bottom }); - setOpen(true); - }; - const menuProps = { - open, - anchorPoint: point, - onOpenChange: (o: boolean) => { - if (o) setPoint(null); - setOpen(o); - }, - }; - return { open, onContextMenu, menuProps }; -} - // The header every rail section shares: a fold toggle, a dot when a folded // section hides a highlighted count, and on the right a count or the row // editor's pencil, then the section's own menu. diff --git a/web/src/components/app/unibox/ThreadLabelMenu.tsx b/web/src/components/app/unibox/ThreadLabelMenu.tsx index 3e6696d93..ce8762879 100644 --- a/web/src/components/app/unibox/ThreadLabelMenu.tsx +++ b/web/src/components/app/unibox/ThreadLabelMenu.tsx @@ -4,10 +4,12 @@ // something is assigned; the header's meta line shows the chips), the panel // has a search-or-create header, an assigned-chips row, and color-dotted // checkbox rows. Assigns at the thread level via PUT /unibox/thread/labels. +// The panel is exported on its own for the conversation row's menu. import React from "react"; import { CheckIcon, Loader2Icon, PlusIcon, TagIcon } from "lucide-react"; import toast from "react-hot-toast"; +import { useIsMutating } from "@tanstack/react-query"; import { PopoverMenu, @@ -18,7 +20,9 @@ import { CategoryChip } from "@/components/app/contacts/CategoryPicker"; import { useUserProfile } from "@/hooks/context/user"; import useCreateCategory from "@/lib/api/hooks/app/categories/useCreateCategory"; import useThreadLabels from "@/lib/api/hooks/app/unibox/useThreadLabels"; -import useSetThreadLabels from "@/lib/api/hooks/app/unibox/useSetThreadLabels"; +import useSetThreadLabels, { + setThreadLabelsKey, +} from "@/lib/api/hooks/app/unibox/useSetThreadLabels"; import { TagMeaningTooltip } from "@/components/ui/tag-meaning-tooltip"; import { errorMessage } from "@/lib/errors/message"; @@ -29,6 +33,52 @@ interface Props { } export function ThreadLabelMenu({ threadId, open, onOpenChange }: Props) { + const labelsQ = useThreadLabels(threadId); + const saving = useIsMutating({ mutationKey: setThreadLabelsKey(threadId) }) > 0; + const assigned = (labelsQ.data ?? []).length > 0; + + return ( + + + + + + + + + ); +} + +// The search-or-create box and the label rows, mounted only while shown. +export function ThreadLabelPanel({ + threadId, + shortcutHint = true, +}: { + threadId: string; + /** The `c` hint names the open conversation, so a list row leaves it out. */ + shortcutHint?: boolean; +}) { const { user } = useUserProfile(); const categories = React.useMemo( () => user.categories ?? [], @@ -39,10 +89,6 @@ export function ThreadLabelMenu({ threadId, open, onOpenChange }: Props) { const createCategory = useCreateCategory(); const [query, setQuery] = React.useState(""); - React.useEffect(() => { - if (!open) setQuery(""); - }, [open]); - const current = React.useMemo(() => labelsQ.data ?? [], [labelsQ.data]); const currentIds = React.useMemo( () => new Set(current.map((c) => c.id)), @@ -83,32 +129,6 @@ export function ThreadLabelMenu({ threadId, open, onOpenChange }: Props) { }; return ( - - - - -
{/* Search-or-create header. */}
@@ -209,12 +229,12 @@ export function ThreadLabelMenu({ threadId, open, onOpenChange }: Props) {
Labels are shared with contacts - - c - + {shortcutHint && ( + + c + + )}
- - ); } diff --git a/web/src/hooks/useAnchoredMenu.ts b/web/src/hooks/useAnchoredMenu.ts new file mode 100644 index 000000000..0a50f8bfd --- /dev/null +++ b/web/src/hooks/useAnchoredMenu.ts @@ -0,0 +1,31 @@ +// A PopoverMenu that opens under its own trigger on a click and at the pointer +// on a right-click. Spread `menuProps` onto the PopoverMenu and put +// `onContextMenu` on the element the right-click belongs to. + +import React from "react"; + +export type AnchorPoint = { x: number; y: number }; + +export function useAnchoredMenu() { + const [open, setOpen] = React.useState(false); + const [point, setPoint] = React.useState(null); + const onContextMenu = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + // A keyboard context-menu key reports 0,0: open under the element instead. + const r = e.currentTarget.getBoundingClientRect(); + setPoint(e.clientX || e.clientY ? { x: e.clientX, y: e.clientY } : { x: r.left + 12, y: r.bottom }); + setOpen(true); + }; + const menuProps = { + open, + anchorPoint: point, + onOpenChange: (o: boolean) => { + if (o) setPoint(null); + setOpen(o); + }, + }; + // `fromPointer` tells a right-click from the trigger, for menus that act on + // more than their own row when right-clicked. + return { open, fromPointer: open && point !== null, onContextMenu, menuProps }; +} diff --git a/web/src/lib/api/hooks/app/unibox/useSetThreadLabels.ts b/web/src/lib/api/hooks/app/unibox/useSetThreadLabels.ts index 01210874d..8c905298d 100644 --- a/web/src/lib/api/hooks/app/unibox/useSetThreadLabels.ts +++ b/web/src/lib/api/hooks/app/unibox/useSetThreadLabels.ts @@ -2,6 +2,10 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import setThreadLabels from "@/lib/api/client/app/unibox/setThreadLabels"; import type MiniCategory from "@/lib/api/models/app/contacts/MiniCategory"; +// Lets a trigger show the save in flight from a panel it does not render. +export const setThreadLabelsKey = (threadId: string) => + ["unibox", "thread", "labels", "set", threadId] as const; + // Replaces a thread's conversation labels. On success it primes the // per-thread labels cache and invalidates everything the label change // touches: the (collapsed) inbox list rows that render label chips and @@ -10,6 +14,7 @@ export default function useSetThreadLabels(threadId: string) { const queryClient = useQueryClient(); return useMutation({ + mutationKey: setThreadLabelsKey(threadId), mutationFn: (categoryIds: string[]) => setThreadLabels(threadId, categoryIds), onSuccess: (labels: MiniCategory[]) => {