channelReactions.test.tsx10.1 KBView on GitHub
import { fireEvent, render, screen } from '@testing-library/react';

// The sender avatar reaches for tRPC (BIMI lookup) and a canvas, neither of which exists here and
// neither of which is what these cases are about.
jest.mock('@/components/ui/bimi-avatar', () => ({
  BimiAvatar: ({ name }: { name: string }) => <span>{name}</span>,
}));

import {
  ChannelMessageList,
  type ThreadMessage,
} from '@/modules/inbox/components/ChannelMessageList';

/**
 * Reactions in the unibox's Slack views (design: slack-parity.md Phase 6).
 *
 * The gap this closes was never ingest β€” `reaction_added`/`reaction_removed` have been handled
 * since `crm_event_reactions` landed, and the CONVERSATION timeline has rendered chips all along.
 * It was that the unibox read never selected them and this row never drew them, so the same
 * message showed its reactions on one screen and not the other.
 *
 * The interesting cases are the ones a screenshot would not catch: a chip whose reaction was
 * written by the webhook (no `emojiUnicode`, only Slack's shortcode), and a surface with no way
 * to react at all β€” a live-filled thread whose messages have no `crm_events` row yet.
 */

const message = (overrides: Partial<ThreadMessage> = {}): ThreadMessage => ({
  id: 'evt-1',
  senderName: 'Jesse',
  outbound: false,
  when: '2026-08-12T00:00:00.000Z',
  body: 'hello',
  ...overrides,
});

describe('ChannelMessageList reactions', () => {
  it('draws a chip per aggregate, with its glyph and count', () => {
    render(
      <ChannelMessageList
        messages={[
          message({
            reactions: [
              { key=[redacted], emojiUnicode: 'πŸ‘€', count: 2, reactedByMe: false },
              { key=[redacted], emojiUnicode: 'πŸŽ‰', count: 1, reactedByMe: true },
            ],
          }),
        ]}
      />,
    );
    expect(screen.getByText('πŸ‘€')).toBeInTheDocument();
    expect(screen.getByText('2')).toBeInTheDocument();
    expect(screen.getByText('πŸŽ‰')).toBeInTheDocument();
  });

  it('resolves the glyph from Slack’s shortcode when the webhook wrote the reaction', () => {
    // Cedar-origin reactions carry `emojiUnicode`; the webhook echo carries only the key. Without
    // the shortcode fallback those chips would render as the bare string "white_check_mark".
    render(
      <ChannelMessageList
        messages={[
          message({ reactions: [{ key=[redacted], emojiUnicode: null, count: 1, reactedByMe: false }] }),
        ]}
      />,
    );
    expect(screen.getByText('βœ…')).toBeInTheDocument();
  });

  it('falls back to the raw key for a workspace’s custom emoji', () => {
    // A custom emoji has no unicode character anywhere; its name is the only thing we can show,
    // and showing nothing would silently drop a reaction that exists.
    render(
      <ChannelMessageList
        messages={[
          message({ reactions: [{ key=[redacted], emojiUnicode: null, count: 3, reactedByMe: false }] }),
        ]}
      />,
    );
    expect(screen.getByText('shipit-parrot')).toBeInTheDocument();
  });

  it('toggles the chip’s own key on click', () => {
    const onToggleReaction = jest.fn();
    render(
      <ChannelMessageList
        messages={[
          message({ reactions: [{ key=[redacted], emojiUnicode: 'πŸ‘€', count: 1, reactedByMe: true }] }),
        ]}
        onToggleReaction={onToggleReaction}
      />,
    );
    fireEvent.click(screen.getByTitle('eyes'));
    // The whole AGGREGATE, not just its key. Slack writes through on `key` + `emojiUnicode` β€”
    // the shortcode, never the glyph, since re-deriving a name from a character fails for custom
    // emoji β€” while LinkedIn and WhatsApp need `reactedByMe` to tell a withdrawal from a
    // replacement, those providers allowing only one reaction per person.
    expect(onToggleReaction).toHaveBeenCalledWith('evt-1', {
      key=[redacted],
      emojiUnicode: 'πŸ‘€',
      count: 1,
      reactedByMe: true,
    });
  });

  it('offers quick reactions and a picker when the surface can react', () => {
    const onReact = jest.fn();
    render(<ChannelMessageList messages={[message()]} onReact={onReact} />);
    fireEvent.click(screen.getByText('βœ…'));
    expect(onReact).toHaveBeenCalledWith('evt-1', 'βœ…');
    expect(screen.getByLabelText('Add reaction')).toBeInTheDocument();
  });

  it('floats the react affordance out of the layout instead of reserving a row for it', () => {
    // Slack's toolbar hovers over the message's top-right corner; it costs the message no height.
    // The predecessor rendered the same buttons inline and merely faded them, so every unreacted
    // message reserved a strip of empty space β€” a channel of plain messages read double-spaced.
    render(<ChannelMessageList messages={[message()]} onReact={jest.fn()} />);
    const toolbar = screen.getByLabelText('Add reaction').parentElement!;
    expect(toolbar.className).toContain('absolute');
    // And nothing inline: an unreacted message has no chips row under its body.
    expect(screen.queryByLabelText('Add another reaction')).not.toBeInTheDocument();
  });

  it('adds Slack’s trailing add-pill once a message already has chips', () => {
    // The row is committed to the space by then, so the pill costs nothing extra β€” it is the
    // in-place way to pile onto an existing reaction without going up to the toolbar.
    render(
      <ChannelMessageList
        messages={[
          message({ reactions: [{ key=[redacted], emojiUnicode: 'πŸ‘€', count: 1, reactedByMe: false }] }),
        ]}
        onReact={jest.fn()}
      />,
    );
    expect(screen.getByLabelText('Add another reaction')).toBeInTheDocument();
  });

  it('renders nothing when there is neither a reaction nor a way to add one', () => {
    // The live-filled thread case: no `crm_events` row exists yet, so there is nothing to react
    // to β€” and a row of dead grey buttons under every message would be worse than none.
    render(<ChannelMessageList messages={[message()]} />);
    expect(screen.queryByLabelText('Add reaction')).not.toBeInTheDocument();
    expect(screen.queryByText('βœ…')).not.toBeInTheDocument();
  });

  it('shows existing chips read-only when the surface cannot toggle them', () => {
    render(
      <ChannelMessageList
        messages={[
          message({ reactions: [{ key=[redacted], emojiUnicode: 'πŸ‘€', count: 1, reactedByMe: false }] }),
        ]}
      />,
    );
    expect(screen.getByTitle('eyes')).toBeDisabled();
  });
});

