mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-04 00:02:05 +00:00
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
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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<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("@/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 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<HTMLElement>('[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");
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={open}
|
||||
onContextMenu={(e) => {
|
||||
// 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}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -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<typeof useAnchoredMenu>;
|
||||
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<void>, 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<HTMLElement>('[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 (
|
||||
<div
|
||||
@@ -326,7 +403,7 @@ function RowActions({
|
||||
>
|
||||
<RowButton
|
||||
label={filed ? "Move to inbox" : "Archive"}
|
||||
onClick={() => 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({
|
||||
|
||||
<PopoverMenu
|
||||
align="end"
|
||||
open={menuOpen}
|
||||
{...menu.menuProps}
|
||||
onOpenChange={(o) => {
|
||||
setMenuOpen(o);
|
||||
if (!o) setSnoozeMode(false);
|
||||
menu.menuProps.onOpenChange(o);
|
||||
if (!o) setMode("actions");
|
||||
}}
|
||||
>
|
||||
<PopoverMenuTrigger asChild>
|
||||
@@ -356,75 +433,105 @@ function RowActions({
|
||||
<MoreHorizontalIcon className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</PopoverMenuTrigger>
|
||||
<PopoverMenuContent>
|
||||
<PopoverMenuContent className={mode === "labels" ? "p-0" : undefined}>
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
{snoozeMode ? (
|
||||
<motion.div
|
||||
key="snooze"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.12, ease: [0.16, 1, 0.3, 1] }}
|
||||
>
|
||||
{mode === "snooze" ? (
|
||||
<motion.div key="snooze" ref={focusFirstItem} {...fade}>
|
||||
<PopoverMenuLabel>Snooze until</PopoverMenuLabel>
|
||||
{SNOOZE_PRESETS.map((p) => (
|
||||
<PopoverMenuItem
|
||||
key={p.label}
|
||||
onSelect={() => actions.snooze([threadId], p.until())}
|
||||
onSelect={() => run(() => actions.snooze(targets, p.until()))}
|
||||
>
|
||||
{p.label}
|
||||
</PopoverMenuItem>
|
||||
))}
|
||||
<PopoverMenuSeparator />
|
||||
<PopoverMenuItem
|
||||
onSelect={() => setSnoozeMode(false)}
|
||||
onSelect={() => setMode("actions")}
|
||||
closeOnSelect={false}
|
||||
>
|
||||
Back
|
||||
</PopoverMenuItem>
|
||||
</motion.div>
|
||||
) : mode === "labels" ? (
|
||||
<motion.div key="labels" {...fade}>
|
||||
<ThreadLabelPanel threadId={threadId} shortcutHint={false} />
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="actions"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.12, ease: [0.16, 1, 0.3, 1] }}
|
||||
>
|
||||
<PopoverMenuItem
|
||||
icon={
|
||||
unread ? (
|
||||
<MailOpenIcon className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<MailCheckIcon className="w-3.5 h-3.5" />
|
||||
)
|
||||
}
|
||||
onSelect={() => actions.setSeen([threadId], unread)}
|
||||
>
|
||||
{unread ? "Mark as read" : "Mark as unread"}
|
||||
</PopoverMenuItem>
|
||||
<motion.div key="actions" {...fade}>
|
||||
{bulk ? (
|
||||
<>
|
||||
<PopoverMenuLabel>
|
||||
{bulk.length.toLocaleString()} conversations
|
||||
</PopoverMenuLabel>
|
||||
<PopoverMenuItem
|
||||
icon={<MailOpenIcon className="w-3.5 h-3.5" />}
|
||||
onSelect={() => run(() => actions.setSeen(targets, true), false)}
|
||||
>
|
||||
Mark as read
|
||||
</PopoverMenuItem>
|
||||
<PopoverMenuItem
|
||||
icon={<MailCheckIcon className="w-3.5 h-3.5" />}
|
||||
onSelect={() => run(() => actions.setSeen(targets, false))}
|
||||
>
|
||||
Mark as unread
|
||||
</PopoverMenuItem>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<PopoverMenuItem
|
||||
icon={<ExternalLinkIcon className="w-3.5 h-3.5" />}
|
||||
onSelect={openInNewTab}
|
||||
>
|
||||
Open in new tab
|
||||
</PopoverMenuItem>
|
||||
<PopoverMenuSeparator />
|
||||
<PopoverMenuItem
|
||||
icon={
|
||||
unread ? (
|
||||
<MailOpenIcon className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<MailCheckIcon className="w-3.5 h-3.5" />
|
||||
)
|
||||
}
|
||||
onSelect={() => run(() => actions.setSeen(targets, unread), !unread)}
|
||||
>
|
||||
{unread ? "Mark as read" : "Mark as unread"}
|
||||
</PopoverMenuItem>
|
||||
</>
|
||||
)}
|
||||
{snoozedScope ? (
|
||||
<PopoverMenuItem
|
||||
icon={<MoonIcon className="w-3.5 h-3.5" />}
|
||||
onSelect={() => actions.unsnooze([threadId])}
|
||||
onSelect={() => run(() => actions.unsnooze(targets))}
|
||||
>
|
||||
Un-snooze now
|
||||
</PopoverMenuItem>
|
||||
) : (
|
||||
<PopoverMenuItem
|
||||
icon={<MoonIcon className="w-3.5 h-3.5" />}
|
||||
onSelect={() => setSnoozeMode(true)}
|
||||
onSelect={() => setMode("snooze")}
|
||||
closeOnSelect={false}
|
||||
>
|
||||
Snooze…
|
||||
</PopoverMenuItem>
|
||||
)}
|
||||
{!bulk && (
|
||||
<PopoverMenuItem
|
||||
icon={<TagIcon className="w-3.5 h-3.5" />}
|
||||
onSelect={() => setMode("labels")}
|
||||
closeOnSelect={false}
|
||||
>
|
||||
Labels…
|
||||
</PopoverMenuItem>
|
||||
)}
|
||||
<PopoverMenuSeparator />
|
||||
{filed ? (
|
||||
<PopoverMenuItem
|
||||
icon={<InboxIcon className="w-3.5 h-3.5" />}
|
||||
disabled={actions.filing}
|
||||
onSelect={() => actions.file([threadId], "inbox")}
|
||||
onSelect={() => run(() => actions.file(targets, "inbox"))}
|
||||
>
|
||||
Move to inbox
|
||||
</PopoverMenuItem>
|
||||
@@ -432,7 +539,7 @@ function RowActions({
|
||||
<PopoverMenuItem
|
||||
icon={<ArchiveIcon className="w-3.5 h-3.5" />}
|
||||
disabled={actions.filing}
|
||||
onSelect={() => actions.file([threadId], "archive")}
|
||||
onSelect={() => run(() => actions.file(targets, "archive"))}
|
||||
>
|
||||
Archive
|
||||
</PopoverMenuItem>
|
||||
@@ -442,7 +549,7 @@ function RowActions({
|
||||
danger
|
||||
icon={<TrashIcon className="w-3.5 h-3.5" />}
|
||||
disabled={actions.filing}
|
||||
onSelect={() => actions.file([threadId], "trash")}
|
||||
onSelect={() => run(() => actions.file(targets, "trash"))}
|
||||
>
|
||||
Delete
|
||||
</PopoverMenuItem>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<HTMLElement>) => {
|
||||
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.
|
||||
|
||||
@@ -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 (
|
||||
<PopoverMenu
|
||||
align="end"
|
||||
side="bottom"
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
>
|
||||
<PopoverMenuTrigger asChild>
|
||||
<button
|
||||
aria-label="Label this conversation (press c)"
|
||||
title="Label (c)"
|
||||
className={`size-7 rounded-md inline-flex items-center justify-center transition-colors ${
|
||||
open || assigned
|
||||
? open
|
||||
? "bg-slate-100 text-slate-900"
|
||||
: "text-sky-700 hover:bg-slate-100"
|
||||
: "text-slate-500 hover:text-slate-900 hover:bg-slate-100"
|
||||
}`}
|
||||
>
|
||||
{saving ? (
|
||||
<Loader2Icon className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<TagIcon className="w-[15px] h-[15px]" />
|
||||
)}
|
||||
</button>
|
||||
</PopoverMenuTrigger>
|
||||
<PopoverMenuContent className="p-0">
|
||||
<ThreadLabelPanel threadId={threadId} />
|
||||
</PopoverMenuContent>
|
||||
</PopoverMenu>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<PopoverMenu
|
||||
align="end"
|
||||
side="bottom"
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
>
|
||||
<PopoverMenuTrigger asChild>
|
||||
<button
|
||||
aria-label="Label this conversation (press c)"
|
||||
title="Label (c)"
|
||||
className={`size-7 rounded-md inline-flex items-center justify-center transition-colors ${
|
||||
open || current.length > 0
|
||||
? open
|
||||
? "bg-slate-100 text-slate-900"
|
||||
: "text-sky-700 hover:bg-slate-100"
|
||||
: "text-slate-500 hover:text-slate-900 hover:bg-slate-100"
|
||||
}`}
|
||||
>
|
||||
{setLabels.isPending ? (
|
||||
<Loader2Icon className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<TagIcon className="w-[15px] h-[15px]" />
|
||||
)}
|
||||
</button>
|
||||
</PopoverMenuTrigger>
|
||||
<PopoverMenuContent className="p-0">
|
||||
<div className="w-[260px]">
|
||||
{/* Search-or-create header. */}
|
||||
<div className="px-2.5 py-2 border-b border-slate-200 flex items-center gap-1.5">
|
||||
@@ -209,12 +229,12 @@ export function ThreadLabelMenu({ threadId, open, onOpenChange }: Props) {
|
||||
|
||||
<div className="px-2.5 h-7 border-t border-slate-100 flex items-center justify-between text-[10px] text-slate-400">
|
||||
<span>Labels are shared with contacts</span>
|
||||
<kbd className="h-4 px-1 rounded border border-slate-200 bg-slate-50 font-mono inline-flex items-center">
|
||||
c
|
||||
</kbd>
|
||||
{shortcutHint && (
|
||||
<kbd className="h-4 px-1 rounded border border-slate-200 bg-slate-50 font-mono inline-flex items-center">
|
||||
c
|
||||
</kbd>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverMenuContent>
|
||||
</PopoverMenu>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<AnchorPoint | null>(null);
|
||||
const onContextMenu = (e: React.MouseEvent<HTMLElement>) => {
|
||||
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 };
|
||||
}
|
||||
@@ -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[]) => {
|
||||
|
||||
Reference in New Issue
Block a user