- {areReactionsLoading &&
}
- {!areReactionsLoading && (
- <>
- {reactionDetails.map(({ type, user }) => {
- const belongsToCurrentUser = client.user?.id === user?.id;
- const EmojiComponent = Array.isArray(reactionOptions)
- ? undefined
- : (reactionOptions.quick[type]?.Component ??
- reactionOptions.extended?.[type]?.Component);
-
- return (
-
-
-
-
- {belongsToCurrentUser ? t('You') : user?.name || user?.id}
-
- {belongsToCurrentUser && (
-
);
-}
+};
+
+MessageReactionsDetail.displayName = 'MessageReactionsDetail';
+
+MessageReactionsDetail.getDialogId = ({ messageId }) =>
+ `message-reactions-detail-${messageId}`;
diff --git a/src/components/Reactions/ReactionSelector.tsx b/src/components/Reactions/ReactionSelector.tsx
index 9679fa0e8..2d348b1fd 100644
--- a/src/components/Reactions/ReactionSelector.tsx
+++ b/src/components/Reactions/ReactionSelector.tsx
@@ -1,7 +1,7 @@
import React, { type ReactNode, useMemo, useState } from 'react';
import clsx from 'clsx';
-import { useDialog } from '../Dialog';
+import { useDialogOnNearestManager } from '../Dialog';
import { defaultReactionOptions } from './reactionOptions';
import { useComponentContext } from '../../context/ComponentContext';
import { useMessageContext } from '../../context/MessageContext';
@@ -24,6 +24,7 @@ interface ReactionSelectorInterface {
(props: ReactionSelectorProps): ReactNode;
getDialogId: (_: { messageId: string; threadList?: boolean }) => string;
displayName: string;
+ ExtendedList: React.ComponentType
;
}
const stableOwnReactions: ReactionResponse[] = [];
@@ -32,8 +33,10 @@ export const ReactionSelector: ReactionSelectorInterface = (props) => {
const { handleReaction: propHandleReaction, own_reactions: propOwnReactions } = props;
const [extendedListOpen, setExtendedListOpen] = useState(false);
- const { reactionOptions = defaultReactionOptions } =
- useComponentContext('ReactionSelector');
+ const {
+ reactionOptions = defaultReactionOptions,
+ ReactionSelectorExtendedList = ReactionSelector.ExtendedList,
+ } = useComponentContext('ReactionSelector');
const {
closeReactionSelectorOnClick,
@@ -45,7 +48,7 @@ export const ReactionSelector: ReactionSelectorInterface = (props) => {
messageId: message.id,
threadList,
});
- const dialog = useDialog({ id: dialogId });
+ const { dialog } = useDialogOnNearestManager({ id: dialogId });
const handleReaction = propHandleReaction ?? contextHandleReaction;
const ownReactions = propOwnReactions ?? message?.own_reactions ?? stableOwnReactions;
@@ -77,7 +80,10 @@ export const ReactionSelector: ReactionSelectorInterface = (props) => {
{!extendedListOpen && (
<>
-
+
{adjustedQuickReactionOptions.map(
({ Component, name: reactionName, type: reactionType }) => (
-
@@ -106,6 +112,7 @@ export const ReactionSelector: ReactionSelectorInterface = (props) => {
appearance='outline'
circular
className='str-chat__reaction-selector__add-button'
+ data-testid='reaction-selector-add-button'
onClick={() => setExtendedListOpen(true)}
size='sm'
variant='secondary'
@@ -114,34 +121,9 @@ export const ReactionSelector: ReactionSelectorInterface = (props) => {
>
)}
- {extendedListOpen &&
- !Array.isArray(reactionOptions) &&
- reactionOptions.extended && (
-
- {Object.entries(reactionOptions.extended).map(
- ([reactionType, { Component, name: reactionName }]) => (
- {
- handleReaction(reactionType, event);
- if (closeReactionSelectorOnClick) {
- dialog.close();
- }
- }}
- >
-
-
-
-
- ),
- )}
-
- )}
+ {extendedListOpen && (
+
+ )}
);
};
@@ -152,3 +134,67 @@ ReactionSelector.getDialogId = ({ messageId, threadList }) => {
};
ReactionSelector.displayName = 'ReactionSelector';
+
+ReactionSelector.ExtendedList = function ReactionSelectorExtendedList({
+ dialogId,
+ handleReaction: propHandleReaction,
+ own_reactions: propOwnReactions,
+}) {
+ const { reactionOptions = defaultReactionOptions } = useComponentContext(
+ 'ReactionSelector.ExtendedList',
+ );
+ const {
+ closeReactionSelectorOnClick,
+ handleReaction: contextHandleReaction,
+ message,
+ } = useMessageContext('ReactionSelector');
+
+ const handleReaction = propHandleReaction ?? contextHandleReaction;
+ const ownReactions = propOwnReactions ?? message?.own_reactions ?? stableOwnReactions;
+
+ const { dialog } = useDialogOnNearestManager({ id: dialogId || '' });
+
+ const ownReactionByType = useMemo(() => {
+ const map: { [key: string]: ReactionResponse } = {};
+
+ for (const reaction of ownReactions) {
+ map[reaction.type] ??= reaction;
+ }
+
+ return map;
+ }, [ownReactions]);
+
+ if (Array.isArray(reactionOptions) || !reactionOptions.extended) {
+ return null;
+ }
+
+ return (
+
+ {Object.entries(reactionOptions.extended).map(
+ ([reactionType, { Component, name: reactionName }]) => (
+ {
+ handleReaction(reactionType, event);
+ if (closeReactionSelectorOnClick) {
+ dialog.close();
+ }
+ }}
+ >
+
+
+
+
+ ),
+ )}
+
+ );
+};
diff --git a/src/components/Reactions/__tests__/MessageReactions.test.tsx b/src/components/Reactions/__tests__/MessageReactions.test.tsx
index 6cc3d9ca7..e97961c83 100644
--- a/src/components/Reactions/__tests__/MessageReactions.test.tsx
+++ b/src/components/Reactions/__tests__/MessageReactions.test.tsx
@@ -1,14 +1,18 @@
import React from 'react';
import { render } from '@testing-library/react';
+import { fromPartial } from '@total-typescript/shoehorn';
import { axe } from '../../../../axe-helper';
-import { MessageReactions } from '../MessageReactions';
+import { MessageReactions, type MessageReactionsProps } from '../MessageReactions';
import { MessageProvider } from '../../../context/MessageContext';
import { generateReaction, mockMessageContext } from '../../../mock-builders';
import { DialogManagerProvider, WithComponents } from '../../../context';
-import { defaultReactionOptions } from '../reactionOptions';
+import { defaultReactionOptions, type ReactionOptions } from '../reactionOptions';
+
+import type { ReactionGroupResponse } from 'stream-chat';
+import type { ReactionsComparator } from '../types';
const USER_ID = 'mark';
@@ -16,8 +20,12 @@ const renderComponent = ({
reaction_groups = {},
reactionOptions = defaultReactionOptions,
...props
-}: any = {}) => {
- const reactions = Object.entries(reaction_groups).flatMap(([type, { count }]: any) =>
+}: {
+ reaction_groups?: Record;
+ reactionOptions?: ReactionOptions;
+ sortReactions?: ReactionsComparator;
+} & Partial> = {}) => {
+ const reactions = Object.entries(reaction_groups).flatMap(([type, { count }]) =>
Array.from({ length: count }, (_, i) =>
generateReaction({ type, user: { id: `${USER_ID}-${i}` } }),
),
@@ -50,13 +58,13 @@ describe('MessageReactions', () => {
vi.spyOn(console, 'warn').mockImplementation(null);
it('should render the total reaction count in clustered mode', async () => {
- const { container } = renderComponent({
+ const { container, getByTestId } = renderComponent({
reaction_groups: {
- haha: { count: 2 },
- love: { count: 5 },
+ haha: fromPartial({ count: 2 }),
+ love: fromPartial({ count: 5 }),
},
});
- const countEl = container.querySelector('.str-chat__message-reactions__total-count');
+ const countEl = getByTestId('message-reactions-total-count');
expect(countEl).toBeInTheDocument();
expect(countEl).toHaveTextContent('7');
@@ -73,16 +81,14 @@ describe('MessageReactions', () => {
});
it('should render an emoji for each type of reaction', async () => {
- const reaction_groups = {
- haha: { count: 2 },
- love: { count: 5 },
- };
-
- const { container } = renderComponent({ reaction_groups });
+ const { container, getAllByTestId } = renderComponent({
+ reaction_groups: {
+ haha: fromPartial({ count: 2 }),
+ love: fromPartial({ count: 5 }),
+ },
+ });
- const listItems = container.querySelectorAll(
- '.str-chat__message-reactions__list-item',
- );
+ const listItems = getAllByTestId('message-reactions-list-item');
expect(listItems).toHaveLength(2);
const results = await axe(container);
@@ -90,22 +96,18 @@ describe('MessageReactions', () => {
});
it('should handle custom reaction options', async () => {
- const reaction_groups = {
- banana: { count: 1 },
- cowboy: { count: 2 },
- };
-
- const { container } = renderComponent({
- reaction_groups,
+ const { container, getAllByTestId } = renderComponent({
+ reaction_groups: {
+ banana: fromPartial({ count: 1 }),
+ cowboy: fromPartial({ count: 2 }),
+ },
reactionOptions: [
{ Component: () => <>🍌>, type: 'banana' },
{ Component: () => <>🤠>, type: 'cowboy' },
],
});
- const listItems = container.querySelectorAll(
- '.str-chat__message-reactions__list-item',
- );
+ const listItems = getAllByTestId('message-reactions-list-item');
expect(listItems).toHaveLength(2);
const results = await axe(container);
@@ -113,26 +115,25 @@ describe('MessageReactions', () => {
});
it('should order reactions by first reaction timestamp by default', () => {
- const { container } = renderComponent({
+ const { getAllByTestId } = renderComponent({
reaction_groups: {
- haha: { count: 2, first_reaction_at: new Date().toISOString() },
- like: {
+ haha: fromPartial({
+ count: 2,
+ first_reaction_at: new Date().toISOString(),
+ }),
+ like: fromPartial({
count: 8,
first_reaction_at: new Date(Date.now() + 60_000).toISOString(),
- },
- love: {
+ }),
+ love: fromPartial({
count: 5,
first_reaction_at: new Date(Date.now() + 120_000).toISOString(),
- },
+ }),
},
});
- const listItems = container.querySelectorAll(
- '.str-chat__message-reactions__list-item',
- );
- const icons = Array.from(listItems).map(
- (item) =>
- item.querySelector('.str-chat__message-reactions__list-item-icon')?.textContent,
+ const icons = getAllByTestId('message-reactions-list-item-icon').map(
+ (el) => el.textContent,
);
// haha (😂) should come first, then like (👍), then love (❤️)
@@ -142,21 +143,17 @@ describe('MessageReactions', () => {
});
it('should use custom comparator if provided', () => {
- const { container } = renderComponent({
+ const { getAllByTestId } = renderComponent({
reaction_groups: {
- haha: { count: 2 },
- like: { count: 8 },
- love: { count: 5 },
+ haha: fromPartial({ count: 2 }),
+ like: fromPartial({ count: 8 }),
+ love: fromPartial({ count: 5 }),
},
sortReactions: (a, b) => b.reactionCount - a.reactionCount,
});
- const listItems = container.querySelectorAll(
- '.str-chat__message-reactions__list-item',
- );
- const icons = Array.from(listItems).map(
- (item) =>
- item.querySelector('.str-chat__message-reactions__list-item-icon')?.textContent,
+ const icons = getAllByTestId('message-reactions-list-item-icon').map(
+ (el) => el.textContent,
);
// like (8) > love (5) > haha (2)
diff --git a/src/components/Reactions/__tests__/MessageReactionsDetail.test.tsx b/src/components/Reactions/__tests__/MessageReactionsDetail.test.tsx
index 68e64e298..6c94de33c 100644
--- a/src/components/Reactions/__tests__/MessageReactionsDetail.test.tsx
+++ b/src/components/Reactions/__tests__/MessageReactionsDetail.test.tsx
@@ -1,9 +1,11 @@
import React from 'react';
-import { render, waitFor } from '@testing-library/react';
-
+import { fireEvent, render, waitFor } from '@testing-library/react';
import { axe } from '../../../../axe-helper';
-import { MessageReactionsDetail } from '../MessageReactionsDetail';
+import {
+ MessageReactionsDetail,
+ type MessageReactionsDetailProps,
+} from '../MessageReactionsDetail';
import { MessageProvider } from '../../../context/MessageContext';
import {
@@ -11,15 +13,20 @@ import {
generateUser,
getTestClient,
mockChatContext,
- mockComponentContext,
mockMessageContext,
} from '../../../mock-builders';
-import { ChatProvider, ComponentProvider, DialogManagerProvider } from '../../../context';
-import { defaultReactionOptions } from '../reactionOptions';
+import { ChatProvider, DialogManagerProvider, WithComponents } from '../../../context';
+import type { ComponentContextValue } from '../../../context/ComponentContext';
+import { defaultReactionOptions, type ReactionOptions } from '../reactionOptions';
import { useProcessReactions } from '../hooks/useProcessReactions';
-const generateReactionsFromReactionGroups = (reactionGroups: any) =>
- Object.entries(reactionGroups).flatMap(([type, { count }]: any) =>
+import type { ReactionGroupResponse, ReactionResponse } from 'stream-chat';
+import type { ReactionsComparator } from '../types';
+
+const generateReactionsFromReactionGroups = (
+ reactionGroups: Record>,
+) =>
+ Object.entries(reactionGroups).flatMap(([type, { count }]) =>
generateReactions(count, (i) => ({
type,
user: generateUser({ id: `mark-${i}`, name: `Mark Number ${i}` }),
@@ -34,12 +41,21 @@ const MessageReactionsDetailWrapper = ({
handleFetchReactions,
reaction_groups,
reactions,
- sortReactionDetails,
sortReactions,
...rest
-}: any) => {
+}: {
+ handleFetchReactions?: MessageReactionsDetailProps['handleFetchReactions'];
+ reaction_groups?: Record>;
+ reactions?: ReactionResponse[];
+ sortReactions?: ReactionsComparator;
+} & Partial<
+ Omit<
+ MessageReactionsDetailProps,
+ 'handleFetchReactions' | 'reactions' | 'totalReactionCount'
+ >
+>) => {
const { existingReactions, totalReactionCount } = useProcessReactions({
- reaction_groups,
+ reaction_groups: reaction_groups as Record,
reactions,
sortReactions,
});
@@ -51,7 +67,6 @@ const MessageReactionsDetailWrapper = ({
selectedReactionType={
rest.selectedReactionType ?? existingReactions[0]?.reactionType ?? null
}
- sortReactionDetails={sortReactionDetails}
totalReactionCount={totalReactionCount}
{...rest}
/>
@@ -60,20 +75,23 @@ const MessageReactionsDetailWrapper = ({
const chatClient = getTestClient();
-const renderComponent = ({ handleFetchReactions, ...props }: any) =>
+const renderComponent = ({
+ handleFetchReactions,
+ ...props
+}: {
+ handleFetchReactions?: MessageReactionsDetailProps['handleFetchReactions'];
+} & Record) =>
render(
-
+
-
+
,
);
@@ -94,7 +112,7 @@ describe('MessageReactionsDetail', () => {
love: { count: 5 },
};
const reactions = generateReactionsFromReactionGroups(reactionGroups);
- const fetchReactions = vi.fn((type) =>
+ const fetchReactions = vi.fn((type: string) =>
Promise.resolve(reactions.filter((r) => r.type === type)),
);
@@ -115,7 +133,7 @@ describe('MessageReactionsDetail', () => {
love: { count: 5 },
};
const reactions = generateReactionsFromReactionGroups(reactionGroups);
- const fetchReactions = vi.fn((type) =>
+ const fetchReactions = vi.fn((type: string) =>
Promise.resolve(reactions.filter((r) => r.type === type)),
);
@@ -137,16 +155,14 @@ describe('MessageReactionsDetail', () => {
love: { count: 5 },
};
const reactions = generateReactionsFromReactionGroups(reactionGroups);
- const fetchReactions = vi.fn((type) =>
+ const fetchReactions = vi.fn((type: string) =>
Promise.resolve(reactions.filter((r) => r.type === type)),
);
const { getAllByTestId, rerender } = render(
-
+
{
selectedReactionType='haha'
/>
-
+
,
);
@@ -167,9 +183,7 @@ describe('MessageReactionsDetail', () => {
rerender(
-
+
{
selectedReactionType='love'
/>
-
+
,
);
@@ -218,7 +232,6 @@ describe('MessageReactionsDetail', () => {
reaction_groups: reactionGroups,
reactions,
selectedReactionType: 'haha',
- sortReactionDetails: (a, b) => -a.user.name.localeCompare(b.user.name),
});
await waitFor(() => {
@@ -269,4 +282,134 @@ describe('MessageReactionsDetail', () => {
);
});
});
+
+ it('should always display reaction count for each reaction type', () => {
+ const reactionGroups = {
+ haha: { count: 1 },
+ love: { count: 3 },
+ };
+ const reactions = generateReactionsFromReactionGroups(reactionGroups);
+ const fetchReactions = vi.fn(() => Promise.resolve([]));
+
+ const { getAllByTestId } = renderComponent({
+ handleFetchReactions: fetchReactions,
+ reaction_groups: reactionGroups,
+ reactions,
+ });
+
+ const counts = getAllByTestId('reaction-type-count');
+ expect(counts).toHaveLength(2);
+ expect(counts[0]).toHaveTextContent('1');
+ expect(counts[1]).toHaveTextContent('3');
+ });
+
+ it('should render add emoji button in the reaction type list', () => {
+ const reactionGroups = {
+ love: { count: 2 },
+ };
+ const reactions = generateReactionsFromReactionGroups(reactionGroups);
+ const fetchReactions = vi.fn(() => Promise.resolve([]));
+
+ const { getByTestId } = renderComponent({
+ handleFetchReactions: fetchReactions,
+ reaction_groups: reactionGroups,
+ reactions,
+ });
+
+ expect(getByTestId('add-reaction-button')).toBeInTheDocument();
+ });
+
+ it('should show extended reaction list when add emoji button is clicked', () => {
+ const reactionGroups = {
+ love: { count: 2 },
+ };
+ const reactions = generateReactionsFromReactionGroups(reactionGroups);
+ const fetchReactions = vi.fn(() => Promise.resolve([]));
+
+ const extendedReactionOptions: ReactionOptions = {
+ extended: {
+ rocket: { Component: () => <>🚀>, name: 'Rocket' },
+ star: { Component: () => <>⭐>, name: 'Star' },
+ },
+ quick: {
+ love: { Component: () => <>❤️>, name: 'Heart' },
+ },
+ };
+
+ const { getByTestId, queryByTestId } = render(
+
+
+
+
+
+
+
+
+ ,
+ );
+
+ fireEvent.click(getByTestId('add-reaction-button'));
+
+ // Extended list should be visible
+ expect(getByTestId('reaction-selector-extended-list')).toBeInTheDocument();
+
+ // The detail panel should still be present
+ expect(getByTestId('message-reactions-detail')).toBeInTheDocument();
+
+ // The reaction type list should NOT be visible
+ expect(queryByTestId('reaction-type-list')).not.toBeInTheDocument();
+ });
+
+ it('should use custom ReactionSelectorExtendedList from ComponentContext', () => {
+ const reactionGroups = {
+ love: { count: 2 },
+ };
+ const reactions = generateReactionsFromReactionGroups(reactionGroups);
+ const fetchReactions = vi.fn(() => Promise.resolve([]));
+
+ const CustomExtendedList = vi.fn(() => (
+ Custom
+ ));
+
+ const overrides: Partial = {
+ reactionOptions: {
+ extended: {
+ rocket: { Component: () => <>🚀>, name: 'Rocket' },
+ },
+ quick: {
+ love: { Component: () => <>❤️>, name: 'Heart' },
+ },
+ },
+ ReactionSelectorExtendedList: CustomExtendedList,
+ };
+
+ const { getByTestId } = render(
+
+
+
+
+
+
+
+
+ ,
+ );
+
+ fireEvent.click(getByTestId('add-reaction-button'));
+
+ expect(getByTestId('custom-extended-list')).toBeInTheDocument();
+ expect(CustomExtendedList).toHaveBeenCalled();
+ });
});
diff --git a/src/components/Reactions/__tests__/ReactionSelector.test.tsx b/src/components/Reactions/__tests__/ReactionSelector.test.tsx
index c7c6fc7f1..1dd325fdf 100644
--- a/src/components/Reactions/__tests__/ReactionSelector.test.tsx
+++ b/src/components/Reactions/__tests__/ReactionSelector.test.tsx
@@ -1,12 +1,11 @@
import React from 'react';
import { fireEvent, render } from '@testing-library/react';
+import { ReactionSelector, type ReactionSelectorProps } from '../ReactionSelector';
+import { defaultReactionOptions, type ReactionOptions } from '../reactionOptions';
-import { ReactionSelector } from '../ReactionSelector';
-import { defaultReactionOptions } from '../reactionOptions';
-
-import { ComponentProvider } from '../../../context/ComponentContext';
import { MessageProvider } from '../../../context/MessageContext';
-import { DialogManagerProvider } from '../../../context';
+import { DialogManagerProvider, WithComponents } from '../../../context';
+import type { ComponentContextValue } from '../../../context/ComponentContext';
import {
generateMessage,
@@ -16,16 +15,71 @@ import {
const handleReactionMock = vi.fn();
-const renderComponent = ({ reactionOptions, ...props }: any = {}) =>
+const defaultMessage = generateMessage();
+
+const renderComponent = ({
+ reactionOptions,
+ ReactionSelectorExtendedList,
+ ...props
+}: Partial & {
+ reactionOptions?: ReactionOptions;
+ ReactionSelectorExtendedList?: ComponentContextValue['ReactionSelectorExtendedList'];
+} = {}) =>
render(
-
-
+
-
+
+ ,
+ );
+
+const extendedReactionOptions: ReactionOptions = {
+ extended: {
+ rocket: { Component: () => <>🚀>, name: 'Rocket' },
+ star: { Component: () => <>⭐>, name: 'Star' },
+ thumbsdown: { Component: () => <>👎>, name: 'Thumbs down' },
+ },
+ quick: {
+ love: { Component: () => <>❤️>, name: 'Heart' },
+ },
+};
+
+const renderExtendedList = ({
+ componentOverrides = {},
+ messageOverrides = {},
+ ...props
+}: Partial & {
+ componentOverrides?: Partial;
+ messageOverrides?: Record;
+ dialogId?: string;
+} = {}) =>
+ render(
+
+
+
+
+
+
,
);
@@ -43,17 +97,17 @@ describe('ReactionSelector', () => {
});
it('should render each of reactionOptions if specified as an array (legacy format)', () => {
- const reactionOptions = [
- { Component: vi.fn(() => test1), type: 'test1' },
- { Component: vi.fn(() => test2), type: 'test2' },
+ const reactionOptions: ReactionOptions = [
+ { Component: () => test1, type: 'test1' },
+ { Component: () => test2, type: 'test2' },
];
const { getAllByTestId } = renderComponent({ reactionOptions });
const buttons = getAllByTestId('select-reaction-button');
expect(buttons).toHaveLength(2);
- reactionOptions.forEach((option) => {
- expect(option.Component).toHaveBeenCalledTimes(1);
+ buttons.forEach((button, index) => {
+ expect(button).toHaveTextContent(reactionOptions[index].type);
});
});
@@ -83,13 +137,12 @@ describe('ReactionSelector', () => {
});
it('should render the add button for extended reactions', () => {
- const { container } = renderComponent();
- const addButton = container.querySelector('.str-chat__reaction-selector__add-button');
- expect(addButton).toBeInTheDocument();
+ const { getByTestId } = renderComponent();
+ expect(getByTestId('reaction-selector-add-button')).toBeInTheDocument();
});
it('should show extended reaction list when add button is clicked', () => {
- const reactionOptions = {
+ const reactionOptions: ReactionOptions = {
extended: {
rocket: { Component: () => <>🚀>, name: 'Rocket' },
star: { Component: () => <>⭐>, name: 'Star' },
@@ -98,19 +151,14 @@ describe('ReactionSelector', () => {
love: { Component: () => <>❤️>, name: 'Heart' },
},
};
- const { container } = renderComponent({ reactionOptions });
+ const { getByTestId, queryByTestId } = renderComponent({ reactionOptions });
- const addButton = container.querySelector('.str-chat__reaction-selector__add-button');
- fireEvent.click(addButton);
+ fireEvent.click(getByTestId('reaction-selector-add-button'));
- const extendedList = container.querySelector(
- '.str-chat__reaction-selector-extended-list',
- );
- expect(extendedList).toBeInTheDocument();
+ expect(getByTestId('reaction-selector-extended-list')).toBeInTheDocument();
// Quick list should be hidden when extended list is open
- const quickList = container.querySelector('.str-chat__reaction-selector-list');
- expect(quickList).not.toBeInTheDocument();
+ expect(queryByTestId('reaction-selector-list')).not.toBeInTheDocument();
});
it('should have correct data-text attribute on each button', () => {
@@ -123,4 +171,137 @@ describe('ReactionSelector', () => {
expect.arrayContaining(['haha', 'like', 'love', 'sad', 'wow', 'fire']),
);
});
+
+ it('should use custom ReactionSelectorExtendedList from ComponentContext', () => {
+ const CustomExtendedList = vi.fn(() => (
+ Custom
+ ));
+
+ const { getByTestId } = renderComponent({
+ reactionOptions: extendedReactionOptions,
+ ReactionSelectorExtendedList: CustomExtendedList,
+ });
+
+ fireEvent.click(getByTestId('reaction-selector-add-button'));
+
+ expect(getByTestId('custom-extended-list')).toBeInTheDocument();
+ expect(CustomExtendedList).toHaveBeenCalled();
+ });
+});
+
+describe('ReactionSelector.ExtendedList', () => {
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('should render extended reaction buttons', () => {
+ const { getAllByTestId } = renderExtendedList();
+
+ const buttons = getAllByTestId('select-reaction-button');
+ expect(buttons).toHaveLength(3);
+
+ const dataTexts = buttons.map((btn) => btn.getAttribute('data-text'));
+ expect(dataTexts).toEqual(expect.arrayContaining(['rocket', 'star', 'thumbsdown']));
+ });
+
+ it('should render null when reactionOptions is a legacy array', () => {
+ const legacyOptions: ReactionOptions = [{ Component: () => <>❤️>, type: 'love' }];
+
+ const { queryByTestId } = renderExtendedList({
+ componentOverrides: { reactionOptions: legacyOptions },
+ });
+
+ expect(queryByTestId('reaction-selector-extended-list')).not.toBeInTheDocument();
+ });
+
+ it('should render null when reactionOptions has no extended field', () => {
+ const quickOnly: ReactionOptions = {
+ quick: {
+ love: { Component: () => <>❤️>, name: 'Heart' },
+ },
+ };
+
+ const { queryByTestId } = renderExtendedList({
+ componentOverrides: { reactionOptions: quickOnly },
+ });
+
+ expect(queryByTestId('reaction-selector-extended-list')).not.toBeInTheDocument();
+ });
+
+ it('should mark own reactions with aria-pressed', () => {
+ const ownReaction = generateReaction({ type: 'rocket' });
+ const { getAllByTestId } = renderExtendedList({
+ own_reactions: [ownReaction],
+ });
+
+ const buttons = getAllByTestId('select-reaction-button');
+ const rocketButton = buttons.find(
+ (btn) => btn.getAttribute('data-text') === 'rocket',
+ );
+ const starButton = buttons.find((btn) => btn.getAttribute('data-text') === 'star');
+
+ expect(rocketButton).toHaveAttribute('aria-pressed', 'true');
+ expect(starButton).toHaveAttribute('aria-pressed', 'false');
+ });
+
+ it('should call prop handleReaction over context handleReaction', () => {
+ const propHandleReaction = vi.fn();
+ const { getAllByTestId } = renderExtendedList({
+ handleReaction: propHandleReaction,
+ });
+
+ const buttons = getAllByTestId('select-reaction-button');
+ fireEvent.click(buttons[0]);
+
+ expect(propHandleReaction).toHaveBeenCalledWith(
+ expect.any(String),
+ expect.any(Object),
+ );
+ });
+
+ it('should fall back to context handleReaction when prop is not provided', () => {
+ const contextHandleReaction = vi.fn();
+ const { getAllByTestId } = renderExtendedList({
+ messageOverrides: { handleReaction: contextHandleReaction },
+ });
+
+ const buttons = getAllByTestId('select-reaction-button');
+ fireEvent.click(buttons[0]);
+
+ expect(contextHandleReaction).toHaveBeenCalledWith(
+ expect.any(String),
+ expect.any(Object),
+ );
+ });
+
+ it('should render correct aria-label using reaction name', () => {
+ const { getAllByTestId } = renderExtendedList();
+
+ const buttons = getAllByTestId('select-reaction-button');
+ const rocketButton = buttons.find(
+ (btn) => btn.getAttribute('data-text') === 'rocket',
+ );
+
+ expect(rocketButton).toHaveAttribute('aria-label', 'Select Reaction: Rocket');
+ });
+
+ it('should use reaction type as aria-label fallback when name is missing', () => {
+ const optionsWithoutName: ReactionOptions = {
+ extended: {
+ custom: { Component: () => <>🎯> },
+ },
+ quick: {
+ love: { Component: () => <>❤️>, name: 'Heart' },
+ },
+ };
+
+ const { getByTestId } = renderExtendedList({
+ componentOverrides: { reactionOptions: optionsWithoutName },
+ });
+
+ expect(getByTestId('select-reaction-button')).toHaveAttribute(
+ 'aria-label',
+ 'Select Reaction: custom',
+ );
+ });
});
diff --git a/src/components/Reactions/styling/MessageReactionsDetail.scss b/src/components/Reactions/styling/MessageReactionsDetail.scss
index 809dcdd07..e35eaef5a 100644
--- a/src/components/Reactions/styling/MessageReactionsDetail.scss
+++ b/src/components/Reactions/styling/MessageReactionsDetail.scss
@@ -14,24 +14,21 @@
max-width: 256px;
min-width: min(90vw, 256px);
- &::after {
- content: '';
- position: absolute;
- width: 100%;
- bottom: 0;
- inset-inline-start: 0;
- height: var(--size-12);
- border-end-end-radius: inherit;
- border-end-start-radius: inherit;
- background: linear-gradient(to bottom, rgba(0, 0, 0, 0) 5%, rgba(0, 0, 0, 0.1) 130%);
+ &:has(.str-chat__reaction-selector-extended-list) {
+ @include common.clipping-fade;
+ @include utils.scrollable-y;
+
+ padding: 0;
+ display: block;
+ scrollbar-width: none;
+ max-height: 320px;
+ max-width: unset;
+ min-width: unset;
}
font-family: var(--typography-font-family-sans);
- box-shadow:
- 0 0 0 1px rgba(0, 0, 0, 0.05),
- 0 4px 8px 0 rgba(0, 0, 0, 0.14),
- 0 12px 24px 0 rgba(0, 0, 0, 0.1);
+ box-shadow: var(--light-elevation-3);
padding-block-start: var(--spacing-xxs);
@@ -56,7 +53,7 @@
.str-chat__message-reactions-detail__reaction-type-list {
list-style: none;
margin: 0;
- padding-inline: var(--spacing-xs);
+ padding-inline: var(--spacing-md);
padding-block: var(--spacing-xs);
display: flex;
flex-wrap: wrap;
@@ -68,12 +65,24 @@
.str-chat__message-reactions-detail__reaction-type-list-item-button {
@include common.reaction-button;
box-shadow: unset;
+ min-width: var(--size-48);
.str-chat__message-reactions-detail__reaction-type-list-item-icon {
font-family: system-ui;
font-size: var(--font-size-size-17);
font-style: normal;
line-height: var(--typography-line-height-normal);
+
+ .str-chat__icon {
+ width: var(--icon-size-sm);
+ height: var(--icon-size-sm);
+ }
+
+ &:has(.str-chat__icon) {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ }
}
.str-chat__message-reactions-detail__reaction-type-list-item-count {
@@ -84,19 +93,21 @@
}
}
+ .str-chat__message-reactions-detail__user-list-container {
+ position: relative;
+ border-radius: inherit;
+ @include common.clipping-fade;
+ &::before {
+ display: none;
+ }
+ }
+
.str-chat__message-reactions-detail__user-list {
@include utils.scrollable-y;
scrollbar-width: none;
position: relative;
padding-block-end: var(--spacing-xxs);
- max-height: 100px;
-
- &:has(
- .str-chat__message-reactions-detail__user-list-item
- .str-chat__message-reactions-detail__user-list-item-button:nth-child(-n + 3)
- ) {
- max-height: 106px;
- }
+ max-height: 180px;
.str-chat__message-reactions-detail__skeleton-item {
padding-block: var(--spacing-xxs);
diff --git a/src/components/Reactions/styling/ReactionSelector.scss b/src/components/Reactions/styling/ReactionSelector.scss
index 499764caa..5e6c00b2c 100644
--- a/src/components/Reactions/styling/ReactionSelector.scss
+++ b/src/components/Reactions/styling/ReactionSelector.scss
@@ -1,4 +1,5 @@
-@use '../../../styling/utils';
+@use '../../../styling/utils' as utils;
+@use './common' as common;
.str-chat__reaction-selector {
display: flex;
@@ -9,10 +10,9 @@
gap: var(--spacing-xs);
border-radius: var(--radius-4xl, 32px);
- border: 1px solid var(--border-core-surface-subtle, #e2e6ea);
background: var(--background-core-elevation-2, #fff);
- box-shadow: 0 4px 12px 0 rgba(0, 0, 0, 0.16);
+ box-shadow: var(--light-elevation-3);
&:has(.str-chat__reaction-selector-extended-list) {
padding: 0;
@@ -20,7 +20,9 @@
@include utils.scrollable-y;
scrollbar-width: none;
border-radius: var(--radius-lg);
- max-height: 250px;
+ max-height: 320px;
+
+ @include common.clipping-fade;
}
.str-chat__reaction-selector__add-button {
@@ -33,29 +35,6 @@
}
}
- .str-chat__reaction-selector-extended-list {
- display: grid;
- grid-template-columns: repeat(7, 1fr);
- height: 100%;
- padding-block: var(--spacing-md);
- padding-inline: var(--spacing-sm);
-
- .str-chat__reaction-selector-extended-list__button {
- .str-chat__reaction-icon {
- height: var(--emoji-md);
- width: var(--emoji-md);
- font-size: var(--emoji-md);
- letter-spacing: var(--typography-letter-spacing-default);
- font-style: normal;
- line-height: 0;
- font-family: system-ui;
- display: flex;
- justify-content: center;
- align-items: center;
- }
- }
- }
-
.str-chat__reaction-selector-list {
list-style: none;
margin: var(--spacing-none, 0);
diff --git a/src/components/Reactions/styling/ReactionSelectorExtendedList.scss b/src/components/Reactions/styling/ReactionSelectorExtendedList.scss
new file mode 100644
index 000000000..fb0ca14a5
--- /dev/null
+++ b/src/components/Reactions/styling/ReactionSelectorExtendedList.scss
@@ -0,0 +1,22 @@
+.str-chat__reaction-selector-extended-list {
+ display: grid;
+ grid-template-columns: repeat(7, 1fr);
+ height: 100%;
+ padding-block: var(--spacing-md);
+ padding-inline: var(--spacing-sm);
+
+ .str-chat__reaction-selector-extended-list__button {
+ .str-chat__reaction-icon {
+ height: var(--emoji-md);
+ width: var(--emoji-md);
+ font-size: var(--emoji-md);
+ letter-spacing: var(--typography-letter-spacing-default);
+ font-style: normal;
+ line-height: 0;
+ font-family: system-ui;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ }
+ }
+}
diff --git a/src/components/Reactions/styling/common.scss b/src/components/Reactions/styling/common.scss
index 617f178c7..4b5b9ca05 100644
--- a/src/components/Reactions/styling/common.scss
+++ b/src/components/Reactions/styling/common.scss
@@ -46,3 +46,38 @@
}
}
}
+
+@mixin clipping-fade() {
+ &::before,
+ &::after {
+ content: '';
+ position: absolute;
+ width: 100%;
+ inset-inline-start: 0;
+ height: var(--size-16);
+ }
+
+ &::after {
+ bottom: 0;
+ border-end-end-radius: inherit;
+ border-end-start-radius: inherit;
+ background: linear-gradient(
+ to bottom,
+ transparent 5%,
+ var(--background-core-elevation-0) 95%
+ );
+ }
+
+ &::before {
+ // TODO: figure out a better way (z-index isn't optimal)
+ z-index: 1;
+ top: 0;
+ border-start-end-radius: inherit;
+ border-start-start-radius: inherit;
+ background: linear-gradient(
+ to top,
+ transparent 5%,
+ var(--background-core-elevation-0) 95%
+ );
+ }
+}
diff --git a/src/components/Reactions/styling/index.scss b/src/components/Reactions/styling/index.scss
index 364ea1d65..4490d7d54 100644
--- a/src/components/Reactions/styling/index.scss
+++ b/src/components/Reactions/styling/index.scss
@@ -1,3 +1,4 @@
@use 'ReactionSelector';
+@use 'ReactionSelectorExtendedList';
@use 'MessageReactions';
@use 'MessageReactionsDetail';
diff --git a/src/context/ComponentContext.tsx b/src/context/ComponentContext.tsx
index 8effb1042..322197a65 100644
--- a/src/context/ComponentContext.tsx
+++ b/src/context/ComponentContext.tsx
@@ -1,4 +1,4 @@
-import type { PropsWithChildren } from 'react';
+import type { ComponentProps, PropsWithChildren } from 'react';
import React, { useContext } from 'react';
import {
@@ -43,6 +43,7 @@ import {
type PollOptionSelectorProps,
type QuotedMessagePreviewProps,
type ReactionOptions,
+ type ReactionSelector,
type ReactionSelectorProps,
type RecordingPermissionDeniedNotificationProps,
type ReminderNotificationProps,
@@ -211,6 +212,9 @@ export type ComponentContextValue = {
reactionOptions?: ReactionOptions;
/** Custom UI component to display the reaction selector, defaults to and accepts same props as: [ReactionSelector](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Reactions/ReactionSelector.tsx) */
ReactionSelector?: React.ForwardRefExoticComponent;
+ ReactionSelectorExtendedList?: React.ComponentType<
+ ComponentProps<(typeof ReactionSelector)['ExtendedList']>
+ >;
/** Custom UI component to display the list of reactions on a message, defaults to and accepts same props as: [MessageReactions](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Reactions/MessageReactions.tsx) */
MessageReactions?: React.ComponentType;
/** Custom UI component to display the reactions modal, defaults to and accepts same props as: [MessageReactionsDetail](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Reactions/MessageReactionsDetail.tsx) */
diff --git a/src/i18n/de.json b/src/i18n/de.json
index 05f9b4225..ff81ac8e6 100644
--- a/src/i18n/de.json
+++ b/src/i18n/de.json
@@ -38,6 +38,7 @@
"📍Shared location": "📍Geteilter Standort",
"Add a comment": "Einen Kommentar hinzufügen",
"Add an option": "Eine Option hinzufügen",
+ "Add reaction": "Reaktion hinzufügen",
"All results loaded": "Alle Ergebnisse geladen",
"Allow access to camera": "Zugriff auf Kamera erlauben",
"Allow access to microphone": "Zugriff auf Mikrofon erlauben",
diff --git a/src/i18n/en.json b/src/i18n/en.json
index 00b2660f0..36a4fed93 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -38,6 +38,7 @@
"📍Shared location": "📍Shared location",
"Add a comment": "Add a Comment",
"Add an option": "Add an Option",
+ "Add reaction": "Add reaction",
"All results loaded": "All results loaded",
"Allow access to camera": "Allow access to camera",
"Allow access to microphone": "Allow access to microphone",
diff --git a/src/i18n/es.json b/src/i18n/es.json
index 18c6a1deb..7bab77976 100644
--- a/src/i18n/es.json
+++ b/src/i18n/es.json
@@ -46,6 +46,7 @@
"📍Shared location": "📍Ubicación compartida",
"Add a comment": "Agregar un comentario",
"Add an option": "Agregar una opción",
+ "Add reaction": "Añadir reacción",
"All results loaded": "Todos los resultados cargados",
"Allow access to camera": "Permitir acceso a la cámara",
"Allow access to microphone": "Permitir acceso al micrófono",
diff --git a/src/i18n/fr.json b/src/i18n/fr.json
index 1aefa165a..a4fe0fa7b 100644
--- a/src/i18n/fr.json
+++ b/src/i18n/fr.json
@@ -46,6 +46,7 @@
"📍Shared location": "📍Emplacement partagé",
"Add a comment": "Ajouter un commentaire",
"Add an option": "Ajouter une option",
+ "Add reaction": "Ajouter une réaction",
"All results loaded": "Tous les résultats sont chargés",
"Allow access to camera": "Autoriser l'accès à la caméra",
"Allow access to microphone": "Autoriser l'accès au microphone",
diff --git a/src/i18n/hi.json b/src/i18n/hi.json
index dfedcdd9b..4b896a4a1 100644
--- a/src/i18n/hi.json
+++ b/src/i18n/hi.json
@@ -38,6 +38,7 @@
"📍Shared location": "📍साझा किया गया स्थान",
"Add a comment": "एक टिप्पणी जोड़ें",
"Add an option": "एक विकल्प जोड़ें",
+ "Add reaction": "प्रतिक्रिया जोड़ें",
"All results loaded": "सभी परिणाम लोड हो गए",
"Allow access to camera": "कैमरा तक पहुँच दें",
"Allow access to microphone": "माइक्रोफ़ोन तक पहुँच दें",
diff --git a/src/i18n/it.json b/src/i18n/it.json
index 2d6b1d9ce..c7017103a 100644
--- a/src/i18n/it.json
+++ b/src/i18n/it.json
@@ -46,6 +46,7 @@
"📍Shared location": "📍Posizione condivisa",
"Add a comment": "Aggiungi un commento",
"Add an option": "Aggiungi un'opzione",
+ "Add reaction": "Aggiungi reazione",
"All results loaded": "Tutti i risultati caricati",
"Allow access to camera": "Consenti l'accesso alla fotocamera",
"Allow access to microphone": "Consenti l'accesso al microfono",
diff --git a/src/i18n/ja.json b/src/i18n/ja.json
index 7c1b21166..32b201084 100644
--- a/src/i18n/ja.json
+++ b/src/i18n/ja.json
@@ -37,6 +37,7 @@
"📍Shared location": "📍共有された位置情報",
"Add a comment": "コメントを追加",
"Add an option": "オプションを追加",
+ "Add reaction": "リアクションを追加",
"All results loaded": "すべての結果が読み込まれました",
"Allow access to camera": "カメラへのアクセスを許可する",
"Allow access to microphone": "マイクロフォンへのアクセスを許可する",
diff --git a/src/i18n/ko.json b/src/i18n/ko.json
index 601798b16..ed8a861c5 100644
--- a/src/i18n/ko.json
+++ b/src/i18n/ko.json
@@ -37,6 +37,7 @@
"📍Shared location": "📍공유된 위치",
"Add a comment": "댓글 추가",
"Add an option": "옵션 추가",
+ "Add reaction": "반응 추가",
"All results loaded": "모든 결과가 로드되었습니다",
"Allow access to camera": "카메라에 대한 액세스 허용",
"Allow access to microphone": "마이크로폰에 대한 액세스 허용",
diff --git a/src/i18n/nl.json b/src/i18n/nl.json
index 0bc74d805..b40d30e0a 100644
--- a/src/i18n/nl.json
+++ b/src/i18n/nl.json
@@ -38,6 +38,7 @@
"📍Shared location": "📍Gedeelde locatie",
"Add a comment": "Voeg een opmerking toe",
"Add an option": "Voeg een optie toe",
+ "Add reaction": "Reactie toevoegen",
"All results loaded": "Alle resultaten geladen",
"Allow access to camera": "Toegang tot camera toestaan",
"Allow access to microphone": "Toegang tot microfoon toestaan",
diff --git a/src/i18n/pt.json b/src/i18n/pt.json
index e47de7aeb..d4927b696 100644
--- a/src/i18n/pt.json
+++ b/src/i18n/pt.json
@@ -46,6 +46,7 @@
"📍Shared location": "📍Localização compartilhada",
"Add a comment": "Adicionar um comentário",
"Add an option": "Adicionar uma opção",
+ "Add reaction": "Adicionar reação",
"All results loaded": "Todos os resultados carregados",
"Allow access to camera": "Permitir acesso à câmera",
"Allow access to microphone": "Permitir acesso ao microfone",
diff --git a/src/i18n/ru.json b/src/i18n/ru.json
index 3f9569b1a..e0902f0b0 100644
--- a/src/i18n/ru.json
+++ b/src/i18n/ru.json
@@ -55,6 +55,7 @@
"📍Shared location": "📍Общее местоположение",
"Add a comment": "Добавить комментарий",
"Add an option": "Добавить вариант",
+ "Add reaction": "Добавить реакцию",
"All results loaded": "Все результаты загружены",
"Allow access to camera": "Разрешить доступ к камере",
"Allow access to microphone": "Разрешить доступ к микрофону",
diff --git a/src/i18n/tr.json b/src/i18n/tr.json
index 94db22366..90bd87c36 100644
--- a/src/i18n/tr.json
+++ b/src/i18n/tr.json
@@ -38,6 +38,7 @@
"📍Shared location": "📍Paylaşılan konum",
"Add a comment": "Yorum ekle",
"Add an option": "Bir seçenek ekle",
+ "Add reaction": "Tepki ekle",
"All results loaded": "Tüm sonuçlar yüklendi",
"Allow access to camera": "Kameraya erişime izin ver",
"Allow access to microphone": "Mikrofona erişime izin ver",