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']);
  });
});