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();
});
});