/**
 * Day pills between messages (the CRM conversation timeline has had them; the unibox had none,
 * so a channel read as one unbroken run of messages however many days apart they were).
 *
 * The same `DayDivider` component both surfaces use β€” a lookalike would drift, and this is
 * exactly the sort of thing that drifts.
 */
describe('ChannelMessageList day dividers', () => {
  const at = (iso: string, id: string): ThreadMessage =>
    message({ id, when: iso, senderName: 'Jesse', body: id });

  it('puts a pill before the first message and at every day change', () => {
    // Local midday on two consecutive days, computed rather than written as a UTC literal: a `Z`
    // time lands on a different calendar day depending on where the test runs, and the pills are
    // rendered from the LOCAL date. See the day-boundary case below for the failure that taught us.
    const noon = (day: number) => new Date(2026, 2, day, 12, 0, 0).toISOString();
    const hour = 60 * 60 * 1000;
    render(
      <ChannelMessageList
        messages={[
          at(noon(4), 'a'),
          at(new Date(new Date(noon(4)).getTime() + hour).toISOString(), 'b'),
          at(noon(5), 'c'),
        ]}
      />,
    );
    // Two days β†’ two pills, not three: the middle message shares a day with the first.
    expect(screen.getByText('Mar 4')).toBeInTheDocument();
    expect(screen.getByText('Mar 5')).toBeInTheDocument();
  });

  it('says Today and Yesterday near now, and spells out the year further back', () => {
    const now = new Date();
    const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000);
    render(
      <ChannelMessageList
        messages={[
          at('2019-06-01T12:00:00.000Z', 'old'),
          at(yesterday.toISOString(), 'y'),
          at(now.toISOString(), 'n'),
        ]}
      />,
    );
    expect(screen.getByText('Jun 1, 2019')).toBeInTheDocument();
    expect(screen.getByText('Yesterday')).toBeInTheDocument();
    expect(screen.getByText('Today')).toBeInTheDocument();
  });

  it('breaks the sender-grouping chain at a day boundary', () => {
    // Two minutes apart but across midnight: without the break this renders as a headerless
    // continuation directly under a pill announcing a new day, with no sender or time to anchor
    // it. The sender's name reappearing is what proves the chain broke.
    //
    // Straddles LOCAL midnight, computed rather than written as a UTC literal. The literals this
    // replaced (`…T23:59:00Z` / `…T00:01:00Z`) are only a day boundary in UTC: west of Greenwich
    // both fall on the same local day, the chain correctly does NOT break, and the test passed for
    // the wrong reason β€” one header instead of two β€” while failing in CI, which runs UTC.
    const midnight = new Date(2026, 2, 5, 0, 0, 0);
    render(
      <ChannelMessageList
        messages={[
          at(new Date(midnight.getTime() - 60_000).toISOString(), 'before'),
          at(new Date(midnight.getTime() + 60_000).toISOString(), 'after'),
        ]}
      />,
    );
    // The HEADER name specifically. `BimiAvatar` also renders the sender's name as its fallback
    // glyph, so a bare `getAllByText` counts two elements per un-grouped message and conflates
    // "the chain broke" with "the avatar exists" β€” which is what made the old expectation of 2
    // look right.
    expect(screen.getAllByText('Jesse', { selector: '.font-semibold' })).toHaveLength(2);
  });

  it('renders messages with no timestamp rather than dropping them', () => {
    // `when` is optional on ThreadMessage β€” a message without one gets no pill and still shows.
    render(<ChannelMessageList messages={[message({ id: 'x', when: undefined, body: 'no date' })]} />);
    expect(screen.getByText('no date')).toBeInTheDocument();
  });
});