uniboxHotkeys.test.tsx9.6 KBView on GitHub /**
* Every mail-list key, on a LinkedIn / Slack row.
*
* The unibox shipped with one keyboard and two kinds of row, and only one of them was wired.
* `MailListHotkeys` resolved its target out of a ref that only ever holds an EMAIL id, so on a
* chat row `r` announced "No emails to select" — the thing that started this — while anything
* reached through the bulk selection instead passed `li:<chatId>` to the Gmail driver, which
* happily issued a modify for a thread id Gmail has never heard of.
*
* So these assert both halves of the split: a chat row takes the chat action, an email row in
* the same list still takes the Gmail one, and neither is ever handed the other's id.
*/
import { act, render } from '@testing-library/react';
import { HotkeysProvider } from 'react-hotkeys-hook';
import React from 'react';
// ── Module mocks (must precede the imports they affect) ─────────────────────
jest.mock('nuqs', () => ({ useQueryState: () => ['all', jest.fn()] }));
jest.mock('react-router', () => ({ useParams: () => ({ folder: 'inbox' }) }));
// The exit animation reaches into the DOM for rows this test never renders, and it is not what
// is being asserted — mocking it also keeps the huge <Thread> module out of the graph.
jest.mock('@/modules/threads/threadList/threadItem/components/thread', () => ({
triggerThreadExitAnimation: jest.fn(() => Promise.resolve()),
}));
const mockOptimistic = {
optimisticMarkAsRead: jest.fn(),
optimisticMarkAsUnread: jest.fn(),
optimisticMoveThreadsTo: jest.fn(),
optimisticToggleImportant: jest.fn(),
optimisticDeleteThreads: jest.fn(),
optimisticToggleStar: jest.fn(),
optimisticMarkDone: jest.fn(),
undoLastAction: jest.fn(),
};
jest.mock('@/modules/threads/rendering/use-optimistic-actions', () => ({
useOptimisticActions: () => mockOptimistic,
}));
const mockItemActions = {
markDone: jest.fn(),
markManyDone: jest.fn(),
snooze: jest.fn(),
toggleStar: jest.fn(),
setStarred: jest.fn(),
setUnread: jest.fn(),
restore: jest.fn(),
markChannelRead: jest.fn(),
dropFromFeed: jest.fn(),
};
jest.mock('@/modules/inbox/hooks/use-inbox-item-actions', () => ({
useInboxItemActions: () => mockItemActions,
}));
jest.mock('@/modules/threads/hooks/use-unread-filter', () => ({
useUnreadFilter: () => ({ toggle: jest.fn(), unreadOnly: false }),
}));
const mockToastInfo = jest.fn();
// The factory runs while the imports below are being resolved, which is BEFORE the const
// above is initialised — so `info` forwards lazily rather than capturing the mock by value.
jest.mock('sonner', () => ({
toast: Object.assign(jest.fn(), {
info: (...args: unknown[]) => mockToastInfo(...args),
error: jest.fn(),
dismiss: jest.fn(),
}),
}));
import {
resetOpenChannelChat,
useOpenChannelItem,
} from '@/modules/inbox/hooks/use-open-channel-item';
import { MailListHotkeys } from '@/modules/threads/threadList/utils/mail-list-hotkeys';
import { settledChannelFeeds } from '../../lib/inboxFeed';
import type { InboxItem } from '@/modules/inbox/types';
import { useCedarStore } from '@/modules/store';
// ── Fixtures ────────────────────────────────────────────────────────────────
const linkedinItem = (over: Partial<InboxItem> = {}): InboxItem =>
({
id: 'li:chat_1',
channel: 'linkedin',
ref: { kind: 'linkedin', chatId: 'chat_1', unipileAccountId: 'A1' },
sortedAt: '2026-09-01T00:00:00.000Z',
snippet: 'hi',
unread: false,
starred: false,
counterpart: { name: 'Dana' },
...over,
}) as unknown as InboxItem;
const slackItem = (over: Partial<InboxItem> = {}): InboxItem =>
({
id: 'slack:W1:C123',
channel: 'slack',
ref: { kind: 'slack', slackChannelId: 'C123', workspaceId: 'W1', conversationId: 'conv_1' },
sortedAt: '2026-09-01T01:00:00.000Z',
snippet: 'ping',
unread: true,
starred: false,
counterpart: { name: '#cedar' },
...over,
}) as unknown as InboxItem;
const emailItem = (): InboxItem =>
({
id: 'email:thr_1',
channel: 'email',
ref: { kind: 'email', threadId: 'thr_1' },
sortedAt: '2026-09-01T02:00:00.000Z',
snippet: 'subject',
unread: true,
starred: false,
counterpart: { name: 'Someone' },
}) as unknown as InboxItem;
/** Renders the hotkeys plus a window onto whichever chat they opened. */
function Harness() {
const { openChannelItem } = useOpenChannelItem();
return (
<HotkeysProvider initiallyActiveScopes={['mail-list']}>
<MailListHotkeys />
<div data-testid="open-chat">{openChannelItem?.id ?? 'none'}</div>
</HotkeysProvider>
);
}
const press = (key=[redacted], modifiers: { shiftKey?: boolean } = {}) =>
act(() => {
document.body.dispatchEvent(
new KeyboardEvent('keydown', {
key,
code: `Key${key.toUpperCase()}`,
bubbles: true,
...modifiers,
}),
);
});
const hoverChannelRow = (item: InboxItem) =>
act(() => {
window.dispatchEvent(new CustomEvent('inboxItemHover', { detail: { item } }));
});
const hoverEmailRow = (id: string) =>
act(() => {
window.dispatchEvent(new CustomEvent('emailHover', { detail: { id } }));
});
const seedFeed = (items: InboxItem[]) =>
act(() => {
useCedarStore.getState().setChannelFeeds(settledChannelFeeds(items));
});
beforeEach(() => {
jest.clearAllMocks();
resetOpenChannelChat();
useCedarStore.setState({
channelFeeds: {},
bulkSelected: [],
selectedThreadId: null,
focusedIndex: null,
threadMap: {},
mainThreadId: '',
activeThreadId: '',
});
});
describe('unibox hotkeys — a chat row takes the chat action', () => {
it('r opens the hovered chat (which is where its composer gets focus)', () => {
const item = linkedinItem();
seedFeed([item]);
const view = render(<Harness />);
hoverChannelRow(item);
press('r');
expect(view.getByTestId('open-chat').textContent).toBe('li:chat_1');
// And nothing tried to open a mail thread with a LinkedIn id.
expect(useCedarStore.getState().isThreadOpen).toBe(false);
});
it('⇧R opens it too — a chat has one composer and one counterpart', () => {
const item = slackItem();
seedFeed([item]);
const view = render(<Harness />);
hoverChannelRow(item);
press('R', { shiftKey=[redacted] });
expect(view.getByTestId('open-chat').textContent).toBe('slack:W1:C123');
});
it('e marks the hovered chat done rather than archiving a Gmail thread', async () => {
const item = linkedinItem();
seedFeed([item]);
render(<Harness />);
hoverChannelRow(item);
press('e');
await act(async () => {});
expect(mockItemActions.markManyDone).toHaveBeenCalledWith([item]);
expect(mockOptimistic.optimisticMarkDone).not.toHaveBeenCalled();
});
it('x stars the chat row instead of calling Gmail with its id', () => {
const item = linkedinItem({ starred: false });
seedFeed([item]);
render(<Harness />);
hoverChannelRow(item);
press('x');
expect(mockItemActions.setStarred).toHaveBeenCalledWith(item, true);
expect(mockOptimistic.optimisticToggleStar).not.toHaveBeenCalled();
});
it('u flips read state through the chat override, not through Gmail', () => {
const read = linkedinItem({ unread: false });
seedFeed([read]);
render(<Harness />);
hoverChannelRow(read);
press('u');
expect(mockItemActions.setUnread).toHaveBeenCalledWith(read, true);
expect(mockOptimistic.optimisticMarkAsUnread).not.toHaveBeenCalled();
});
it('h collects the chat row for the remind dialog', () => {
const item = slackItem();
seedFeed([item]);
render(<Harness />);
hoverChannelRow(item);
press('h');
expect(useCedarStore.getState().threadRemindDialogOpen).toBe(true);
expect(useCedarStore.getState().threadRemindThreadIds).toEqual(['slack:W1:C123']);
});
it('f says so rather than silently doing nothing', () => {
const item = linkedinItem();
seedFeed([item]);
const view = render(<Harness />);
hoverChannelRow(item);
press('f');
expect(mockToastInfo).toHaveBeenCalledWith("Forward isn't available for LinkedIn chats.");
expect(view.getByTestId('open-chat').textContent).toBe('none');
});
});
describe('unibox hotkeys — an email row in the same list still takes the Gmail action', () => {
it('x stars through the optimistic Gmail path', () => {
seedFeed([emailItem()]);
render(<Harness />);
// An email row's selection id is the bare threadId, and it hovers via `emailHover`.
hoverEmailRow('thr_1');
press('x');
expect(mockOptimistic.optimisticToggleStar).toHaveBeenCalledWith(['thr_1'], true);
expect(mockItemActions.setStarred).not.toHaveBeenCalled();
});
it('r opens the thread and leaves the reply for ThreadDisplayHotkeys to draft', () => {
seedFeed([emailItem()]);
render(<Harness />);
hoverEmailRow('thr_1');
press('r');
const state = useCedarStore.getState();
expect(state.selectedThreadId).toBe('thr_1');
expect(state.pendingComposeAction).toBe('reply');
});
});
describe('unibox hotkeys — a mixed selection acts on both halves', () => {
it('e marks the chats done and the threads done, in one keystroke', async () => {
const chat = linkedinItem();
seedFeed([chat, emailItem()]);
act(() => {
useCedarStore.getState().setBulkSelected(['li:chat_1', 'thr_1']);
});
render(<Harness />);
press('e');
await act(async () => {});
expect(mockItemActions.markManyDone).toHaveBeenCalledWith([chat]);
expect(mockOptimistic.optimisticMarkDone).toHaveBeenCalledWith(['thr_1']);
});
});