diff --git a/src/components/Channel/Channel.tsx b/src/components/Channel/Channel.tsx
index 32fc8fe0fb..b81a465e5f 100644
--- a/src/components/Channel/Channel.tsx
+++ b/src/components/Channel/Channel.tsx
@@ -105,7 +105,6 @@ type ChannelPropsForwardedToComponentContext = Pick<
| 'Avatar'
| 'BaseImage'
| 'CooldownTimer'
- | 'CustomMessageActionsList'
| 'DateSeparator'
| 'EditMessageInput'
| 'EditMessageModal'
@@ -128,7 +127,6 @@ type ChannelPropsForwardedToComponentContext = Pick<
| 'MessageListNotifications'
| 'MessageListMainPanel'
| 'MessageNotification'
- | 'MessageOptions'
| 'MessageRepliesCountButton'
| 'MessageStatus'
| 'MessageSystem'
@@ -1216,7 +1214,6 @@ const ChannelInner = (
Avatar: props.Avatar,
BaseImage: props.BaseImage,
CooldownTimer: props.CooldownTimer,
- CustomMessageActionsList: props.CustomMessageActionsList,
DateSeparator: props.DateSeparator,
EditMessageInput: props.EditMessageInput,
EditMessageModal: props.EditMessageModal,
@@ -1238,7 +1235,6 @@ const ChannelInner = (
props.MessageIsThreadReplyInChannelButtonIndicator,
MessageListNotifications: props.MessageListNotifications,
MessageNotification: props.MessageNotification,
- MessageOptions: props.MessageOptions,
MessageRepliesCountButton: props.MessageRepliesCountButton,
MessageStatus: props.MessageStatus,
MessageSystem: props.MessageSystem,
@@ -1286,7 +1282,6 @@ const ChannelInner = (
props.Avatar,
props.BaseImage,
props.CooldownTimer,
- props.CustomMessageActionsList,
props.DateSeparator,
props.EditMessageInput,
props.EditMessageModal,
@@ -1307,7 +1302,6 @@ const ChannelInner = (
props.MessageIsThreadReplyInChannelButtonIndicator,
props.MessageListNotifications,
props.MessageNotification,
- props.MessageOptions,
props.MessageRepliesCountButton,
props.MessageStatus,
props.MessageSystem,
diff --git a/src/components/Channel/__tests__/Channel.test.js b/src/components/Channel/__tests__/Channel.test.js
index db59877d03..9edc74e723 100644
--- a/src/components/Channel/__tests__/Channel.test.js
+++ b/src/components/Channel/__tests__/Channel.test.js
@@ -2273,32 +2273,4 @@ describe('Channel', () => {
);
});
});
-
- describe('Custom Components', () => {
- it('should render CustomMessageActionsList if provided', async () => {
- const CustomMessageActionsList = jest
- .fn()
- .mockImplementation(() => 'CustomMessageActionsList');
-
- const messageContextValue = {
- message: generateMessage(),
- messageListRect: {},
- };
-
- await renderComponent({
- channel,
- chatClient,
- children: (
-
- [])} />
-
- ),
- CustomMessageActionsList,
- });
-
- await waitFor(() => {
- expect(CustomMessageActionsList).toHaveBeenCalledTimes(1);
- });
- });
- });
});
diff --git a/src/components/Message/FixedHeightMessage.tsx b/src/components/Message/FixedHeightMessage.tsx
index 9ee2801ebb..eab271569b 100644
--- a/src/components/Message/FixedHeightMessage.tsx
+++ b/src/components/Message/FixedHeightMessage.tsx
@@ -1,9 +1,8 @@
-import React, { useCallback, useMemo } from 'react';
+import React, { useMemo } from 'react';
-import { useDeleteHandler, useUserRole } from './hooks';
+import { useUserRole } from './hooks';
import { MessageDeleted as DefaultMessageDeleted } from './MessageDeleted';
import { MessageTimestamp } from './MessageTimestamp';
-import { getMessageActions } from './utils';
import { Avatar } from '../Avatar';
import { Gallery } from '../Gallery';
@@ -55,7 +54,6 @@ const UnMemoizedFixedHeightMessage = (props: FixedHeightMessageProps) => {
propGroupedByUser !== undefined ? propGroupedByUser : contextGroupedByUser;
const message = propMessage || contextMessage;
- const handleDelete = useDeleteHandler(message);
const role = useUserRole(message);
const messageTextToRender =
@@ -70,11 +68,6 @@ const UnMemoizedFixedHeightMessage = (props: FixedHeightMessageProps) => {
const userId = message.user?.id || '';
const userColor = useMemo(() => getUserColor(theme, userId), [userId, theme]);
- const messageActionsHandler = useCallback(
- () => getMessageActions(['delete'], { canDelete: role.canDelete }),
- [role],
- );
-
const images = message?.attachments?.filter(({ type }) => type === 'image');
return (
@@ -105,13 +98,7 @@ const UnMemoizedFixedHeightMessage = (props: FixedHeightMessageProps) => {
{renderedText}
-
role.isMyMessage}
- />
+
{
autoscrollToBottom={props.autoscrollToBottom}
canPin={canPin}
closeReactionSelectorOnClick={closeReactionSelectorOnClick}
- customMessageActions={props.customMessageActions}
deliveredTo={props.deliveredTo}
disableQuotedMessages={props.disableQuotedMessages}
endOfGroup={props.endOfGroup}
diff --git a/src/components/Message/MessageOptions.tsx b/src/components/Message/MessageOptions.tsx
deleted file mode 100644
index 0a280aa46d..0000000000
--- a/src/components/Message/MessageOptions.tsx
+++ /dev/null
@@ -1,112 +0,0 @@
-import clsx from 'clsx';
-import React from 'react';
-
-import {
- ActionsIcon as DefaultActionsIcon,
- ReactionIcon as DefaultReactionIcon,
- ThreadIcon as DefaultThreadIcon,
-} from './icons';
-import { MESSAGE_ACTIONS } from './utils';
-import { MessageActions } from '../MessageActions';
-import { useDialogIsOpen } from '../Dialog';
-import { ReactionSelectorWithButton } from '../Reactions/ReactionSelectorWithButton';
-
-import { useMessageContext, useTranslationContext } from '../../context';
-
-import type { IconProps } from '../../types/types';
-import type { MessageContextValue } from '../../context/MessageContext';
-
-export type MessageOptionsProps = Partial<
- Pick
-> & {
- /* Custom component rendering the icon used in message actions button. This button invokes the message actions menu. */
- ActionsIcon?: React.ComponentType;
- /* If true, show the `ThreadIcon` and enable navigation into a `Thread` component. */
- displayReplies?: boolean;
- /* Custom component rendering the icon used in a button invoking reactions selector for a given message. */
- ReactionIcon?: React.ComponentType;
- /* Theme string to be added to CSS class names. */
- theme?: string;
- /* Custom component rendering the icon used in a message options button opening thread */
- ThreadIcon?: React.ComponentType;
-};
-
-const UnMemoizedMessageOptions = (props: MessageOptionsProps) => {
- const {
- ActionsIcon = DefaultActionsIcon,
- displayReplies = true,
- handleOpenThread: propHandleOpenThread,
- ReactionIcon = DefaultReactionIcon,
- theme = 'simple',
- ThreadIcon = DefaultThreadIcon,
- } = props;
-
- const {
- getMessageActions,
- handleOpenThread: contextHandleOpenThread,
- initialMessage,
- message,
- threadList,
- } = useMessageContext('MessageOptions');
-
- const { t } = useTranslationContext('MessageOptions');
-
- // It is necessary to namespace the dialog IDs because a message with the same ID
- // can appear in the main message list as well as in the thread message list.
- // Without the namespace, the search for dialog would be performed by the message ID only
- // which could return the dialog for a message in another message list (which would not be rendered).
- const dialogIdNamespace = threadList ? '-thread-' : '';
-
- const messageActionsDialogIsOpen = useDialogIsOpen(
- `message-actions${dialogIdNamespace}--${message.id}`,
- );
- const reactionSelectorDialogIsOpen = useDialogIsOpen(
- `reaction-selector${dialogIdNamespace}--${message.id}`,
- );
- const handleOpenThread = propHandleOpenThread || contextHandleOpenThread;
-
- const messageActions = getMessageActions();
-
- const shouldShowReactions = messageActions.indexOf(MESSAGE_ACTIONS.react) > -1;
- const shouldShowReplies =
- messageActions.indexOf(MESSAGE_ACTIONS.reply) > -1 && displayReplies && !threadList;
-
- if (
- !message.type ||
- message.type === 'error' ||
- message.type === 'system' ||
- message.type === 'ephemeral' ||
- message.status === 'failed' ||
- message.status === 'sending' ||
- initialMessage
- ) {
- return null;
- }
-
- return (
-
-
- {shouldShowReplies && (
-
- )}
- {shouldShowReactions && }
-
- );
-};
-
-export const MessageOptions = React.memo(
- UnMemoizedMessageOptions,
-) as typeof UnMemoizedMessageOptions;
diff --git a/src/components/Message/MessageSimple.tsx b/src/components/Message/MessageSimple.tsx
index 9bf01d1666..b7c38cd571 100644
--- a/src/components/Message/MessageSimple.tsx
+++ b/src/components/Message/MessageSimple.tsx
@@ -5,7 +5,7 @@ import { MessageErrorIcon } from './icons';
import { MessageBouncePrompt as DefaultMessageBouncePrompt } from '../MessageBounce';
import { MessageDeleted as DefaultMessageDeleted } from './MessageDeleted';
import { MessageBlocked as DefaultMessageBlocked } from './MessageBlocked';
-import { MessageOptions as DefaultMessageOptions } from './MessageOptions';
+import { MessageActions as DefaultMessageActions } from '../MessageActions';
import { MessageRepliesCountButton as DefaultMessageRepliesCountButton } from './MessageRepliesCountButton';
import { MessageStatus as DefaultMessageStatus } from './MessageStatus';
import { MessageText } from './MessageText';
@@ -70,10 +70,7 @@ const MessageSimpleWithContext = (props: MessageSimpleWithContextProps) => {
Attachment = DefaultAttachment,
Avatar = DefaultAvatar,
EditMessageModal = DefaultEditMessageModal,
- MessageOptions = DefaultMessageOptions,
- // TODO: remove this "passthrough" in the next
- // major release and use the new default instead
- MessageActions = MessageOptions,
+ MessageActions = DefaultMessageActions,
MessageBlocked = DefaultMessageBlocked,
MessageBouncePrompt = DefaultMessageBouncePrompt,
MessageDeleted = DefaultMessageDeleted,
@@ -81,10 +78,10 @@ const MessageSimpleWithContext = (props: MessageSimpleWithContextProps) => {
MessageRepliesCountButton = DefaultMessageRepliesCountButton,
MessageStatus = DefaultMessageStatus,
MessageTimestamp = DefaultMessageTimestamp,
+ PinIndicator,
ReactionsList = DefaultReactionList,
ReminderNotification = DefaultReminderNotification,
StreamedMessageText = DefaultStreamedMessageText,
- PinIndicator,
} = useComponentContext('MessageSimple');
const hasAttachment = messageHasAttachments(message);
const hasReactions = messageHasReactions(message);
diff --git a/src/components/Message/__tests__/FixedHeightMessage.test.js b/src/components/Message/__tests__/FixedHeightMessage.test.js
index b18c24de12..9823aa5a65 100644
--- a/src/components/Message/__tests__/FixedHeightMessage.test.js
+++ b/src/components/Message/__tests__/FixedHeightMessage.test.js
@@ -7,7 +7,6 @@ import { FixedHeightMessage } from '../FixedHeightMessage';
import { Avatar as AvatarMock } from '../../Avatar';
import { Gallery as GalleryMock } from '../../Gallery';
import { Message } from '../Message';
-import { MessageActions as MessageActionsMock } from '../../MessageActions';
import { ChannelActionProvider } from '../../../context/ChannelActionContext';
import { ChannelStateProvider } from '../../../context/ChannelStateContext';
@@ -20,17 +19,13 @@ import {
generateUser,
getTestClientWithUser,
} from '../../../mock-builders';
-import { ComponentProvider } from '../../../context';
+import { ComponentProvider, DialogManagerProvider } from '../../../context';
jest.mock('../../Avatar', () => ({ Avatar: jest.fn(() => ) }));
jest.mock('../../Gallery', () => ({ Gallery: jest.fn(() => ) }));
-jest.mock('../../MessageActions', () => ({
- MessageActions: jest.fn((props) => props.getMessageActions()),
-}));
const aliceProfile = { image: 'alice-avatar.jpg', name: 'alice' };
const alice = generateUser(aliceProfile);
-const bob = generateUser({ name: 'bob' });
async function renderMsg(message) {
const channel = generateChannel({ state: { membership: {} } });
@@ -57,7 +52,9 @@ async function renderMsg(message) {
}}
>
-
+
+
+
@@ -93,22 +90,6 @@ describe('', () => {
);
});
- it('should render message action for owner', async () => {
- const message = generateMessage({ user: alice });
- await renderMsg(message);
- expect(MessageActionsMock).toHaveBeenCalledWith(
- expect.objectContaining({ message }),
- undefined,
- );
- expect(MessageActionsMock).toHaveReturnedWith(['delete']);
- });
-
- it('should not render message action for others', async () => {
- const message = generateMessage({ user: bob });
- await renderMsg(message);
- expect(MessageActionsMock).toHaveReturnedWith([]);
- });
-
it('should display text in users set language', async () => {
const message = generateMessage({
i18n: { en_text: 'hello', fr_text: 'bonjour', language: 'fr' },
diff --git a/src/components/Message/__tests__/MessageOptions.test.js b/src/components/Message/__tests__/MessageOptions.test.js
deleted file mode 100644
index 96c91d1b78..0000000000
--- a/src/components/Message/__tests__/MessageOptions.test.js
+++ /dev/null
@@ -1,412 +0,0 @@
-import React from 'react';
-import { act, fireEvent, render, screen } from '@testing-library/react';
-import '@testing-library/jest-dom';
-
-import { Message } from '../Message';
-import { MessageOptions } from '../MessageOptions';
-import { MessageSimple } from '../MessageSimple';
-import { ACTIONS_NOT_WORKING_IN_THREAD, MESSAGE_ACTIONS } from '../utils';
-
-import { Attachment } from '../../Attachment';
-import { defaultReactionOptions } from '../../Reactions';
-
-import {
- ChannelActionProvider,
- ChannelStateProvider,
- ChatProvider,
- ComponentProvider,
- DialogManagerProvider,
-} from '../../../context';
-
-import {
- generateChannel,
- generateMessage,
- generateUser,
- getTestClientWithUser,
-} from '../../../mock-builders';
-
-const MESSAGE_ACTIONS_TEST_ID = 'message-actions';
-
-const minimumCapabilitiesToRenderMessageActions = { 'delete-any-message': true };
-const alice = generateUser({ name: 'alice' });
-const defaultMessageProps = {
- initialMessage: false,
- message: generateMessage(),
- messageActions: Object.keys(MESSAGE_ACTIONS),
- threadList: false,
-};
-const defaultOptionsProps = {};
-
-function generateAliceMessage(messageOptions) {
- return generateMessage({
- user: alice,
- ...messageOptions,
- });
-}
-
-async function renderMessageOptions({
- channelConfig,
- channelStateOpts = {},
- customMessageProps = {},
- customOptionsProps = {},
-}) {
- const client = await getTestClientWithUser(alice);
- const channel = generateChannel({
- getConfig: () => channelConfig,
- state: { membership: {} },
- });
-
- return render(
-
-
-
-
- ,
- reactionOptions: defaultReactionOptions,
- }}
- >
-
-
-
-
-
-
-
- ,
- );
-}
-
-const threadActionTestId = 'thread-action';
-const reactionActionTestId = 'message-reaction-action';
-
-describe('', () => {
- beforeEach(jest.clearAllMocks);
- it('should not render message options when there is no message set', async () => {
- const { queryByTestId } = await renderMessageOptions({
- customMessageProps: {
- message: {},
- },
- });
- expect(queryByTestId(/message-options/)).not.toBeInTheDocument();
- });
-
- it.each([
- ['type', 'error'],
- ['type', 'system'],
- ['type', 'ephemeral'],
- ['status', 'failed'],
- ['status', 'sending'],
- ])(
- 'should not render message options when message is of %s %s and is from current user.',
- async (key, value) => {
- const message = generateAliceMessage({ [key]: value });
- const { queryByTestId } = await renderMessageOptions({
- customMessageProps: { message },
- });
- expect(queryByTestId(/message-options/)).not.toBeInTheDocument();
- },
- );
-
- it('should not render message options when it is parent message in a thread', async () => {
- const { queryByTestId } = await renderMessageOptions({
- customMessageProps: {
- initialMessage: true,
- },
- });
- expect(queryByTestId(/message-options/)).not.toBeInTheDocument();
- });
-
- it('should display thread actions when message is not displayed in a thread list and channel has replies configured', async () => {
- const { getByTestId } = await renderMessageOptions({
- channelStateOpts: {
- channelCapabilities: { 'send-reply': true },
- channelConfig: { replies: true },
- },
- customMessageProps: defaultMessageProps,
- });
- expect(getByTestId(threadActionTestId)).toBeInTheDocument();
- });
-
- it('should not display thread actions when message is in a thread list', async () => {
- const { queryByTestId } = await renderMessageOptions({
- channelConfig: { replies: true },
- customMessageProps: { threadList: true },
- });
- expect(queryByTestId(threadActionTestId)).not.toBeInTheDocument();
- });
-
- it('should not display thread actions when channel does not have replies enabled', async () => {
- const { queryByTestId } = await renderMessageOptions({
- channelConfig: { replies: false },
- });
- expect(queryByTestId(threadActionTestId)).not.toBeInTheDocument();
- });
-
- it('should trigger open thread handler when custom thread action is set and thread action is clicked', async () => {
- const handleOpenThread = jest.fn(() => {});
- const message = generateMessage();
- const { getByTestId } = await renderMessageOptions({
- channelStateOpts: {
- channelCapabilities: { 'send-reply': true },
- channelConfig: { replies: true },
- },
- customMessageProps: { message, openThread: handleOpenThread, threadList: false },
- });
- expect(handleOpenThread).not.toHaveBeenCalled();
- fireEvent.click(getByTestId(threadActionTestId));
-
- expect(handleOpenThread).toHaveBeenCalled();
- });
-
- it('should display reactions action when channel has reactions enabled', async () => {
- const { getByTestId } = await renderMessageOptions({
- channelStateOpts: {
- channelCapabilities: { 'send-reaction': true },
- },
- });
- expect(getByTestId(reactionActionTestId)).toBeInTheDocument();
- });
-
- it('should not display reactions action when channel has reactions disabled', async () => {
- const { queryByTestId } = await renderMessageOptions({
- channelStateOpts: {
- channelCapabilities: { 'send-reaction': false },
- },
- });
- expect(queryByTestId(reactionActionTestId)).not.toBeInTheDocument();
- });
-
- it('should not render ReactionsSelector until open', async () => {
- const { queryByTestId } = await renderMessageOptions({
- channelStateOpts: {
- channelCapabilities: { 'send-reaction': true },
- },
- });
- expect(screen.queryByTestId('reaction-selector')).not.toBeInTheDocument();
- await act(async () => {
- await fireEvent.click(queryByTestId(reactionActionTestId));
- });
- expect(screen.getByTestId('reaction-selector')).toBeInTheDocument();
- });
-
- it('should unmount ReactionsSelector when closed by click on dialog overlay', async () => {
- const { queryByTestId } = await renderMessageOptions({
- channelStateOpts: {
- channelCapabilities: { 'send-reaction': true },
- },
- });
- await act(async () => {
- await fireEvent.click(queryByTestId(reactionActionTestId));
- });
- await act(async () => {
- await fireEvent.click(screen.getByTestId('str-chat__dialog-overlay'));
- });
- expect(screen.queryByTestId('reaction-selector')).not.toBeInTheDocument();
- });
-
- it('should unmount ReactionsSelector when closed pressed Esc button', async () => {
- const { queryByTestId } = await renderMessageOptions({
- channelStateOpts: {
- channelCapabilities: { 'send-reaction': true },
- },
- });
- await act(async () => {
- await fireEvent.click(queryByTestId(reactionActionTestId));
- });
- await act(async () => {
- await fireEvent.keyUp(document, { charCode: 27, code: 'Escape', key: 'Escape' });
- });
- expect(screen.queryByTestId('reaction-selector')).not.toBeInTheDocument();
- });
-
- it('should unmount ReactionsSelector when closed on reaction selection and closeReactionSelectorOnClick enabled', async () => {
- const { queryByTestId } = await renderMessageOptions({
- channelStateOpts: {
- channelCapabilities: { 'send-reaction': true },
- },
- customMessageProps: {
- closeReactionSelectorOnClick: true,
- },
- });
- await act(async () => {
- await fireEvent.click(queryByTestId(reactionActionTestId));
- });
- await act(async () => {
- await fireEvent.click(screen.queryAllByTestId('select-reaction-button')[0]);
- });
- expect(screen.queryByTestId('reaction-selector')).not.toBeInTheDocument();
- });
-
- it('should not unmount ReactionsSelector when closed on reaction selection and closeReactionSelectorOnClick enabled', async () => {
- const { queryByTestId } = await renderMessageOptions({
- channelStateOpts: {
- channelCapabilities: { 'send-reaction': true },
- },
- customMessageProps: {
- closeReactionSelectorOnClick: false,
- },
- });
- await act(async () => {
- await fireEvent.click(queryByTestId(reactionActionTestId));
- });
- await act(async () => {
- await fireEvent.click(screen.queryAllByTestId('select-reaction-button')[0]);
- });
- expect(screen.queryByTestId('reaction-selector')).toBeInTheDocument();
- });
-
- it('should render message actions', async () => {
- const { queryByTestId } = await renderMessageOptions({
- channelStateOpts: {
- channelCapabilities: minimumCapabilitiesToRenderMessageActions,
- },
- });
-
- expect(queryByTestId(MESSAGE_ACTIONS_TEST_ID)).toBeInTheDocument();
- });
-
- it('should not show message actions button if actions are disabled', async () => {
- const { queryByTestId } = await renderMessageOptions({
- channelStateOpts: {
- channelCapabilities: minimumCapabilitiesToRenderMessageActions,
- },
- customMessageProps: { messageActions: [] },
- });
-
- expect(queryByTestId(MESSAGE_ACTIONS_TEST_ID)).not.toBeInTheDocument();
- });
-
- it('should not show actions box for message in thread if only non-thread actions are available', async () => {
- const { queryByTestId } = await renderMessageOptions({
- channelStateOpts: {
- channelCapabilities: minimumCapabilitiesToRenderMessageActions,
- },
- customMessageProps: {
- messageActions: ACTIONS_NOT_WORKING_IN_THREAD,
- threadList: true,
- },
- });
-
- expect(queryByTestId(MESSAGE_ACTIONS_TEST_ID)).not.toBeInTheDocument();
- });
-
- it('should show actions box for message in thread if not only non-thread actions are available', async () => {
- const { queryByTestId } = await renderMessageOptions({
- channelStateOpts: {
- channelCapabilities: minimumCapabilitiesToRenderMessageActions,
- },
- customMessageProps: {
- messageActions: [...ACTIONS_NOT_WORKING_IN_THREAD, MESSAGE_ACTIONS.delete],
- threadList: true,
- },
- });
-
- expect(queryByTestId(MESSAGE_ACTIONS_TEST_ID)).toBeInTheDocument();
- });
-
- it('should show actions box for a message in thread if custom actions provided are non-thread', async () => {
- const { queryByTestId } = await renderMessageOptions({
- channelStateOpts: {
- channelCapabilities: minimumCapabilitiesToRenderMessageActions,
- },
- customMessageProps: {
- customMessageActions: ACTIONS_NOT_WORKING_IN_THREAD,
- messageActions: ACTIONS_NOT_WORKING_IN_THREAD,
- threadList: true,
- },
- });
- expect(queryByTestId(MESSAGE_ACTIONS_TEST_ID)).toBeInTheDocument();
- });
-
- it('should not show actions box for message outside thread with single action "react"', async () => {
- const { queryByTestId } = await renderMessageOptions({
- channelStateOpts: {
- channelCapabilities: minimumCapabilitiesToRenderMessageActions,
- },
- customMessageProps: {
- messageActions: [MESSAGE_ACTIONS.react],
- },
- });
- expect(queryByTestId(MESSAGE_ACTIONS_TEST_ID)).not.toBeInTheDocument();
- });
-
- it('should show actions box for message outside thread with single action "react" if custom actions available', async () => {
- const { queryByTestId } = await renderMessageOptions({
- channelStateOpts: {
- channelCapabilities: minimumCapabilitiesToRenderMessageActions,
- },
- customMessageProps: {
- customMessageActions: [MESSAGE_ACTIONS.react],
- messageActions: [MESSAGE_ACTIONS.react],
- },
- });
-
- expect(queryByTestId(MESSAGE_ACTIONS_TEST_ID)).toBeInTheDocument();
- });
-
- it('should not show actions box for message outside thread with single action "reply"', async () => {
- const { queryByTestId } = await renderMessageOptions({
- channelStateOpts: {
- channelCapabilities: minimumCapabilitiesToRenderMessageActions,
- },
- customMessageProps: {
- messageActions: [MESSAGE_ACTIONS.reply],
- },
- });
-
- expect(queryByTestId(MESSAGE_ACTIONS_TEST_ID)).not.toBeInTheDocument();
- });
-
- it('should show actions box for message outside thread with single action "reply" if custom actions available', async () => {
- const { queryByTestId } = await renderMessageOptions({
- channelStateOpts: {
- channelCapabilities: minimumCapabilitiesToRenderMessageActions,
- },
- customMessageProps: {
- customMessageActions: [MESSAGE_ACTIONS.reply],
- messageActions: [MESSAGE_ACTIONS.reply],
- },
- });
-
- expect(queryByTestId(MESSAGE_ACTIONS_TEST_ID)).toBeInTheDocument();
- });
-
- it('should not show actions box for message outside thread with two actions "react" & "reply"', async () => {
- const actions = [MESSAGE_ACTIONS.react, MESSAGE_ACTIONS.reply];
- const { queryByTestId } = await renderMessageOptions({
- channelStateOpts: {
- channelCapabilities: minimumCapabilitiesToRenderMessageActions,
- },
- customMessageProps: {
- messageActions: actions,
- },
- });
-
- expect(queryByTestId(MESSAGE_ACTIONS_TEST_ID)).not.toBeInTheDocument();
- });
-
- it('should show actions box for message outside thread with single actions "react" & "reply" if custom actions available', async () => {
- const actions = [MESSAGE_ACTIONS.react, MESSAGE_ACTIONS.reply];
- const { queryByTestId } = await renderMessageOptions({
- channelStateOpts: {
- channelCapabilities: minimumCapabilitiesToRenderMessageActions,
- },
- customMessageProps: {
- customMessageActions: actions,
- messageActions: actions,
- },
- });
-
- expect(queryByTestId(MESSAGE_ACTIONS_TEST_ID)).toBeInTheDocument();
- });
-});
diff --git a/src/components/Message/__tests__/MessageSimple.test.js b/src/components/Message/__tests__/MessageSimple.test.js
index 83bd3c80e6..4267d457c0 100644
--- a/src/components/Message/__tests__/MessageSimple.test.js
+++ b/src/components/Message/__tests__/MessageSimple.test.js
@@ -1,5 +1,5 @@
import React from 'react';
-import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
+import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import Dayjs from 'dayjs';
import calendar from 'dayjs/plugin/calendar';
@@ -7,7 +7,6 @@ import { toHaveNoViolations } from 'jest-axe';
import { axe } from '../../../../axe-helper';
import { Message } from '../Message';
import { MessageSimple } from '../MessageSimple';
-import { MessageOptions as MessageOptionsMock } from '../MessageOptions';
import { MessageText as MessageTextMock } from '../MessageText';
import { MESSAGE_ACTIONS } from '../utils';
@@ -42,9 +41,6 @@ expect.extend(toHaveNoViolations);
Dayjs.extend(calendar);
-jest.mock('../MessageOptions', () => ({
- MessageOptions: jest.fn(() => ),
-}));
jest.mock('../MessageText', () => ({
MessageText: jest.fn(() => ),
}));
@@ -233,16 +229,16 @@ describe('', () => {
expect(results).toHaveNoViolations();
});
- it('should render message with custom options component when one is given', async () => {
+ it('should render message with custom actions component when one is given', async () => {
const message = generateAliceMessage({ text: '' });
- const CustomOptions = () => Options
;
+ const CustomActions = () => Actions
;
const { container, getByTestId } = await renderMessageSimple({
components: {
- MessageOptions: CustomOptions,
+ MessageActions: CustomActions,
},
message,
});
- expect(getByTestId('custom-message-options')).toBeInTheDocument();
+ expect(getByTestId('custom-message-actions')).toBeInTheDocument();
const results = await axe(container);
expect(results).toHaveNoViolations();
});
@@ -534,14 +530,16 @@ describe('', () => {
it('should render message options', async () => {
const message = generateAliceMessage({ text: undefined });
- const { container } = await renderMessageSimple({
+ const { container, getByTestId } = await renderMessageSimple({
message,
props: {
handleOpenThread: jest.fn(),
},
});
- expect(MessageOptionsMock).toHaveBeenCalled();
+ await waitFor(() => {
+ expect(getByTestId('message-actions-host')).toBeInTheDocument();
+ });
const results = await axe(container);
expect(results).toHaveNoViolations();
});
diff --git a/src/components/Message/__tests__/MessageText.test.js b/src/components/Message/__tests__/MessageText.test.js
index a049f918b3..6df70b5071 100644
--- a/src/components/Message/__tests__/MessageText.test.js
+++ b/src/components/Message/__tests__/MessageText.test.js
@@ -9,6 +9,7 @@ import {
ChannelStateProvider,
ChatProvider,
ComponentProvider,
+ DialogManagerProvider,
TranslationProvider,
} from '../../../context';
import {
@@ -24,14 +25,13 @@ import {
import { Attachment } from '../../Attachment';
import { defaultReactionOptions } from '../../Reactions';
import { Message } from '../Message';
-import { MessageOptions as MessageOptionsMock } from '../MessageOptions';
import { MessageSimple } from '../MessageSimple';
import { MessageText } from '../MessageText';
expect.extend(toHaveNoViolations);
-jest.mock('../MessageOptions', () => ({
- MessageOptions: jest.fn(() => ),
+jest.mock('../../MessageActions', () => ({
+ MessageActions: jest.fn(() => ),
}));
const alice = generateUser({ name: 'alice' });
@@ -89,9 +89,11 @@ async function renderMessageText({
reactionOptions: defaultReactionOptions,
}}
>
-
-
-
+
+
+
+
+
@@ -281,24 +283,11 @@ describe('', () => {
expect(results).toHaveNoViolations();
});
- it('should render message options', async () => {
- const { container } = await renderMessageText();
- expect(MessageOptionsMock).toHaveBeenCalledTimes(1);
- const results = await axe(container);
- expect(results).toHaveNoViolations();
- });
-
- it('should render message options with custom props when those are set', async () => {
- const displayLeft = false;
- const { container } = await renderMessageText({
- customProps: {
- customOptionProps: {
- displayLeft,
- },
- },
- });
-
- expect(MessageOptionsMock).toHaveBeenCalled();
+ it('should render message actions', async () => {
+ const { container, getByTestId } = await renderMessageText();
+ await waitFor(() =>
+ expect(getByTestId('mocked-message-actions')).toBeInTheDocument(),
+ );
const results = await axe(container);
expect(results).toHaveNoViolations();
});
diff --git a/src/components/Message/__tests__/__snapshots__/MessageText.test.js.snap b/src/components/Message/__tests__/__snapshots__/MessageText.test.js.snap
index 590c69bfb2..04231ced6c 100644
--- a/src/components/Message/__tests__/__snapshots__/MessageText.test.js.snap
+++ b/src/components/Message/__tests__/__snapshots__/MessageText.test.js.snap
@@ -9,7 +9,9 @@ exports[` should render with a custom inner class when one is set
class="str-chat__message-inner"
data-testid="message-inner"
>
-
+
@@ -71,7 +73,9 @@ exports[` should render with a custom wrapper class when one is s
class="str-chat__message-inner"
data-testid="message-inner"
>
-
+
@@ -133,7 +137,9 @@ exports[` should render with custom theme identifier in generated
class="str-chat__message-inner"
data-testid="message-inner"
>
-
+
diff --git a/src/components/Message/index.ts b/src/components/Message/index.ts
index 3509ee6b01..eb848343e2 100644
--- a/src/components/Message/index.ts
+++ b/src/components/Message/index.ts
@@ -6,7 +6,6 @@ export * from './MessageBlocked';
export * from './MessageDeleted';
export * from './MessageEditedTimestamp';
export * from './MessageIsThreadReplyInChannelButtonIndicator';
-export * from './MessageOptions';
export * from './MessageRepliesCountButton';
export * from './MessageSimple';
export * from './MessageStatus';
diff --git a/src/components/Message/types.ts b/src/components/Message/types.ts
index c32669cb60..885fd14648 100644
--- a/src/components/Message/types.ts
+++ b/src/components/Message/types.ts
@@ -25,8 +25,6 @@ export type MessageProps = {
autoscrollToBottom?: () => void;
/** If true, picking a reaction from the `ReactionSelector` component will close the selector */
closeReactionSelectorOnClick?: boolean;
- /** Object containing custom message actions and function handlers */
- customMessageActions?: MessageContextValue['customMessageActions'];
/** An array of user IDs that have confirmed the message delivery to their device */
deliveredTo?: UserResponse[];
/** If true, disables the ability for users to quote messages, defaults to false */
diff --git a/src/components/Message/utils.tsx b/src/components/Message/utils.tsx
index 5a1974d6be..fddf841688 100644
--- a/src/components/Message/utils.tsx
+++ b/src/components/Message/utils.tsx
@@ -13,11 +13,7 @@ import type {
} from 'stream-chat';
import type { PinPermissions } from './hooks';
import type { MessageProps } from './types';
-import type {
- ComponentContextValue,
- CustomMessageActions,
- MessageContextValue,
-} from '../../context';
+import type { MessageContextValue } from '../../context';
/**
* Following function validates a function which returns notification message.
@@ -235,60 +231,6 @@ export const ACTIONS_NOT_WORKING_IN_THREAD = [
MESSAGE_ACTIONS.markUnread,
];
-/**
- * @deprecated use `shouldRenderMessageActions` instead
- */
-export const showMessageActionsBox = (
- actions: MessageActionsArray,
- inThread?: boolean | undefined,
-) => shouldRenderMessageActions({ inThread, messageActions: actions });
-
-export const shouldRenderMessageActions = ({
- customMessageActions,
- CustomMessageActionsList,
- inThread,
- messageActions,
-}: {
- messageActions: MessageActionsArray;
- customMessageActions?: CustomMessageActions;
- CustomMessageActionsList?: ComponentContextValue['CustomMessageActionsList'];
- inThread?: boolean;
-}) => {
- if (
- typeof CustomMessageActionsList !== 'undefined' ||
- typeof customMessageActions !== 'undefined'
- )
- return true;
-
- if (!messageActions.length) return false;
-
- if (
- inThread &&
- messageActions.filter((action) => !ACTIONS_NOT_WORKING_IN_THREAD.includes(action))
- .length === 0
- ) {
- return false;
- }
-
- if (
- messageActions.length === 1 &&
- (messageActions.includes(MESSAGE_ACTIONS.react) ||
- messageActions.includes(MESSAGE_ACTIONS.reply))
- ) {
- return false;
- }
-
- if (
- messageActions.length === 2 &&
- messageActions.includes(MESSAGE_ACTIONS.react) &&
- messageActions.includes(MESSAGE_ACTIONS.reply)
- ) {
- return false;
- }
-
- return true;
-};
-
function areMessagesEqual(prevMessage: LocalMessage, nextMessage: LocalMessage): boolean {
const areBaseMessagesEqual = (
prevMessage: LocalMessageBase,
diff --git a/src/components/MessageActions/CustomMessageActionsList.tsx b/src/components/MessageActions/CustomMessageActionsList.tsx
deleted file mode 100644
index 969439d093..0000000000
--- a/src/components/MessageActions/CustomMessageActionsList.tsx
+++ /dev/null
@@ -1,37 +0,0 @@
-import React from 'react';
-
-import type { LocalMessage } from 'stream-chat';
-import type { CustomMessageActions } from '../../context/MessageContext';
-
-export type CustomMessageActionsListProps = {
- message: LocalMessage;
- customMessageActions?: CustomMessageActions;
-};
-
-export const CustomMessageActionsList = (props: CustomMessageActionsListProps) => {
- const { customMessageActions, message } = props;
-
- if (!customMessageActions) return null;
-
- const customActionsArray = Object.keys(customMessageActions);
-
- return (
- <>
- {customActionsArray.map((customAction) => {
- const customHandler = customMessageActions[customAction];
-
- return (
-
- );
- })}
- >
- );
-};
diff --git a/src/components/MessageActions/MessageActions.tsx b/src/components/MessageActions/MessageActions.tsx
index c3db70ca8b..3371fb92cb 100644
--- a/src/components/MessageActions/MessageActions.tsx
+++ b/src/components/MessageActions/MessageActions.tsx
@@ -1,171 +1,128 @@
import clsx from 'clsx';
+import React, { useState } from 'react';
import type { PropsWithChildren } from 'react';
-import React, { useCallback, useRef } from 'react';
-import { MessageActionsBox } from './MessageActionsBox';
-
-import { DialogAnchor, useDialogIsOpen, useDialogOnNearestManager } from '../Dialog';
-import { ActionsIcon as DefaultActionsIcon } from '../Message/icons';
-import { isUserMuted, shouldRenderMessageActions } from '../Message/utils';
-
-import { useChatContext } from '../../context/ChatContext';
-import type { MessageContextValue } from '../../context/MessageContext';
-import { useMessageContext } from '../../context/MessageContext';
-import { useComponentContext, useTranslationContext } from '../../context';
-
-import type { IconProps } from '../../types/types';
-
-type MessageContextPropsToPick =
- | 'getMessageActions'
- | 'handleDelete'
- | 'handleFlag'
- | 'handleMarkUnread'
- | 'handleMute'
- | 'handlePin'
- | 'message';
-
-export type MessageActionsProps = Partial<
- Pick
-> & {
- /* Custom component rendering the icon used in message actions button. This button invokes the message actions menu. */
- ActionsIcon?: React.ComponentType;
- /* Custom CSS class to be added to the `div` wrapping the component */
- customWrapperClass?: string;
- /* If true, renders the wrapper component as a `span`, not a `div` */
- inline?: boolean;
- /* Function that returns whether the message was sent by the connected user */
- mine?: () => boolean;
+import { useChatContext, useMessageContext, useTranslationContext } from '../../context';
+import { ActionsIcon } from '../../components/Message/icons';
+import {
+ DialogAnchor,
+ useDialogIsOpen,
+ useDialogOnNearestManager,
+} from '../../components/Dialog';
+import { MessageActionsWrapper } from './MessageActionsWrapper';
+import { useBaseMessageActionSetFilter, useSplitMessageActionSet } from './hooks';
+import { defaultMessageActionSet } from './defaults';
+import type { MESSAGE_ACTIONS } from '../Message/utils';
+
+export type MessageActionSetItem = {
+ Component: React.ComponentType;
+ placement: 'quick' | 'dropdown';
+ type: keyof typeof MESSAGE_ACTIONS | (string & {});
};
-export const MessageActions = (props: MessageActionsProps) => {
- const {
- ActionsIcon = DefaultActionsIcon,
- customWrapperClass = '',
- getMessageActions: propGetMessageActions,
- handleDelete: propHandleDelete,
- handleFlag: propHandleFlag,
- handleMarkUnread: propHandleMarkUnread,
- handleMute: propHandleMute,
- handlePin: propHandlePin,
- inline,
- message: propMessage,
- mine,
- } = props;
-
- const { mutes } = useChatContext('MessageActions');
-
- const {
- customMessageActions,
- getMessageActions: contextGetMessageActions,
- handleDelete: contextHandleDelete,
- handleFlag: contextHandleFlag,
- handleMarkUnread: contextHandleMarkUnread,
- handleMute: contextHandleMute,
- handlePin: contextHandlePin,
- isMyMessage,
- message: contextMessage,
- setEditingState,
- threadList,
- } = useMessageContext('MessageActions');
-
- const { CustomMessageActionsList } = useComponentContext('MessageActions');
-
- const { t } = useTranslationContext('MessageActions');
-
- const getMessageActions = propGetMessageActions || contextGetMessageActions;
- const handleDelete = propHandleDelete || contextHandleDelete;
- const handleFlag = propHandleFlag || contextHandleFlag;
- const handleMarkUnread = propHandleMarkUnread || contextHandleMarkUnread;
- const handleMute = propHandleMute || contextHandleMute;
- const handlePin = propHandlePin || contextHandlePin;
- const message = propMessage || contextMessage;
- const isMine = mine ? mine() : isMyMessage();
-
- const isMuted = useCallback(() => isUserMuted(message, mutes), [message, mutes]);
-
- const dialogIdNamespace = threadList ? '-thread-' : '';
- const dialogId = `message-actions${dialogIdNamespace}--${message.id}`;
- const { dialog, dialogManager } = useDialogOnNearestManager({ id: dialogId });
- const dialogIsOpen = useDialogIsOpen(dialogId, dialogManager?.id);
+export type MessageActionsProps = {
+ disableBaseMessageActionSetFilter?: boolean;
+ messageActionSet?: MessageActionSetItem[];
+};
- const messageActions = getMessageActions();
+// TODO: allow passing down customWrapperClass
+/**
+ * A new actions component to replace current `MessageOptions` component.
+ * Exports from `stream-chat-react/experimental` __MIGHT__ change - use with caution
+ * and follow release notes in case you notice unexpected behavior.
+ */
+export const MessageActions = ({
+ disableBaseMessageActionSetFilter = false,
+ messageActionSet = defaultMessageActionSet,
+}: MessageActionsProps) => {
+ const { theme } = useChatContext();
+ const { isMyMessage, message } = useMessageContext();
+ const { t } = useTranslationContext();
+ const [actionsBoxButtonElement, setActionsBoxButtonElement] =
+ useState(null);
+
+ const filteredMessageActionSet = useBaseMessageActionSetFilter(
+ messageActionSet,
+ disableBaseMessageActionSetFilter,
+ );
- const renderMessageActions = shouldRenderMessageActions({
- customMessageActions,
- CustomMessageActionsList,
- inThread: threadList,
- messageActions,
- });
+ const { dropdownActionSet, quickActionSet } = useSplitMessageActionSet(
+ filteredMessageActionSet,
+ );
- const actionsBoxButtonRef = useRef(null);
+ const dropdownDialogId = `message-actions--${message.id}`;
+ const reactionSelectorDialogId = `reaction-selector--${message.id}`;
+ const { dialog, dialogManager } = useDialogOnNearestManager({ id: dropdownDialogId });
+ const dropdownDialogIsOpen = useDialogIsOpen(dropdownDialogId, dialogManager?.id);
+ const reactionSelectorDialogIsOpen = useDialogIsOpen(
+ reactionSelectorDialogId,
+ dialogManager?.id,
+ );
- if (!renderMessageActions) return null;
+ // do not render anything if total action count is zero
+ if (dropdownActionSet.length + quickActionSet.length === 0) {
+ return null;
+ }
return (
-
-
-
-
-
-
+ {dropdownActionSet.length > 0 && (
+
+
+
+
+
+ {dropdownActionSet.map(({ Component: DropdownActionComponent, type }) => (
+
+ ))}
+
+
+
+ )}
+ {quickActionSet.map(({ Component: QuickActionComponent, type }) => (
+
+ ))}
+
);
};
-export type MessageActionsWrapperProps = {
- customWrapperClass?: string;
- inline?: boolean;
- toggleOpen?: () => void;
-};
-
-export const MessageActionsWrapper = (
- props: PropsWithChildren
,
-) => {
- const { children, customWrapperClass, inline, toggleOpen } = props;
-
- const defaultWrapperClass = clsx(
- 'str-chat__message-simple__actions__action',
- 'str-chat__message-simple__actions__action--options',
- 'str-chat__message-actions-container',
+const DropdownBox = ({ children, open }: PropsWithChildren<{ open: boolean }>) => {
+ const { t } = useTranslationContext();
+ return (
+
);
-
- const wrapperProps = {
- className: customWrapperClass || defaultWrapperClass,
- 'data-testid': 'message-actions',
- onClick: toggleOpen,
- };
-
- if (inline) return {children};
-
- return {children}
;
};
diff --git a/src/components/MessageActions/MessageActionsBox.tsx b/src/components/MessageActions/MessageActionsBox.tsx
deleted file mode 100644
index 0a6c4392b6..0000000000
--- a/src/components/MessageActions/MessageActionsBox.tsx
+++ /dev/null
@@ -1,203 +0,0 @@
-import clsx from 'clsx';
-import type { ComponentProps } from 'react';
-import React from 'react';
-import { CustomMessageActionsList as DefaultCustomMessageActionsList } from './CustomMessageActionsList';
-import { RemindMeActionButton } from './RemindMeSubmenu';
-import { OPTIONAL_MESSAGE_ACTIONS, useMessageReminder } from '../Message';
-import { useMessageComposer } from '../MessageInput';
-import {
- useChatContext,
- useComponentContext,
- useMessageContext,
- useTranslationContext,
-} from '../../context';
-import { MESSAGE_ACTIONS } from '../Message/utils';
-import type { MessageContextValue } from '../../context';
-
-type PropsDrilledToMessageActionsBox =
- | 'getMessageActions'
- | 'handleDelete'
- | 'handleEdit'
- | 'handleMarkUnread'
- | 'handleFlag'
- | 'handleMute'
- | 'handlePin';
-
-export type MessageActionsBoxProps = Pick<
- MessageContextValue,
- PropsDrilledToMessageActionsBox
-> & {
- isUserMuted: () => boolean;
- mine: boolean;
- open: boolean;
-} & ComponentProps<'div'>;
-
-const UnMemoizedMessageActionsBox = (props: MessageActionsBoxProps) => {
- const {
- className,
- getMessageActions,
- handleDelete,
- handleEdit,
- handleFlag,
- handleMarkUnread,
- handleMute,
- handlePin,
- isUserMuted,
- mine,
- open,
- ...restDivProps
- } = props;
-
- const { client } = useChatContext();
- const { CustomMessageActionsList = DefaultCustomMessageActionsList } =
- useComponentContext('MessageActionsBox');
- const { customMessageActions, message, threadList } =
- useMessageContext('MessageActionsBox');
- const { t } = useTranslationContext('MessageActionsBox');
- const messageComposer = useMessageComposer();
- const reminder = useMessageReminder(message.id);
-
- const messageActions = getMessageActions();
-
- const handleQuote = () => {
- messageComposer.setQuotedMessage(message);
-
- const elements = message.parent_id
- ? document.querySelectorAll('.str-chat__thread .str-chat__textarea__textarea')
- : document.getElementsByClassName('str-chat__textarea__textarea');
- const textarea = elements.item(0);
-
- if (textarea instanceof HTMLTextAreaElement) {
- textarea.focus();
- }
- };
-
- const rootClassName = clsx('str-chat__message-actions-box', className, {
- 'str-chat__message-actions-box--open': open,
- });
-
- const buttonClassName =
- 'str-chat__message-actions-list-item str-chat__message-actions-list-item-button';
-
- return (
-
-
-
- {messageActions.indexOf(MESSAGE_ACTIONS.quote) > -1 && (
-
- )}
- {messageActions.indexOf(MESSAGE_ACTIONS.pin) > -1 && !message.parent_id && (
-
- )}
- {messageActions.indexOf(MESSAGE_ACTIONS.markUnread) > -1 &&
- !threadList &&
- !!message.id && (
-
- )}
- {messageActions.indexOf(MESSAGE_ACTIONS.flag) > -1 && (
-
- )}
- {messageActions.indexOf(MESSAGE_ACTIONS.mute) > -1 && (
-
- )}
- {messageActions.indexOf(MESSAGE_ACTIONS.edit) > -1 && (
-
- )}
- {messageActions.indexOf(MESSAGE_ACTIONS.delete) > -1 && (
-
- )}
- {messageActions.indexOf(OPTIONAL_MESSAGE_ACTIONS.deleteForMe) > -1 &&
- !message.deleted_for_me && (
-
- )}
- {messageActions.indexOf(MESSAGE_ACTIONS.remindMe) > -1 && (
-
- )}
- {messageActions.indexOf(MESSAGE_ACTIONS.saveForLater) > -1 && (
-
- )}
-
-
- );
-};
-
-/**
- * A popup box that displays the available actions on a message, such as edit, delete, pin, etc.
- */
-export const MessageActionsBox = React.memo(
- UnMemoizedMessageActionsBox,
-) as typeof UnMemoizedMessageActionsBox;
diff --git a/src/components/MessageActions/MessageActionsWrapper.tsx b/src/components/MessageActions/MessageActionsWrapper.tsx
new file mode 100644
index 0000000000..44e67b8aed
--- /dev/null
+++ b/src/components/MessageActions/MessageActionsWrapper.tsx
@@ -0,0 +1,30 @@
+import clsx from 'clsx';
+import React, { type PropsWithChildren } from 'react';
+
+export type MessageActionsWrapperProps = {
+ customWrapperClass?: string;
+ inline?: boolean;
+ toggleOpen?: () => void;
+};
+
+export const MessageActionsWrapper = (
+ props: PropsWithChildren,
+) => {
+ const { children, customWrapperClass, inline, toggleOpen } = props;
+
+ const defaultWrapperClass = clsx(
+ 'str-chat__message-simple__actions__action',
+ 'str-chat__message-simple__actions__action--options',
+ 'str-chat__message-actions-container',
+ );
+
+ const wrapperProps = {
+ className: customWrapperClass || defaultWrapperClass,
+ 'data-testid': 'message-actions',
+ onClick: toggleOpen,
+ };
+
+ if (inline) return {children};
+
+ return {children}
;
+};
diff --git a/src/components/MessageActions/__tests__/CustomMessageActionsList.test.js b/src/components/MessageActions/__tests__/CustomMessageActionsList.test.js
deleted file mode 100644
index 36842c97aa..0000000000
--- a/src/components/MessageActions/__tests__/CustomMessageActionsList.test.js
+++ /dev/null
@@ -1,60 +0,0 @@
-import React from 'react';
-import { fireEvent, render } from '@testing-library/react';
-import { CustomMessageActionsList } from '../CustomMessageActionsList';
-import { act } from 'react';
-
-describe('CustomMessageActionsList', () => {
- it('should render custom list of actions', () => {
- const message = { id: 'mId' };
-
- const actions = {
- key0: () => {},
- key1: () => {},
- };
-
- const { container } = render(
- ,
- );
-
- expect(container).toMatchInlineSnapshot(`
-
-
-
-
- `);
- });
-
- it('should allow clicking custom action', () => {
- const message = { id: 'mId' };
-
- const actions = {
- key0: jest.fn(),
- };
-
- const { getByText } = render(
- ,
- );
-
- const button = getByText('key0');
-
- const event = new Event('click', { bubbles: true });
-
- act(() => {
- fireEvent(button, event);
- });
-
- expect(actions.key0).toHaveBeenCalledWith(message, expect.any(Object)); // replacing SyntheticEvent with any(Object)
- });
-});
diff --git a/src/components/MessageActions/__tests__/MessageActions.test.js b/src/components/MessageActions/__tests__/MessageActions.test.js
index 362213cc0a..ccef104770 100644
--- a/src/components/MessageActions/__tests__/MessageActions.test.js
+++ b/src/components/MessageActions/__tests__/MessageActions.test.js
@@ -1,11 +1,13 @@
import React from 'react';
+import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
-import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { toHaveNoViolations } from 'jest-axe';
+import { axe } from '../../../../axe-helper';
import { MessageActions } from '../MessageActions';
-import { MessageActionsBox as MessageActionsBoxMock } from '../MessageActionsBox';
import {
+ ChannelActionProvider,
ChannelStateProvider,
ChatProvider,
ComponentProvider,
@@ -15,275 +17,845 @@ import {
} from '../../../context';
import {
+ generateChannel,
generateMessage,
- getTestClient,
+ generateUser,
+ getTestClientWithUser,
+ initClientWithChannels,
mockTranslationContext,
} from '../../../mock-builders';
-jest.mock('../MessageActionsBox', () => ({
- MessageActionsBox: jest.fn(() => ),
-}));
+import { Message } from '../../Message';
+import { Channel } from '../../Channel';
+import { Chat } from '../../Chat';
-const wrapperMock = document.createElement('div');
-jest.spyOn(wrapperMock, 'addEventListener');
+expect.extend(toHaveNoViolations);
-const defaultProps = {
- getMessageActions: () => ['flag', 'mute'],
- handleDelete: () => {},
- handleFlag: () => {},
- handleMute: () => {},
- handlePin: () => {},
- message: generateMessage(),
-};
+const alice = generateUser({ name: 'alice' });
+const TOGGLE_ACTIONS_BUTTON_TEST_ID = 'message-actions-toggle-button';
+const MESSAGE_ACTIONS_HOST_TEST_ID = 'message-actions-host';
+const dialogOverlayTestId = 'str-chat__dialog-overlay';
+const threadActionTestId = 'thread-action';
+const reactionActionTestId = 'message-reaction-action';
+const reactionSelectorTestId = 'reaction-selector';
-const messageContextValue = {
- getMessageActions: () => ['delete', 'edit', 'flag', 'mute', 'pin', 'react', 'reply'],
- handleDelete: () => {},
- handleFlag: () => {},
- handleMute: () => {},
- handlePin: () => {},
+const defaultMessageContextValue = {
+ getMessageActions: () => [
+ 'delete',
+ 'edit',
+ 'flag',
+ 'mute',
+ 'pin',
+ 'quote',
+ 'react',
+ 'reply',
+ ],
+ handleDelete: jest.fn(),
+ handleEdit: jest.fn(),
+ handleFlag: jest.fn(),
+ handleMarkUnread: jest.fn(),
+ handleMute: jest.fn(),
+ handleOpenThread: jest.fn(),
+ handlePin: jest.fn(),
isMyMessage: () => false,
message: generateMessage(),
- setEditingState: () => {},
+ setEditingState: jest.fn(),
+};
+
+const toggleOpenMessageActions = async (index = 0) => {
+ await act(async () => {
+ const buttons = screen.getAllByTestId(TOGGLE_ACTIONS_BUTTON_TEST_ID);
+ await fireEvent.click(buttons[index]);
+ });
};
-const chatClient = getTestClient();
+async function renderMessageActions({
+ channelConfig = {},
+ channelStateOpts = {},
+ chatClient,
+ customChatContext = {},
+ customMessageContext = {},
+ messageActionsProps = {},
+} = {}) {
+ const client = chatClient || (await getTestClientWithUser(alice));
+ const channel = generateChannel({
+ getConfig: () => channelConfig,
+ state: { membership: {} },
+ ...channelStateOpts,
+ });
-function renderMessageActions(customProps = {}) {
return render(
-
-
-
-
+
+
+
+
-
-
-
+
+
+
+
+
-
+
,
);
}
-const dialogOverlayTestId = 'str-chat__dialog-overlay';
-const messageActionsTestId = 'message-actions';
-
-const toggleOpenMessageActions = async () => {
- await act(async () => {
- await fireEvent.click(screen.getByRole('button'));
- });
-};
-describe(' component', () => {
- afterEach(cleanup);
+describe('', () => {
beforeEach(jest.clearAllMocks);
- it('should render correctly when not open', () => {
- const { container } = renderMessageActions();
- expect(container).toMatchInlineSnapshot(`
-
- `);
- });
+ describe('Rendering and visibility', () => {
+ it('should not render when there are no actions available', async () => {
+ const { queryByTestId } = await renderMessageActions({
+ messageActionsProps: {
+ disableBaseMessageActionSetFilter: true,
+ messageActionSet: [],
+ },
+ });
+ expect(queryByTestId(MESSAGE_ACTIONS_HOST_TEST_ID)).not.toBeInTheDocument();
+ });
- it('should not return anything if message has no actions available', () => {
- const { queryByTestId } = renderMessageActions({
- getMessageActions: () => [],
+ it('should not render when message is not set', async () => {
+ const { queryByTestId } = await renderMessageActions({
+ customMessageContext: {
+ message: {},
+ },
+ });
+ expect(queryByTestId(MESSAGE_ACTIONS_HOST_TEST_ID)).not.toBeInTheDocument();
+ });
+
+ it.each([
+ ['type', 'error'],
+ ['type', 'system'],
+ ['type', 'ephemeral'],
+ ['status', 'failed'],
+ ['status', 'sending'],
+ ])('should not render when message is of %s %s', async (key, value) => {
+ const message = generateMessage({ [key]: value, user: alice });
+ const { queryByTestId } = await renderMessageActions({
+ customMessageContext: { message },
+ });
+ expect(queryByTestId(MESSAGE_ACTIONS_HOST_TEST_ID)).not.toBeInTheDocument();
+ });
+
+ it('should not render when message is parent message in a thread', async () => {
+ const { queryByTestId } = await renderMessageActions({
+ customMessageContext: {
+ initialMessage: true,
+ },
+ });
+ expect(queryByTestId(MESSAGE_ACTIONS_HOST_TEST_ID)).not.toBeInTheDocument();
+ });
+
+ it('should render correctly when not open', async () => {
+ const { container } = await renderMessageActions();
+ expect(container.querySelector('.str-chat__message-options')).toBeInTheDocument();
+ const button = screen.getByTestId(TOGGLE_ACTIONS_BUTTON_TEST_ID);
+ expect(button).toHaveAttribute('aria-expanded', 'false');
+ expect(button).toHaveAttribute('aria-haspopup', 'true');
});
- // eslint-disable-next-line jest-dom/prefer-in-document
- expect(queryByTestId(messageActionsTestId)).toBeNull();
- });
- it('should open message actions box on click', async () => {
- renderMessageActions();
- expect(MessageActionsBoxMock).not.toHaveBeenCalled();
- await act(async () => {
+ it('should apply active class when dropdown is open', async () => {
+ const { container } = await renderMessageActions();
+ const actionsHost = container.querySelector('.str-chat__message-options');
+ expect(actionsHost).not.toHaveClass('str-chat__message-options--active');
+
await toggleOpenMessageActions();
+
+ expect(actionsHost).toHaveClass('str-chat__message-options--active');
});
- expect(MessageActionsBoxMock).toHaveBeenLastCalledWith(
- expect.objectContaining({ open: true }),
- undefined,
- );
- const dialogOverlay = screen.getByTestId(dialogOverlayTestId);
- expect(dialogOverlay.children.length).toBeGreaterThan(0);
});
- it('should close message actions box on icon click if already opened', async () => {
- renderMessageActions();
- expect(MessageActionsBoxMock).not.toHaveBeenCalled();
- const dialogOverlay = screen.queryByTestId(dialogOverlayTestId);
- expect(dialogOverlay).not.toBeInTheDocument();
- await toggleOpenMessageActions();
- expect(MessageActionsBoxMock).toHaveBeenLastCalledWith(
- expect.objectContaining({ open: true }),
- undefined,
- );
- await toggleOpenMessageActions();
- await waitFor(() => {
- expect(dialogOverlay).not.toBeInTheDocument();
+ describe('Dropdown actions', () => {
+ it('should open dropdown when toggle button is clicked', async () => {
+ await renderMessageActions();
+ expect(screen.queryByTestId(dialogOverlayTestId)).not.toBeInTheDocument();
+
+ await toggleOpenMessageActions();
+
+ expect(screen.getByTestId(dialogOverlayTestId)).toBeInTheDocument();
+ expect(screen.getByTestId(TOGGLE_ACTIONS_BUTTON_TEST_ID)).toHaveAttribute(
+ 'aria-expanded',
+ 'true',
+ );
+ });
+
+ it('should close dropdown when toggle button is clicked again', async () => {
+ await renderMessageActions();
+ await toggleOpenMessageActions();
+ expect(screen.getByTestId(dialogOverlayTestId)).toBeInTheDocument();
+
+ await toggleOpenMessageActions();
+
+ await waitFor(() => {
+ expect(screen.queryByTestId(dialogOverlayTestId)).not.toBeInTheDocument();
+ });
});
- });
- it('should close message actions box when user clicks overlay if it is already opened', async () => {
- renderMessageActions();
- await toggleOpenMessageActions();
- expect(MessageActionsBoxMock).toHaveBeenLastCalledWith(
- expect.objectContaining({ open: true }),
- undefined,
- );
- const dialogOverlay = screen.queryByTestId(dialogOverlayTestId);
- await act(async () => {
- await fireEvent.click(dialogOverlay);
- });
- expect(MessageActionsBoxMock).toHaveBeenCalledTimes(1);
- expect(dialogOverlay).not.toBeInTheDocument();
+ it('should close dropdown when overlay is clicked', async () => {
+ await renderMessageActions();
+ await toggleOpenMessageActions();
+ const dialogOverlay = screen.getByTestId(dialogOverlayTestId);
+
+ await act(async () => {
+ await fireEvent.click(dialogOverlay);
+ });
+
+ expect(screen.queryByTestId(dialogOverlayTestId)).not.toBeInTheDocument();
+ });
+
+ it('should close dropdown when Escape key is pressed', async () => {
+ await renderMessageActions();
+ await toggleOpenMessageActions();
+ expect(screen.getByTestId(dialogOverlayTestId)).toBeInTheDocument();
+
+ await act(async () => {
+ await fireEvent.keyUp(document, { charCode: 27, code: 'Escape', key: 'Escape' });
+ });
+
+ expect(screen.queryByTestId(dialogOverlayTestId)).not.toBeInTheDocument();
+ });
});
- it('should close message actions box when user presses Escape key', async () => {
- renderMessageActions();
- await toggleOpenMessageActions();
- await act(async () => {
- await fireEvent.keyUp(document, { charCode: 27, code: 'Escape', key: 'Escape' });
+ describe('Dropdown action buttons', () => {
+ it('should render and call handleDelete when Delete button is clicked', async () => {
+ const handleDelete = jest.fn();
+ const message = generateMessage({ user: alice });
+ const { getByText } = await renderMessageActions({
+ channelStateOpts: {
+ channelCapabilities: { 'delete-own-message': true },
+ },
+ customMessageContext: { handleDelete, message },
+ });
+ await toggleOpenMessageActions();
+
+ await act(async () => {
+ await fireEvent.click(getByText('Delete'));
+ });
+
+ expect(handleDelete).toHaveBeenCalledTimes(1);
+ });
+
+ it('should render and call handleEdit when Edit Message button is clicked', async () => {
+ const handleEdit = jest.fn();
+ const message = generateMessage({ user: alice });
+ const { getByText } = await renderMessageActions({
+ channelStateOpts: {
+ channelCapabilities: { 'update-own-message': true },
+ },
+ customMessageContext: { handleEdit, message },
+ });
+ await toggleOpenMessageActions();
+
+ await act(async () => {
+ await fireEvent.click(getByText('Edit Message'));
+ });
+
+ expect(handleEdit).toHaveBeenCalledTimes(1);
+ });
+
+ it('should render and call handleFlag when Flag button is clicked', async () => {
+ const handleFlag = jest.fn();
+ const otherUser = generateUser();
+ const message = generateMessage({ user: otherUser });
+ const { getByText } = await renderMessageActions({
+ channelStateOpts: {
+ channelCapabilities: { 'flag-message': true },
+ },
+ customMessageContext: { handleFlag, message },
+ });
+ await toggleOpenMessageActions();
+
+ await act(async () => {
+ await fireEvent.click(getByText('Flag'));
+ });
+
+ expect(handleFlag).toHaveBeenCalledTimes(1);
+ });
+
+ it('should render and call handleMute when Mute button is clicked', async () => {
+ const handleMute = jest.fn();
+ const otherUser = generateUser();
+ const message = generateMessage({ user: otherUser });
+ const { getByText } = await renderMessageActions({
+ channelStateOpts: {
+ channelCapabilities: { 'mute-channel': true },
+ },
+ customMessageContext: { handleMute, message },
+ });
+ await toggleOpenMessageActions();
+
+ await act(async () => {
+ await fireEvent.click(getByText('Mute'));
+ });
+
+ expect(handleMute).toHaveBeenCalledTimes(1);
+ });
+
+ it('should show Unmute text when user is muted', async () => {
+ const otherUser = generateUser();
+ const message = generateMessage({ user: otherUser });
+ const handleMute = jest.fn();
+ const chatClient = await getTestClientWithUser(alice);
+ const mutes = [{ target: otherUser, user: alice }];
+
+ const { getByText } = await renderMessageActions({
+ channelStateOpts: {
+ channelCapabilities: { 'mute-channel': true },
+ },
+ chatClient,
+ customChatContext: { mutes },
+ customMessageContext: { handleMute, message },
+ });
+ await toggleOpenMessageActions();
+
+ expect(getByText('Unmute')).toBeInTheDocument();
+ });
+
+ it('should render and call handlePin when Pin button is clicked', async () => {
+ const handlePin = jest.fn();
+ const message = generateMessage({ pinned: false });
+ const { getByText } = await renderMessageActions({
+ channelStateOpts: {
+ channelCapabilities: { 'pin-message': true },
+ },
+ customMessageContext: { handlePin, message },
+ });
+ await toggleOpenMessageActions();
+
+ await act(async () => {
+ await fireEvent.click(getByText('Pin'));
+ });
+
+ expect(handlePin).toHaveBeenCalledTimes(1);
+ });
+
+ it('should show Unpin text when message is pinned', async () => {
+ const message = generateMessage({ pinned: true });
+ const { getByText } = await renderMessageActions({
+ channelStateOpts: {
+ channelCapabilities: { 'pin-message': true },
+ },
+ customMessageContext: { message },
+ });
+ await toggleOpenMessageActions();
+
+ expect(getByText('Unpin')).toBeInTheDocument();
+ });
+
+ it('should set quoted message when Quote button is clicked', async () => {
+ const {
+ channels: [channel],
+ client,
+ } = await initClientWithChannels();
+ const message = generateMessage({ user: client.user });
+ const setQuotedMessageSpy = jest.spyOn(channel.messageComposer, 'setQuotedMessage');
+
+ await act(async () => {
+ await render(
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ,
+ );
+ });
+
+ await toggleOpenMessageActions();
+
+ await act(async () => {
+ await fireEvent.click(screen.getByText('Quote'));
+ });
+
+ expect(setQuotedMessageSpy).toHaveBeenCalledWith(message);
});
- expect(MessageActionsBoxMock).toHaveBeenCalledTimes(1);
- const dialogOverlay = screen.queryByTestId(dialogOverlayTestId);
- expect(dialogOverlay).not.toBeInTheDocument();
});
- it('should render the message actions box correctly', async () => {
- renderMessageActions();
- await toggleOpenMessageActions();
- expect(MessageActionsBoxMock).toHaveBeenLastCalledWith(
- expect.objectContaining({
- getMessageActions: defaultProps.getMessageActions,
- handleDelete: defaultProps.handleDelete,
- handleEdit: expect.any(Function),
- handleFlag: defaultProps.handleFlag,
- handleMute: defaultProps.handleMute,
- handlePin: defaultProps.handlePin,
- isUserMuted: expect.any(Function),
- mine: false,
- open: true,
- }),
- undefined,
- );
+ describe('Quick actions', () => {
+ it('should display thread (reply) action when channel has replies enabled', async () => {
+ const { getByTestId } = await renderMessageActions({
+ channelStateOpts: {
+ channelCapabilities: { 'send-reply': true },
+ channelConfig: { replies: true },
+ },
+ });
+
+ expect(getByTestId(threadActionTestId)).toBeInTheDocument();
+ });
+
+ it('should not display thread action when channel does not have replies enabled', async () => {
+ const { queryByTestId } = await renderMessageActions({
+ channelStateOpts: {
+ channelConfig: { replies: false },
+ },
+ });
+
+ expect(queryByTestId(threadActionTestId)).not.toBeInTheDocument();
+ });
+
+ it('should not display thread action when message is in a thread', async () => {
+ const message = generateMessage({ parent_id: 'parent-123' });
+ const { queryByTestId } = await renderMessageActions({
+ channelStateOpts: {
+ channelCapabilities: { 'send-reply': true },
+ channelConfig: { replies: true },
+ },
+ customMessageContext: {
+ message,
+ },
+ });
+
+ expect(queryByTestId(threadActionTestId)).not.toBeInTheDocument();
+ });
+
+ it('should call handleOpenThread when Reply button is clicked', async () => {
+ const handleOpenThread = jest.fn();
+ const { getByTestId } = await renderMessageActions({
+ channelStateOpts: {
+ channelCapabilities: { 'send-reply': true },
+ channelConfig: { replies: true },
+ },
+ customMessageContext: {
+ handleOpenThread,
+ },
+ });
+
+ await act(async () => {
+ await fireEvent.click(getByTestId(threadActionTestId));
+ });
+
+ expect(handleOpenThread).toHaveBeenCalledTimes(1);
+ });
+
+ it('should display reaction action when channel has reactions enabled', async () => {
+ const { getByTestId } = await renderMessageActions({
+ channelStateOpts: {
+ channelCapabilities: { 'send-reaction': true },
+ },
+ });
+
+ expect(getByTestId(reactionActionTestId)).toBeInTheDocument();
+ });
+
+ it('should not display reaction action when channel has reactions disabled', async () => {
+ const { queryByTestId } = await renderMessageActions({
+ channelStateOpts: {
+ channelCapabilities: { 'send-reaction': false },
+ },
+ });
+
+ expect(queryByTestId(reactionActionTestId)).not.toBeInTheDocument();
+ });
});
- it('should not register click and keyup event listeners to close actions box until opened', async () => {
- renderMessageActions();
- const addEventListener = jest.spyOn(document, 'addEventListener');
- expect(document.addEventListener).not.toHaveBeenCalled();
- await toggleOpenMessageActions();
- expect(document.addEventListener).toHaveBeenCalledWith('keyup', expect.any(Function));
- addEventListener.mockClear();
+ describe('Reaction selector', () => {
+ it('should not render ReactionSelector until reaction button is clicked', async () => {
+ await renderMessageActions({
+ channelStateOpts: {
+ channelCapabilities: { 'send-reaction': true },
+ },
+ });
+
+ expect(screen.queryByTestId(reactionSelectorTestId)).not.toBeInTheDocument();
+
+ await act(async () => {
+ await fireEvent.click(screen.getByTestId(reactionActionTestId));
+ });
+
+ expect(screen.getByTestId(reactionSelectorTestId)).toBeInTheDocument();
+ });
+
+ it('should close ReactionSelector when dialog overlay is clicked', async () => {
+ await renderMessageActions({
+ channelStateOpts: {
+ channelCapabilities: { 'send-reaction': true },
+ },
+ });
+
+ await act(async () => {
+ await fireEvent.click(screen.getByTestId(reactionActionTestId));
+ });
+
+ expect(screen.getByTestId(reactionSelectorTestId)).toBeInTheDocument();
+
+ await act(async () => {
+ await fireEvent.click(screen.getByTestId(dialogOverlayTestId));
+ });
+
+ expect(screen.queryByTestId(reactionSelectorTestId)).not.toBeInTheDocument();
+ });
+
+ it('should close ReactionSelector when Escape key is pressed', async () => {
+ await renderMessageActions({
+ channelStateOpts: {
+ channelCapabilities: { 'send-reaction': true },
+ },
+ });
+
+ await act(async () => {
+ await fireEvent.click(screen.getByTestId(reactionActionTestId));
+ });
+
+ expect(screen.getByTestId(reactionSelectorTestId)).toBeInTheDocument();
+
+ await act(async () => {
+ await fireEvent.keyUp(document, { charCode: 27, code: 'Escape', key: 'Escape' });
+ });
+
+ expect(screen.queryByTestId(reactionSelectorTestId)).not.toBeInTheDocument();
+ });
+
+ it('should apply active class when ReactionSelector is open', async () => {
+ const { container } = await renderMessageActions({
+ channelStateOpts: {
+ channelCapabilities: { 'send-reaction': true },
+ },
+ });
+
+ const actionsHost = container.querySelector('.str-chat__message-options');
+ expect(actionsHost).not.toHaveClass('str-chat__message-options--active');
+
+ await act(async () => {
+ await fireEvent.click(screen.getByTestId(reactionActionTestId));
+ });
+
+ expect(actionsHost).toHaveClass('str-chat__message-options--active');
+ });
});
- it('should remove keyup event listener when unmounted if actions box not opened', async () => {
- const { unmount } = renderMessageActions();
- const removeEventListener = jest.spyOn(document, 'removeEventListener');
- expect(document.removeEventListener).not.toHaveBeenCalled();
- await toggleOpenMessageActions();
- unmount();
- expect(document.removeEventListener).toHaveBeenCalledWith(
- 'keyup',
- expect.any(Function),
- );
- removeEventListener.mockClear();
+ describe('Mark as unread action', () => {
+ const ACTION_TEXT = 'Mark as unread';
+ const me = generateUser();
+ const otherUser = generateUser();
+ const message = generateMessage({ user: otherUser });
+ const lastReceivedId = message.id;
+ const read = [
+ {
+ last_read: new Date(),
+ last_read_message_id: message.id,
+ unread_messages: 0,
+ user: me,
+ },
+ ];
+ const own_capabilities = [
+ 'ban-channel-members',
+ 'connect-events',
+ 'delete-any-message',
+ 'delete-own-message',
+ 'flag-message',
+ 'mute-channel',
+ 'pin-message',
+ 'quote-message',
+ 'read-events',
+ 'send-message',
+ 'send-reaction',
+ 'send-reply',
+ 'update-any-message',
+ 'update-own-message',
+ ];
+
+ const renderMarkUnreadUI = async ({ channelProps, chatProps, messageProps }) =>
+ await act(async () => {
+ await render(
+
+
+
+
+
+
+ ,
+ );
+ });
+
+ afterEach(jest.restoreAllMocks);
+
+ it('should not be displayed without "read-events" capability', async () => {
+ const {
+ channels: [channel],
+ client,
+ } = await initClientWithChannels({
+ channelsData: [
+ {
+ channel: {
+ own_capabilities: own_capabilities.filter((c) => c !== 'read-events'),
+ },
+ messages: [message],
+ read,
+ },
+ ],
+ customUser: me,
+ });
+
+ await renderMarkUnreadUI({
+ channelProps: { channel },
+ chatProps: { client },
+ messageProps: { message },
+ });
+ await toggleOpenMessageActions();
+
+ expect(screen.queryByText(ACTION_TEXT)).not.toBeInTheDocument();
+ });
+
+ it('should be displayed for own messages', async () => {
+ const myMessage = { ...message, user: me };
+ const {
+ channels: [channel],
+ client,
+ } = await initClientWithChannels({
+ channelsData: [
+ {
+ channel: { own_capabilities },
+ messages: [myMessage],
+ read,
+ },
+ ],
+ customUser: me,
+ });
+
+ await renderMarkUnreadUI({
+ channelProps: { channel },
+ chatProps: { client },
+ messageProps: { message: myMessage },
+ });
+ await toggleOpenMessageActions();
+
+ expect(screen.queryByText(ACTION_TEXT)).toBeInTheDocument();
+ });
+
+ it('should not be displayed for thread messages', async () => {
+ const threadMessage = generateMessage({ parent_id: 'parent-123', user: otherUser });
+ const {
+ channels: [channel],
+ client,
+ } = await initClientWithChannels({
+ channelsData: [
+ { channel: { own_capabilities }, messages: [threadMessage], read },
+ ],
+ customUser: me,
+ });
+
+ await renderMarkUnreadUI({
+ channelProps: { channel },
+ chatProps: { client },
+ messageProps: { message: threadMessage, threadList: true },
+ });
+ await toggleOpenMessageActions();
+
+ expect(screen.queryByText(ACTION_TEXT)).not.toBeInTheDocument();
+ });
+
+ it('should call channel.markUnread when Mark as unread button is clicked', async () => {
+ const {
+ channels: [channel],
+ client,
+ } = await initClientWithChannels({
+ channelsData: [{ channel: { own_capabilities }, messages: [message], read }],
+ customUser: me,
+ });
+ jest.spyOn(channel, 'markUnread');
+
+ await renderMarkUnreadUI({
+ channelProps: { channel },
+ chatProps: { client },
+ messageProps: { message },
+ });
+ await toggleOpenMessageActions();
+
+ await act(async () => {
+ await fireEvent.click(screen.getByText(ACTION_TEXT));
+ });
+
+ expect(channel.markUnread).toHaveBeenCalledWith(
+ expect.objectContaining({ message_id: message.id }),
+ );
+ });
+
+ it('should call custom success notification on successful mark unread', async () => {
+ const getMarkMessageUnreadSuccessNotification = jest.fn();
+ const {
+ channels: [channel],
+ client,
+ } = await initClientWithChannels({
+ channelsData: [{ channel: { own_capabilities }, messages: [message], read }],
+ customUser: me,
+ });
+
+ await renderMarkUnreadUI({
+ channelProps: { channel },
+ chatProps: { client },
+ messageProps: { getMarkMessageUnreadSuccessNotification, message },
+ });
+ await toggleOpenMessageActions();
+
+ await act(async () => {
+ await fireEvent.click(screen.getByText(ACTION_TEXT));
+ });
+
+ expect(getMarkMessageUnreadSuccessNotification).toHaveBeenCalledWith(
+ expect.objectContaining(message),
+ );
+ });
+
+ it('should call custom error notification on failed mark unread', async () => {
+ const getMarkMessageUnreadErrorNotification = jest.fn();
+ const {
+ channels: [channel],
+ client,
+ } = await initClientWithChannels({
+ channelsData: [{ channel: { own_capabilities }, messages: [message], read }],
+ customUser: me,
+ });
+ jest.spyOn(channel, 'markUnread').mockRejectedValueOnce();
+
+ await renderMarkUnreadUI({
+ channelProps: { channel },
+ chatProps: { client },
+ messageProps: { getMarkMessageUnreadErrorNotification, message },
+ });
+ await toggleOpenMessageActions();
+
+ await act(async () => {
+ await fireEvent.click(screen.getByText(ACTION_TEXT));
+ });
+
+ expect(getMarkMessageUnreadErrorNotification).toHaveBeenCalledWith(
+ expect.objectContaining(message),
+ );
+ });
});
- it('should render with a custom wrapper class when one is set', () => {
- const { container } = renderMessageActions({
- customWrapperClass: 'custom-wrapper-class',
- });
- expect(container).toMatchInlineSnapshot(`
-
- `);
+ describe('Custom message action sets', () => {
+ it('should render custom action components', async () => {
+ const CustomAction = () => ;
+ const customMessageActionSet = [
+ {
+ Component: CustomAction,
+ placement: 'dropdown',
+ type: 'custom',
+ },
+ ];
+
+ await renderMessageActions({
+ messageActionsProps: {
+ disableBaseMessageActionSetFilter: true,
+ messageActionSet: customMessageActionSet,
+ },
+ });
+ await toggleOpenMessageActions();
+
+ expect(screen.getByTestId('custom-action')).toBeInTheDocument();
+ });
+
+ it('should support custom quick actions', async () => {
+ const CustomQuickAction = () => (
+
+ );
+ const customMessageActionSet = [
+ {
+ Component: CustomQuickAction,
+ placement: 'quick',
+ type: 'customQuick',
+ },
+ ];
+
+ await renderMessageActions({
+ messageActionsProps: {
+ disableBaseMessageActionSetFilter: true,
+ messageActionSet: customMessageActionSet,
+ },
+ });
+
+ expect(screen.getByTestId('custom-quick-action')).toBeInTheDocument();
+ });
+
+ it('should allow disabling base filter', async () => {
+ const message = generateMessage({ status: 'failed' });
+ const { queryByTestId } = await renderMessageActions({
+ customMessageContext: { message },
+ messageActionsProps: {
+ disableBaseMessageActionSetFilter: true,
+ },
+ });
+
+ // Should render even for failed messages when filter is disabled
+ expect(queryByTestId(MESSAGE_ACTIONS_HOST_TEST_ID)).toBeInTheDocument();
+ });
});
- it('should render with an inline element wrapper when inline set', () => {
- const { container } = renderMessageActions({
- inline: true,
- });
- expect(container).toMatchInlineSnapshot(`
-
- `);
+ describe('Accessibility', () => {
+ it('should have no accessibility violations when closed', async () => {
+ const { container } = await renderMessageActions();
+ const results = await axe(container);
+ expect(results).toHaveNoViolations();
+ });
+
+ it('should have no accessibility violations when dropdown is open', async () => {
+ const { container } = await renderMessageActions();
+ await toggleOpenMessageActions();
+ const results = await axe(container);
+ expect(results).toHaveNoViolations();
+ });
+
+ it('should have proper aria attributes', async () => {
+ await renderMessageActions();
+ const button = screen.getByTestId(TOGGLE_ACTIONS_BUTTON_TEST_ID);
+
+ expect(button).toHaveAttribute('aria-expanded', 'false');
+ expect(button).toHaveAttribute('aria-haspopup', 'true');
+ expect(button).toHaveAttribute('aria-label', 'Open Message Actions Menu');
+
+ await toggleOpenMessageActions();
+
+ expect(button).toHaveAttribute('aria-expanded', 'true');
+ });
+
+ it('should have role=listbox for dropdown actions list', async () => {
+ await renderMessageActions();
+ await toggleOpenMessageActions();
+
+ const actionsList = screen.getByRole('listbox', { name: 'Message Options' });
+ expect(actionsList).toBeInTheDocument();
+ });
});
});
diff --git a/src/components/MessageActions/__tests__/MessageActionsBox.test.js b/src/components/MessageActions/__tests__/MessageActionsBox.test.js
deleted file mode 100644
index 427cb297ff..0000000000
--- a/src/components/MessageActions/__tests__/MessageActionsBox.test.js
+++ /dev/null
@@ -1,570 +0,0 @@
-import React from 'react';
-import { act, fireEvent, render, screen } from '@testing-library/react';
-import '@testing-library/jest-dom';
-import { toHaveNoViolations } from 'jest-axe';
-import { axe } from '../../../../axe-helper';
-import { MessageActionsBox } from '../MessageActionsBox';
-
-import { ChannelActionProvider } from '../../../context/ChannelActionContext';
-import { MessageProvider } from '../../../context/MessageContext';
-import { TranslationProvider } from '../../../context/TranslationContext';
-
-import {
- dispatchNotificationMarkUnread,
- generateMessage,
- generateUser,
- initClientWithChannels,
-} from '../../../mock-builders';
-import { Message } from '../../Message';
-import { Channel } from '../../Channel';
-import { Chat } from '../../Chat';
-import {
- ChannelStateProvider,
- ChatProvider,
- ComponentProvider,
- DialogManagerProvider,
-} from '../../../context';
-
-expect.extend(toHaveNoViolations);
-
-const getMessageActionsMock = jest.fn(() => []);
-
-const defaultMessageContextValue = {
- message: generateMessage(),
- messageListRect: {},
-};
-
-const TOGGLE_ACTIONS_BUTTON_TEST_ID = 'message-actions-toggle-button';
-const toggleOpenMessageActions = async (i = 0) => {
- await act(async () => {
- await fireEvent.click(screen.getAllByTestId(TOGGLE_ACTIONS_BUTTON_TEST_ID)[i]);
- });
-};
-
-async function renderComponent(boxProps = {}, messageContext = {}) {
- const {
- channels: [channel],
- client,
- } = await initClientWithChannels();
- return {
- channel,
- result: render(
-
- key }}>
-
-
-
-
-
-
-
-
-
-
-
-
- ,
- ),
- };
-}
-
-describe('MessageActionsBox', () => {
- afterEach(jest.clearAllMocks);
-
- it('should not show any of the action buttons if no actions are returned by getMessageActions', async () => {
- const {
- result: { container, queryByText },
- } = await renderComponent({ message: generateMessage() });
- expect(queryByText('Flag')).not.toBeInTheDocument();
- expect(queryByText('Mute')).not.toBeInTheDocument();
- expect(queryByText('Unmute')).not.toBeInTheDocument();
- expect(queryByText('Edit Message')).not.toBeInTheDocument();
- expect(queryByText('Delete')).not.toBeInTheDocument();
- expect(queryByText('Pin')).not.toBeInTheDocument();
- expect(queryByText('Unpin')).not.toBeInTheDocument();
- const results = await axe(container);
- expect(results).toHaveNoViolations();
- });
-
- it('should call the handleFlag prop if the flag button is clicked', async () => {
- getMessageActionsMock.mockImplementationOnce(() => ['flag']);
- const handleFlag = jest.fn();
- const {
- result: { container, getByText },
- } = await renderComponent({ handleFlag, message: generateMessage() });
- await act(async () => {
- await fireEvent.click(getByText('Flag'));
- });
- expect(handleFlag).toHaveBeenCalledTimes(1);
- const results = await axe(container);
- expect(results).toHaveNoViolations();
- });
-
- it('should call the handleMute prop if the mute button is clicked', async () => {
- getMessageActionsMock.mockImplementationOnce(() => ['mute']);
- const handleMute = jest.fn();
- const {
- result: { container, getByText },
- } = await renderComponent({
- handleMute,
- isUserMuted: () => false,
- message: generateMessage(),
- });
- await act(async () => {
- await fireEvent.click(getByText('Mute'));
- });
- expect(handleMute).toHaveBeenCalledTimes(1);
- const results = await axe(container);
- expect(results).toHaveNoViolations();
- });
-
- it('should call the handleMute prop if the unmute button is clicked', async () => {
- getMessageActionsMock.mockImplementationOnce(() => ['mute']);
- const handleMute = jest.fn();
- const {
- result: { container, getByText },
- } = await renderComponent({
- handleMute,
- isUserMuted: () => true,
- message: generateMessage(),
- });
- await act(async () => {
- await fireEvent.click(getByText('Unmute'));
- });
- expect(handleMute).toHaveBeenCalledTimes(1);
- const results = await axe(container);
- expect(results).toHaveNoViolations();
- });
-
- it('should call the handleEdit prop if the edit button is clicked', async () => {
- getMessageActionsMock.mockImplementationOnce(() => ['edit']);
- const handleEdit = jest.fn();
- const {
- result: { container, getByText },
- } = await renderComponent({ handleEdit, message: generateMessage() });
- await act(async () => {
- await fireEvent.click(getByText('Edit Message'));
- });
- expect(handleEdit).toHaveBeenCalledTimes(1);
- const results = await axe(container);
- expect(results).toHaveNoViolations();
- });
-
- it('should call the handleDelete prop if the delete button is clicked', async () => {
- getMessageActionsMock.mockImplementationOnce(() => ['delete']);
- const handleDelete = jest.fn();
- const {
- result: { container, getByText },
- } = await renderComponent({ handleDelete, message: generateMessage() });
- await act(async () => {
- await fireEvent.click(getByText('Delete'));
- });
- expect(handleDelete).toHaveBeenCalledTimes(1);
- const results = await axe(container);
- expect(results).toHaveNoViolations();
- });
-
- it('should call the handleDelete prop if the deleteForMe button is clicked', async () => {
- getMessageActionsMock.mockImplementationOnce(() => ['deleteForMe']);
- const handleDelete = jest.fn();
- const {
- result: { container, getByText },
- } = await renderComponent({ handleDelete, message: generateMessage() });
- await act(async () => {
- await fireEvent.click(getByText('Delete for me'));
- });
- expect(handleDelete).toHaveBeenCalledTimes(1);
- const results = await axe(container);
- expect(results).toHaveNoViolations();
- });
-
- it('should call the handlePin prop if the pin button is clicked', async () => {
- getMessageActionsMock.mockImplementationOnce(() => ['pin']);
- const handlePin = jest.fn();
- const message = generateMessage({ message: generateMessage(), pinned: false });
- const {
- result: { container, getByText },
- } = await renderComponent({ handlePin, message });
- await act(async () => {
- await fireEvent.click(getByText('Pin'));
- });
- expect(handlePin).toHaveBeenCalledTimes(1);
- const results = await axe(container);
- expect(results).toHaveNoViolations();
- });
-
- it('should call the handlePin prop if the unpin button is clicked', async () => {
- getMessageActionsMock.mockImplementationOnce(() => ['pin']);
- const handlePin = jest.fn();
- const message = generateMessage({ message: generateMessage(), pinned: true });
- const {
- result: { container, getByText },
- } = await renderComponent({ handlePin, message });
- await act(async () => {
- await fireEvent.click(getByText('Unpin'));
- });
- expect(handlePin).toHaveBeenCalledTimes(1);
- const results = await axe(container);
- expect(results).toHaveNoViolations();
- });
-
- it('should call use MessageComposer to set quoted message', async () => {
- getMessageActionsMock.mockImplementationOnce(() => ['quote']);
- const {
- channel,
- result: { container, getByText },
- } = await renderComponent({ message: generateMessage() });
- const setQuotedMessageSpy = jest.spyOn(channel.messageComposer, 'setQuotedMessage');
- await act(async () => {
- await fireEvent.click(getByText('Reply'));
- });
- expect(setQuotedMessageSpy).toHaveBeenCalledTimes(1);
- const results = await axe(container);
- expect(results).toHaveNoViolations();
- });
-
- describe('mark message unread', () => {
- afterEach(jest.restoreAllMocks);
- const ACTION_TEXT = 'Mark as unread';
- const me = generateUser();
- const otherUser = generateUser();
- const message = generateMessage({ user: otherUser });
- const lastReceivedId = message.id;
- const read = [
- {
- last_read: new Date(),
- last_read_message_id: message.id, // optional
- unread_messages: 0,
- user: me,
- },
- ];
- const own_capabilities = [
- 'ban-channel-members',
- 'connect-events',
- 'create-call',
- 'delete-any-message',
- 'delete-channel',
- 'delete-own-message',
- 'flag-message',
- 'freeze-channel',
- 'join-call',
- 'join-channel',
- 'leave-channel',
- 'mute-channel',
- 'pin-message',
- 'quote-message',
- 'read-events',
- 'search-messages',
- 'send-custom-events',
- 'send-links',
- 'send-message',
- 'send-reaction',
- 'send-reply',
- 'send-typing-events',
- 'set-channel-cooldown',
- 'skip-slow-mode',
- 'typing-events',
- 'update-any-message',
- 'update-channel',
- 'update-channel-members',
- 'update-own-message',
- 'upload-file',
- ];
- const renderMarkUnreadUI = async ({ channelProps, chatProps, messageProps }) =>
- await act(async () => {
- await render(
-
-
-
-
-
-
- ,
- );
- });
-
- it('should not be displayed as an option in channels without "read-events" capability', async () => {
- const {
- channels: [channel],
- client,
- } = await initClientWithChannels({
- channelsData: [
- {
- channel: {
- own_capabilities: own_capabilities.filter((c) => c !== 'read-events'),
- },
- messages: [message],
- read,
- },
- ],
- customUser: me,
- });
-
- await renderMarkUnreadUI({
- channelProps: { channel },
- chatProps: { client },
- messageProps: { message },
- });
- await toggleOpenMessageActions();
- expect(screen.queryByText(ACTION_TEXT)).not.toBeInTheDocument();
- });
-
- it('should be displayed as an option for own messages', async () => {
- const myMessage = { ...message, user: me };
- const {
- channels: [channel],
- client,
- } = await initClientWithChannels({
- channelsData: [
- {
- channel: { own_capabilities },
- messages: [myMessage],
- read,
- },
- ],
- customUser: me,
- });
-
- await renderMarkUnreadUI({
- channelProps: { channel },
- chatProps: { client },
- messageProps: { message: myMessage },
- });
- await toggleOpenMessageActions();
- expect(screen.queryByText(ACTION_TEXT)).toBeInTheDocument();
- });
-
- it('should not be displayed as an option for thread messages', async () => {
- const {
- channels: [channel],
- client,
- } = await initClientWithChannels({
- channelsData: [{ channel: { own_capabilities }, messages: [message], read }],
- customUser: me,
- });
-
- await renderMarkUnreadUI({
- channelProps: { channel },
- chatProps: { client },
- messageProps: { message, threadList: true },
- });
- await toggleOpenMessageActions();
- expect(screen.queryByText(ACTION_TEXT)).not.toBeInTheDocument();
- });
-
- it('should be displayed as an option for message already marked unread', async () => {
- const {
- channels: [channel],
- client,
- } = await initClientWithChannels({
- channelsData: [{ channel: { own_capabilities }, messages: [message], read }],
- customUser: me,
- });
-
- await renderMarkUnreadUI({
- channelProps: { channel },
- chatProps: { client },
- messageProps: { message },
- });
-
- await act(() => {
- dispatchNotificationMarkUnread({
- channel,
- client,
- payload: {
- first_unread_message_id: message.id,
- last_read: new Date(new Date(message.created_at).getTime() - 1000),
- last_read_message_id: new Date().toISOString(), // any other message id always unique
- unread_messages: 1,
- user: client.user,
- },
- });
- });
-
- await toggleOpenMessageActions();
- expect(screen.queryByText(ACTION_TEXT)).toBeInTheDocument();
- });
-
- it('should not be displayed as an option for message without id', async () => {
- jest.spyOn(console, 'warn').mockImplementationOnce(() => null);
- const messageWithoutID = { ...message, id: undefined };
- const {
- channels: [channel],
- client,
- } = await initClientWithChannels({
- channelsData: [
- {
- channel: { own_capabilities },
- messages: [messageWithoutID],
- read,
- },
- ],
- customUser: me,
- });
- jest.spyOn(channel, 'markUnread');
-
- await renderMarkUnreadUI({
- channelProps: { channel },
- chatProps: { client },
- messageProps: { message: messageWithoutID },
- });
- await toggleOpenMessageActions();
- expect(screen.queryByText(ACTION_TEXT)).not.toBeInTheDocument();
- });
-
- it('should be displayed as an option for messages not marked and marked unread', async () => {
- const otherMsg = generateMessage({
- created_at: new Date(new Date(message.created_at).getTime() + 2000),
- });
- const read = [
- {
- first_unread_message_id: otherMsg.id,
- last_read: new Date(new Date(otherMsg.created_at).getTime() - 1000),
- // last_read_message_id: message.id, // optional
- unread_messages: 2,
- user: me,
- },
- ];
- const {
- channels: [channel],
- client,
- } = await initClientWithChannels({
- channelsData: [
- {
- channel: { own_capabilities },
- messages: [message, otherMsg],
- read,
- },
- ],
- customUser: me,
- });
-
- await act(async () => {
- await render(
-
-
-
-
-
-
-
- ,
- );
- });
- await toggleOpenMessageActions(0);
- let boxes = screen.getAllByTestId('message-actions-box');
-
- expect(boxes).toHaveLength(1);
- expect(boxes[0]).toHaveTextContent(ACTION_TEXT);
-
- await toggleOpenMessageActions(1);
- boxes = screen.getAllByTestId('message-actions-box');
-
- expect(boxes).toHaveLength(1);
- expect(boxes[0]).toHaveTextContent(ACTION_TEXT);
- });
-
- it('should be displayed and execute API request', async () => {
- const {
- channels: [channel],
- client,
- } = await initClientWithChannels({
- channelsData: [{ channel: { own_capabilities }, messages: [message], read }],
- customUser: me,
- });
- jest.spyOn(channel, 'markUnread');
-
- await renderMarkUnreadUI({
- channelProps: { channel },
- chatProps: { client },
- messageProps: { message },
- });
- await toggleOpenMessageActions();
-
- await act(async () => {
- await fireEvent.click(screen.getByText(ACTION_TEXT));
- });
- expect(channel.markUnread).toHaveBeenCalledWith(
- expect.objectContaining({ message_id: message.id }),
- );
- });
-
- it('should allow mark message unread and notify with custom success notification', async () => {
- const getMarkMessageUnreadSuccessNotification = jest.fn();
- const {
- channels: [channel],
- client,
- } = await initClientWithChannels({
- channelsData: [{ channel: { own_capabilities }, messages: [message], read }],
- customUser: me,
- });
- jest.spyOn(channel, 'markUnread');
-
- await renderMarkUnreadUI({
- channelProps: { channel },
- chatProps: { client },
- messageProps: { getMarkMessageUnreadSuccessNotification, message },
- });
- await toggleOpenMessageActions();
- await act(async () => {
- await fireEvent.click(screen.getByText(ACTION_TEXT));
- });
- expect(getMarkMessageUnreadSuccessNotification).toHaveBeenCalledWith(
- expect.objectContaining(message),
- );
- });
-
- it('should allow mark message unread and notify with custom error notification', async () => {
- const getMarkMessageUnreadErrorNotification = jest.fn();
- const {
- channels: [channel],
- client,
- } = await initClientWithChannels({
- channelsData: [{ channel: { own_capabilities }, messages: [message], read }],
- customUser: me,
- });
- jest.spyOn(channel, 'markUnread').mockRejectedValueOnce();
-
- await renderMarkUnreadUI({
- channelProps: { channel },
- chatProps: { client },
- messageProps: { getMarkMessageUnreadErrorNotification, message },
- });
- await toggleOpenMessageActions();
- await act(async () => {
- await fireEvent.click(screen.getByText(ACTION_TEXT));
- });
- expect(getMarkMessageUnreadErrorNotification).toHaveBeenCalledWith(
- expect.objectContaining(message),
- );
- });
- });
-});
diff --git a/src/experimental/MessageActions/defaults.tsx b/src/components/MessageActions/defaults.tsx
similarity index 100%
rename from src/experimental/MessageActions/defaults.tsx
rename to src/components/MessageActions/defaults.tsx
diff --git a/src/experimental/MessageActions/hooks/index.ts b/src/components/MessageActions/hooks/index.ts
similarity index 100%
rename from src/experimental/MessageActions/hooks/index.ts
rename to src/components/MessageActions/hooks/index.ts
diff --git a/src/experimental/MessageActions/hooks/useBaseMessageActionSetFilter.ts b/src/components/MessageActions/hooks/useBaseMessageActionSetFilter.ts
similarity index 95%
rename from src/experimental/MessageActions/hooks/useBaseMessageActionSetFilter.ts
rename to src/components/MessageActions/hooks/useBaseMessageActionSetFilter.ts
index eb1387852b..96d12dc311 100644
--- a/src/experimental/MessageActions/hooks/useBaseMessageActionSetFilter.ts
+++ b/src/components/MessageActions/hooks/useBaseMessageActionSetFilter.ts
@@ -1,7 +1,8 @@
import { useMemo } from 'react';
-import { ACTIONS_NOT_WORKING_IN_THREAD, useUserRole } from '../../../components';
import { useChannelStateContext, useMessageContext } from '../../../context';
+import { useUserRole } from '../../Message/hooks';
+import { ACTIONS_NOT_WORKING_IN_THREAD } from '../../Message/utils';
import type { MessageActionSetItem } from '../MessageActions';
diff --git a/src/experimental/MessageActions/hooks/useSplitMessageActionSet.ts b/src/components/MessageActions/hooks/useSplitMessageActionSet.ts
similarity index 100%
rename from src/experimental/MessageActions/hooks/useSplitMessageActionSet.ts
rename to src/components/MessageActions/hooks/useSplitMessageActionSet.ts
diff --git a/src/components/MessageActions/index.ts b/src/components/MessageActions/index.ts
index 1790160d9b..1e0b5c79f0 100644
--- a/src/components/MessageActions/index.ts
+++ b/src/components/MessageActions/index.ts
@@ -1,3 +1,3 @@
export * from './MessageActions';
-export * from './MessageActionsBox';
-export * from './CustomMessageActionsList';
+export * from './defaults';
+export * from './hooks';
diff --git a/src/components/MessageInput/__tests__/MessageInput.test.js b/src/components/MessageInput/__tests__/MessageInput.test.js
index 0ce72535ec..21c4123012 100644
--- a/src/components/MessageInput/__tests__/MessageInput.test.js
+++ b/src/components/MessageInput/__tests__/MessageInput.test.js
@@ -8,9 +8,9 @@ import { nanoid } from 'nanoid';
import { MessageInput } from '../MessageInput';
import { Channel } from '../../Channel/Channel';
-import { MessageActionsBox } from '../../MessageActions';
+import { MessageActions } from '../../MessageActions';
-import { MessageProvider } from '../../../context/MessageContext';
+import { DialogManagerProvider, MessageProvider } from '../../../context';
import { ChatProvider } from '../../../context/ChatContext';
import {
dispatchMessageDeletedEvent,
@@ -92,6 +92,7 @@ const defaultMessageContextValue = {
handleDelete: () => {},
handleFlag: () => {},
handleMute: () => {},
+ handleOpenThread: () => {},
handlePin: () => {},
isMyMessage: () => true,
message: mainListMessage,
@@ -110,7 +111,14 @@ function dropFile(file, formElement) {
const initQuotedMessagePreview = async (message) => {
await waitFor(() => expect(screen.queryByText(message.text)).not.toBeInTheDocument());
- const quoteButton = await screen.findByText(/^reply$/i);
+ // Open the message actions dropdown
+ const actionsButton = await screen.findByTestId('message-actions-toggle-button');
+ await act(() => {
+ fireEvent.click(actionsButton);
+ });
+
+ // Click the Quote button in the dropdown
+ const quoteButton = await screen.findByText(/^quote$/i);
await waitFor(() => expect(quoteButton).toBeInTheDocument());
act(() => {
@@ -137,7 +145,7 @@ const renderComponent = async ({
customChannel,
customClient,
customUser,
- messageActionsBoxProps = {},
+ messageActionsProps = {},
messageContextOverrides = {},
messageInputProps = {},
} = {}) => {
@@ -158,17 +166,19 @@ const renderComponent = async ({
-
-
-
-
-
-
+
+
+
+
+
+
+
+
,
);
});
diff --git a/src/components/MessageInput/__tests__/ThreadMessageInput.test.js b/src/components/MessageInput/__tests__/ThreadMessageInput.test.js
index 663d892e5c..294336ea56 100644
--- a/src/components/MessageInput/__tests__/ThreadMessageInput.test.js
+++ b/src/components/MessageInput/__tests__/ThreadMessageInput.test.js
@@ -7,9 +7,8 @@ import {
initClientWithChannels,
} from '../../../mock-builders';
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
-import { ChatProvider, MessageProvider, useChannelActionContext } from '../../../context';
+import { ChatProvider, useChannelActionContext } from '../../../context';
import { Channel } from '../../Channel';
-import { MessageActionsBox } from '../../MessageActions';
import React, { useEffect, useRef } from 'react';
import { SearchController } from 'stream-chat';
import { MessageInput } from '../MessageInput';
@@ -53,17 +52,6 @@ const defaultChatContext = {
searchController: new SearchController(),
};
-const defaultMessageContextValue = {
- getMessageActions: () => ['delete', 'edit', 'quote'],
- handleDelete: () => {},
- handleFlag: () => {},
- handleMute: () => {},
- handlePin: () => {},
- isMyMessage: () => true,
- message: mainListMessage,
- setEditingState: () => {},
-};
-
const setup = async ({ channelData } = {}) => {
const {
channels: [customChannel],
@@ -103,8 +91,6 @@ const renderComponent = async ({
customChannel,
customClient,
customUser,
- messageActionsBoxProps = {},
- messageContextOverrides = {},
messageInputProps = {},
thread,
} = {}) => {
@@ -127,14 +113,6 @@ const renderComponent = async ({
>
-
-
-
diff --git a/src/components/MessageList/MessageList.tsx b/src/components/MessageList/MessageList.tsx
index a6576d9e0b..0f0b996ee0 100644
--- a/src/components/MessageList/MessageList.tsx
+++ b/src/components/MessageList/MessageList.tsx
@@ -152,7 +152,6 @@ const MessageListWithContext = (props: MessageListWithContextProps) => {
internalMessageProps: {
additionalMessageInputProps: props.additionalMessageInputProps,
closeReactionSelectorOnClick: props.closeReactionSelectorOnClick,
- customMessageActions: props.customMessageActions,
disableQuotedMessages: props.disableQuotedMessages,
formatDate: props.formatDate,
getDeleteMessageErrorNotification: props.getDeleteMessageErrorNotification,
@@ -298,7 +297,6 @@ const MessageListWithContext = (props: MessageListWithContextProps) => {
type PropsDrilledToMessage =
| 'additionalMessageInputProps'
| 'closeReactionSelectorOnClick'
- | 'customMessageActions'
| 'disableQuotedMessages'
| 'formatDate'
| 'getDeleteMessageErrorNotification'
diff --git a/src/components/MessageList/VirtualizedMessageList.tsx b/src/components/MessageList/VirtualizedMessageList.tsx
index 3c4d6bc189..679087ae46 100644
--- a/src/components/MessageList/VirtualizedMessageList.tsx
+++ b/src/components/MessageList/VirtualizedMessageList.tsx
@@ -72,7 +72,6 @@ import { useLastOwnMessage } from './hooks/useLastOwnMessage';
type PropsDrilledToMessage =
| 'additionalMessageInputProps'
- | 'customMessageActions'
| 'formatDate'
| 'messageActions'
| 'openThread'
@@ -190,7 +189,6 @@ const VirtualizedMessageListWithContext = (
channel,
channelUnreadUiState,
closeReactionSelectorOnClick,
- customMessageActions,
customMessageRenderer,
defaultItemHeight,
disableDateSeparator = true,
@@ -492,7 +490,6 @@ const VirtualizedMessageListWithContext = (
additionalMessageInputProps,
closeReactionSelectorOnClick,
customClasses,
- customMessageActions,
customMessageRenderer,
DateSeparator,
firstUnreadMessageId: channelUnreadUiState?.first_unread_message_id,
diff --git a/src/components/MessageList/VirtualizedMessageListComponents.tsx b/src/components/MessageList/VirtualizedMessageListComponents.tsx
index 7dcd433ea9..efa948f17d 100644
--- a/src/components/MessageList/VirtualizedMessageListComponents.tsx
+++ b/src/components/MessageList/VirtualizedMessageListComponents.tsx
@@ -111,7 +111,6 @@ export const messageRenderer = (
const {
additionalMessageInputProps,
closeReactionSelectorOnClick,
- customMessageActions,
customMessageRenderer,
DateSeparator,
firstUnreadMessageId,
@@ -203,7 +202,6 @@ export const messageRenderer = (
additionalMessageInputProps={additionalMessageInputProps}
autoscrollToBottom={virtuosoRef.current?.autoscrollToBottom}
closeReactionSelectorOnClick={closeReactionSelectorOnClick}
- customMessageActions={customMessageActions}
deliveredTo={ownMessagesDeliveredToOthers[message.id] || []}
endOfGroup={endOfGroup}
firstOfGroup={firstOfGroup}
diff --git a/src/context/ComponentContext.tsx b/src/context/ComponentContext.tsx
index 40f9a7845b..7f75729b48 100644
--- a/src/context/ComponentContext.tsx
+++ b/src/context/ComponentContext.tsx
@@ -8,7 +8,6 @@ import type {
BaseImageProps,
ChannelPreviewActionButtonsProps,
CooldownTimerProps,
- CustomMessageActionsListProps,
DateSeparatorProps,
EditMessageModalProps,
EmojiSearchIndex,
@@ -22,7 +21,6 @@ import type {
MessageInputProps,
MessageListNotificationsProps,
MessageNotificationProps,
- MessageOptionsProps,
MessageProps,
MessageRepliesCountButtonProps,
MessageStatusProps,
@@ -91,8 +89,6 @@ export type ComponentContextValue = {
ChannelPreviewActionButtons?: React.ComponentType;
/** Custom UI component to display the slow mode cooldown timer, defaults to and accepts same props as: [CooldownTimer](https://github.com/GetStream/stream-chat-react/blob/master/src/components/MessageInput/CooldownTimer.tsx) */
CooldownTimer?: React.ComponentType;
- /** Custom UI component to render set of buttons to be displayed in the MessageActionsBox, defaults to and accepts same props as: [CustomMessageActionsList](https://github.com/GetStream/stream-chat-react/blob/master/src/components/MessageActions/CustomMessageActionsList.tsx) */
- CustomMessageActionsList?: React.ComponentType;
/** Custom UI component for date separators, defaults to and accepts same props as: [DateSeparator](https://github.com/GetStream/stream-chat-react/blob/master/src/components/DateSeparator.tsx) */
DateSeparator?: React.ComponentType;
/** Custom UI component to override default edit message input, defaults to and accepts same props as: [EditMessageForm](https://github.com/GetStream/stream-chat-react/blob/master/src/components/MessageInput/EditMessageForm.tsx) */
@@ -137,12 +133,6 @@ export type ComponentContextValue = {
MessageListNotifications?: React.ComponentType;
/** Custom UI component to display a notification when scrolled up the list and new messages arrive, defaults to and accepts same props as [MessageNotification](https://github.com/GetStream/stream-chat-react/blob/master/src/components/MessageList/MessageNotification.tsx) */
MessageNotification?: React.ComponentType;
- /**
- * Custom UI component for message options popup, defaults to and accepts same props as: [MessageOptions](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Message/MessageOptions.tsx)
- *
- * @deprecated Use MessageActions property instead.
- */
- MessageOptions?: React.ComponentType;
/** Custom UI component to display message replies, defaults to and accepts same props as: [MessageRepliesCountButton](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Message/MessageRepliesCountButton.tsx) */
MessageRepliesCountButton?: React.ComponentType;
/** Custom UI component to display message delivery status, defaults to and accepts same props as: [MessageStatus](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Message/MessageStatus.tsx) */
diff --git a/src/context/MessageContext.tsx b/src/context/MessageContext.tsx
index df4010e87e..a61a0a8046 100644
--- a/src/context/MessageContext.tsx
+++ b/src/context/MessageContext.tsx
@@ -27,13 +27,6 @@ import type {
import type { RenderTextOptions } from '../components/Message/renderText';
import type { UnknownType } from '../types/types';
-export type CustomMessageActions = {
- [key: string]: (
- message: LocalMessage,
- event: React.BaseSyntheticEvent,
- ) => Promise | void;
-};
-
export type MessageContextValue = {
/** If actions such as edit, delete, flag, mute are enabled on Message */
actionsEnabled: boolean;
@@ -99,8 +92,6 @@ export type MessageContextValue = {
autoscrollToBottom?: () => void;
/** Message component configuration prop. If true, picking a reaction from the `ReactionSelector` component will close the selector */
closeReactionSelectorOnClick?: boolean;
- /** Object containing custom message actions and function handlers */
- customMessageActions?: CustomMessageActions;
/** An array of user IDs that have confirmed the message delivery to their device */
deliveredTo?: UserResponse[];
/** If true, the message is the last one in a group sent by a specific user (only used in the `VirtualizedMessageList`) */
diff --git a/src/experimental/MessageActions/MessageActions.tsx b/src/experimental/MessageActions/MessageActions.tsx
deleted file mode 100644
index 2ce3517cc9..0000000000
--- a/src/experimental/MessageActions/MessageActions.tsx
+++ /dev/null
@@ -1,127 +0,0 @@
-import clsx from 'clsx';
-import React, { useState } from 'react';
-import type { PropsWithChildren } from 'react';
-
-import { useChatContext, useMessageContext, useTranslationContext } from '../../context';
-import { ActionsIcon } from '../../components/Message/icons';
-import {
- DialogAnchor,
- useDialogIsOpen,
- useDialogOnNearestManager,
-} from '../../components/Dialog';
-import { MessageActionsWrapper } from '../../components/MessageActions/MessageActions';
-import { useBaseMessageActionSetFilter, useSplitMessageActionSet } from './hooks';
-import { defaultMessageActionSet } from './defaults';
-import type { MESSAGE_ACTIONS } from '../../components';
-
-export type MessageActionSetItem = {
- Component: React.ComponentType;
- placement: 'quick' | 'dropdown';
- type: keyof typeof MESSAGE_ACTIONS | (string & {});
-};
-
-export type MessageActionsProps = {
- disableBaseMessageActionSetFilter?: boolean;
- messageActionSet?: MessageActionSetItem[];
-};
-
-// TODO: allow passing down customWrapperClass
-/**
- * A new actions component to replace current `MessageOptions` component.
- * Exports from `stream-chat-react/experimental` __MIGHT__ change - use with caution
- * and follow release notes in case you notice unexpected behavior.
- */
-export const MessageActions = ({
- disableBaseMessageActionSetFilter = false,
- messageActionSet = defaultMessageActionSet,
-}: MessageActionsProps) => {
- const { theme } = useChatContext();
- const { isMyMessage, message } = useMessageContext();
- const { t } = useTranslationContext();
- const [actionsBoxButtonElement, setActionsBoxButtonElement] =
- useState(null);
-
- const filteredMessageActionSet = useBaseMessageActionSetFilter(
- messageActionSet,
- disableBaseMessageActionSetFilter,
- );
-
- const { dropdownActionSet, quickActionSet } = useSplitMessageActionSet(
- filteredMessageActionSet,
- );
-
- const dropdownDialogId = `message-actions--${message.id}`;
- const reactionSelectorDialogId = `reaction-selector--${message.id}`;
- const { dialog, dialogManager } = useDialogOnNearestManager({ id: dropdownDialogId });
- const dropdownDialogIsOpen = useDialogIsOpen(dropdownDialogId, dialogManager?.id);
- const reactionSelectorDialogIsOpen = useDialogIsOpen(
- reactionSelectorDialogId,
- dialogManager?.id,
- );
-
- // do not render anything if total action count is zero
- if (dropdownActionSet.length + quickActionSet.length === 0) {
- return null;
- }
-
- return (
-
- {dropdownActionSet.length > 0 && (
-
-
-
-
-
- {dropdownActionSet.map(({ Component: DropdownActionComponent, type }) => (
-
- ))}
-
-
-
- )}
- {quickActionSet.map(({ Component: QuickActionComponent, type }) => (
-
- ))}
-
- );
-};
-
-const DropdownBox = ({ children, open }: PropsWithChildren<{ open: boolean }>) => {
- const { t } = useTranslationContext();
- return (
-
- );
-};
diff --git a/src/experimental/MessageActions/index.ts b/src/experimental/MessageActions/index.ts
deleted file mode 100644
index 1e0b5c79f0..0000000000
--- a/src/experimental/MessageActions/index.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-export * from './MessageActions';
-export * from './defaults';
-export * from './hooks';
diff --git a/src/experimental/index.ts b/src/experimental/index.ts
index 3941332373..addd53308b 100644
--- a/src/experimental/index.ts
+++ b/src/experimental/index.ts
@@ -1,2 +1 @@
-export * from './MessageActions';
export * from './Search';