Skip to content

Commit

Permalink
fix: MessageActions adjustments (#2472)
Browse files Browse the repository at this point in the history
### 🎯 Goal

This change allows rendering `CustomMessageActionsList` component
without having to provide empty `customMessageActions` object.
  • Loading branch information
arnautov-anton authored Sep 4, 2024
1 parent eb0d6d4 commit fbd1b6f
Show file tree
Hide file tree
Showing 5 changed files with 100 additions and 66 deletions.
9 changes: 2 additions & 7 deletions src/components/Message/MessageOptions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
ReactionIcon as DefaultReactionIcon,
ThreadIcon as DefaultThreadIcon,
} from './icons';
import { MESSAGE_ACTIONS, showMessageActionsBox } from './utils';
import { MESSAGE_ACTIONS } from './utils';

import { MessageActions } from '../MessageActions';

Expand Down Expand Up @@ -47,7 +47,6 @@ const UnMemoizedMessageOptions = <
} = props;

const {
customMessageActions,
getMessageActions,
handleOpenThread: contextHandleOpenThread,
initialMessage,
Expand All @@ -62,8 +61,6 @@ const UnMemoizedMessageOptions = <
const handleOpenThread = propHandleOpenThread || contextHandleOpenThread;

const messageActions = getMessageActions();
const showActionsBox =
showMessageActionsBox(messageActions, threadList) || !!customMessageActions;

const shouldShowReactions = messageActions.indexOf(MESSAGE_ACTIONS.react) > -1;
const shouldShowReplies =
Expand All @@ -85,9 +82,7 @@ const UnMemoizedMessageOptions = <

return (
<div className={rootClassName} data-testid='message-options'>
{showActionsBox && (
<MessageActions ActionsIcon={ActionsIcon} messageWrapperRef={messageWrapperRef} />
)}
<MessageActions ActionsIcon={ActionsIcon} messageWrapperRef={messageWrapperRef} />
{shouldShowReplies && (
<button
aria-label={t('aria/Open Thread')}
Expand Down
67 changes: 32 additions & 35 deletions src/components/Message/__tests__/MessageOptions.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import { MessageSimple } from '../MessageSimple';
import { ACTIONS_NOT_WORKING_IN_THREAD, MESSAGE_ACTIONS } from '../utils';

import { Attachment } from '../../Attachment';
import { MessageActions as MessageActionsMock } from '../../MessageActions';

import { ChannelActionProvider } from '../../../context/ChannelActionContext';
import { ChannelStateProvider } from '../../../context/ChannelStateContext';
Expand All @@ -23,9 +22,7 @@ import {
getTestClientWithUser,
} from '../../../mock-builders';

jest.mock('../../MessageActions', () => ({
MessageActions: jest.fn(() => <div />),
}));
const MESSAGE_ACTIONS_TEST_ID = 'message-actions';

const minimumCapabilitiesToRenderMessageActions = { 'delete-any-message': true };
const alice = generateUser({ name: 'alice' });
Expand Down Expand Up @@ -185,122 +182,122 @@ describe('<MessageOptions />', () => {
});

it('should render message actions', async () => {
await renderMessageOptions({
const { queryByTestId } = await renderMessageOptions({
channelStateOpts: { channelCapabilities: minimumCapabilitiesToRenderMessageActions },
});
// eslint-disable-next-line jest/prefer-called-with
expect(MessageActionsMock).toHaveBeenCalled();

expect(queryByTestId(MESSAGE_ACTIONS_TEST_ID)).toBeInTheDocument();
});

it('should not show message actions button if actions are disabled', async () => {
await renderMessageOptions({
const { queryByTestId } = await renderMessageOptions({
channelStateOpts: { channelCapabilities: minimumCapabilitiesToRenderMessageActions },
customMessageProps: { messageActions: [] },
});
expect(MessageActionsMock).not.toHaveBeenCalled();

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 () => {
await renderMessageOptions({
const { queryByTestId } = await renderMessageOptions({
channelStateOpts: { channelCapabilities: minimumCapabilitiesToRenderMessageActions },
customMessageProps: { messageActions: ACTIONS_NOT_WORKING_IN_THREAD, threadList: true },
});
expect(MessageActionsMock).not.toHaveBeenCalled();

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 () => {
await renderMessageOptions({
const { queryByTestId } = await renderMessageOptions({
channelStateOpts: { channelCapabilities: minimumCapabilitiesToRenderMessageActions },
customMessageProps: {
messageActions: [...ACTIONS_NOT_WORKING_IN_THREAD, MESSAGE_ACTIONS.delete],
threadList: true,
},
});
// eslint-disable-next-line jest/prefer-called-with
expect(MessageActionsMock).toHaveBeenCalled();

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 () => {
await renderMessageOptions({
const { queryByTestId } = await renderMessageOptions({
channelStateOpts: { channelCapabilities: minimumCapabilitiesToRenderMessageActions },
customMessageProps: {
customMessageActions: ACTIONS_NOT_WORKING_IN_THREAD,
messageActions: ACTIONS_NOT_WORKING_IN_THREAD,
threadList: true,
},
});
// eslint-disable-next-line jest/prefer-called-with
expect(MessageActionsMock).toHaveBeenCalled();
expect(queryByTestId(MESSAGE_ACTIONS_TEST_ID)).toBeInTheDocument();
});

it('should not show actions box for message outside thread with single action "react"', async () => {
await renderMessageOptions({
const { queryByTestId } = await renderMessageOptions({
channelStateOpts: { channelCapabilities: minimumCapabilitiesToRenderMessageActions },
customMessageProps: {
messageActions: [MESSAGE_ACTIONS.react],
},
});
// eslint-disable-next-line jest/prefer-called-with
expect(MessageActionsMock).not.toHaveBeenCalled();
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 () => {
await renderMessageOptions({
const { queryByTestId } = await renderMessageOptions({
channelStateOpts: { channelCapabilities: minimumCapabilitiesToRenderMessageActions },
customMessageProps: {
customMessageActions: [MESSAGE_ACTIONS.react],
messageActions: [MESSAGE_ACTIONS.react],
},
});
// eslint-disable-next-line jest/prefer-called-with
expect(MessageActionsMock).toHaveBeenCalled();

expect(queryByTestId(MESSAGE_ACTIONS_TEST_ID)).toBeInTheDocument();
});

it('should not show actions box for message outside thread with single action "reply"', async () => {
await renderMessageOptions({
const { queryByTestId } = await renderMessageOptions({
channelStateOpts: { channelCapabilities: minimumCapabilitiesToRenderMessageActions },
customMessageProps: {
messageActions: [MESSAGE_ACTIONS.reply],
},
});
// eslint-disable-next-line jest/prefer-called-with
expect(MessageActionsMock).not.toHaveBeenCalled();

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 () => {
await renderMessageOptions({
const { queryByTestId } = await renderMessageOptions({
channelStateOpts: { channelCapabilities: minimumCapabilitiesToRenderMessageActions },
customMessageProps: {
customMessageActions: [MESSAGE_ACTIONS.reply],
messageActions: [MESSAGE_ACTIONS.reply],
},
});
// eslint-disable-next-line jest/prefer-called-with
expect(MessageActionsMock).toHaveBeenCalled();

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];
await renderMessageOptions({
const { queryByTestId } = await renderMessageOptions({
channelStateOpts: { channelCapabilities: minimumCapabilitiesToRenderMessageActions },
customMessageProps: {
messageActions: actions,
},
});
// eslint-disable-next-line jest/prefer-called-with
expect(MessageActionsMock).not.toHaveBeenCalled();

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];
await renderMessageOptions({
const { queryByTestId } = await renderMessageOptions({
channelStateOpts: { channelCapabilities: minimumCapabilitiesToRenderMessageActions },
customMessageProps: {
customMessageActions: actions,
messageActions: actions,
},
});
// eslint-disable-next-line jest/prefer-called-with
expect(MessageActionsMock).toHaveBeenCalled();

expect(queryByTestId(MESSAGE_ACTIONS_TEST_ID)).toBeInTheDocument();
});
});
50 changes: 42 additions & 8 deletions src/components/Message/utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@ import type { TFunction } from 'i18next';
import type { MessageResponse, Mute, StreamChat, UserResponse } from 'stream-chat';
import type { PinPermissions } from './hooks';
import type { MessageProps } from './types';
import type { MessageContextValue, StreamMessage } from '../../context';
import type {
ComponentContextValue,
CustomMessageActions,
MessageContextValue,
StreamMessage,
} from '../../context';
import type { DefaultStreamChatGenerics } from '../../types/types';

/**
Expand Down Expand Up @@ -206,26 +211,55 @@ export const ACTIONS_NOT_WORKING_IN_THREAD = [
MESSAGE_ACTIONS.markUnread,
];

/**
* @deprecated use `shouldRenderMessageActions` instead
*/
export const showMessageActionsBox = (
actions: MessageActionsArray,
inThread?: boolean | undefined,
) => {
if (actions.length === 0) {
return false;
}
) => shouldRenderMessageActions({ inThread, messageActions: actions });

export const shouldRenderMessageActions = <
SCG extends DefaultStreamChatGenerics = DefaultStreamChatGenerics
>({
customMessageActions,
CustomMessageActionsList,
inThread,
messageActions,
}: {
messageActions: MessageActionsArray;
customMessageActions?: CustomMessageActions<SCG>;
CustomMessageActionsList?: ComponentContextValue<SCG>['CustomMessageActionsList'];
inThread?: boolean;
}) => {
if (
typeof CustomMessageActionsList !== 'undefined' ||
typeof customMessageActions !== 'undefined'
)
return true;

if (!messageActions.length) return false;

if (
inThread &&
actions.filter((action) => !ACTIONS_NOT_WORKING_IN_THREAD.includes(action)).length === 0
messageActions.filter((action) => !ACTIONS_NOT_WORKING_IN_THREAD.includes(action)).length === 0
) {
return false;
}

if (actions.length === 1 && (actions.includes('react') || actions.includes('reply'))) {
if (
messageActions.length === 1 &&
(messageActions.includes(MESSAGE_ACTIONS.react) ||
messageActions.includes(MESSAGE_ACTIONS.reply))
) {
return false;
}

if (actions.length === 2 && actions.includes('react') && actions.includes('reply')) {
if (
messageActions.length === 2 &&
messageActions.includes(MESSAGE_ACTIONS.react) &&
messageActions.includes(MESSAGE_ACTIONS.reply)
) {
return false;
}

Expand Down
30 changes: 22 additions & 8 deletions src/components/MessageActions/MessageActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,19 @@ import React, {
useRef,
useState,
} from 'react';
import clsx from 'clsx';

import { MessageActionsBox } from './MessageActionsBox';

import { ActionsIcon as DefaultActionsIcon } from '../Message/icons';
import { isUserMuted } from '../Message/utils';
import { isUserMuted, shouldRenderMessageActions } from '../Message/utils';

import { useChatContext } from '../../context/ChatContext';
import { MessageContextValue, useMessageContext } from '../../context/MessageContext';

import type { DefaultStreamChatGenerics, IconProps } from '../../types/types';
import { useMessageActionsBoxPopper } from './hooks';
import { useTranslationContext } from '../../context';
import { useComponentContext, useTranslationContext } from '../../context';

type MessageContextPropsToPick =
| 'getMessageActions'
Expand Down Expand Up @@ -64,6 +65,7 @@ export const MessageActions = <
} = props;

const { mutes } = useChatContext<StreamChatGenerics>('MessageActions');

const {
customMessageActions,
getMessageActions: contextGetMessageActions,
Expand All @@ -75,8 +77,11 @@ export const MessageActions = <
isMyMessage,
message: contextMessage,
setEditingState,
threadList,
} = useMessageContext<StreamChatGenerics>('MessageActions');

const { CustomMessageActionsList } = useComponentContext<StreamChatGenerics>('MessageActions');

const { t } = useTranslationContext('MessageActions');

const getMessageActions = propGetMessageActions || contextGetMessageActions;
Expand All @@ -92,13 +97,21 @@ export const MessageActions = <

const isMuted = useCallback(() => isUserMuted(message, mutes), [message, mutes]);

const messageActions = getMessageActions();

const renderMessageActions = shouldRenderMessageActions<StreamChatGenerics>({
customMessageActions,
CustomMessageActionsList,
inThread: threadList,
messageActions,
});

const hideOptions = useCallback((event: MouseEvent | KeyboardEvent) => {
if (event instanceof KeyboardEvent && event.key !== 'Escape') {
return;
}
setActionsBoxOpen(false);
}, []);
const messageActions = getMessageActions();
const messageDeletedAt = !!message?.deleted_at;

useEffect(() => {
Expand Down Expand Up @@ -133,7 +146,7 @@ export const MessageActions = <
referenceElement: actionsBoxButtonRef.current,
});

if (!messageActions.length && !customMessageActions) return null;
if (!renderMessageActions) return null;

return (
<MessageActionsWrapper
Expand Down Expand Up @@ -178,10 +191,11 @@ export type MessageActionsWrapperProps = {
const MessageActionsWrapper = (props: PropsWithChildren<MessageActionsWrapperProps>) => {
const { children, customWrapperClass, inline, setActionsBoxOpen } = props;

const defaultWrapperClass = `
str-chat__message-simple__actions__action
str-chat__message-simple__actions__action--options
str-chat__message-actions-container`;
const defaultWrapperClass = clsx(
'str-chat__message-simple__actions__action',
'str-chat__message-simple__actions__action--options',
'str-chat__message-actions-container',
);

const wrapperClass = customWrapperClass || defaultWrapperClass;

Expand Down
Loading

0 comments on commit fbd1b6f

Please sign in to comment.