Skip to content

Commit

Permalink
feat: add CustomMessageActionsList to ComponentContext (#2226)
Browse files Browse the repository at this point in the history
### 🎯 Goal

Adds CustomMessageActionsList to ComponentContext for easier message
actions adjustment.
  • Loading branch information
arnautov-anton authored Jan 9, 2024
1 parent 61213d7 commit 2c6e56c
Show file tree
Hide file tree
Showing 8 changed files with 179 additions and 51 deletions.
20 changes: 14 additions & 6 deletions docusaurus/docs/React/components/contexts/component-context.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -71,18 +71,18 @@ Custom UI component to display a user's avatar.

### BaseImage

Custom UI component to display image resp. a fallback in case of load error, in `<img/>` element. The default resp. custom (from `ComponentContext`) `BaseImage` component is rendered by:
Custom UI component to display image resp. a fallback in case of load error, in `<img/>` element. The default resp. custom (from `ComponentContext`) `BaseImage` component is rendered by:

- <GHComponentLink text='Image' path='/Gallery/Image.tsx'/> - single image attachment in message list
- <GHComponentLink text='Gallery' path='/Gallery/Gallery.tsx'/> - group of image attachments in message list
- <GHComponentLink text='AttachmentPreviewList' path='/MessageInput/AttachmentPreviewList.tsx'/> - image uploads preview in message input (composer)
- <GHComponentLink text='Image' path='/Gallery/Image.tsx' /> - single image attachment in message list
- <GHComponentLink text='Gallery' path='/Gallery/Gallery.tsx' /> - group of image attachments in message list
- <GHComponentLink text='AttachmentPreviewList' path='/MessageInput/AttachmentPreviewList.tsx' /> - image uploads preview in message input (composer)

The `BaseImage` component accepts the same props as `<img/>` element.

The [default `BaseImage` component](../../utility-components/base-image) tries to load and display an image and if the load fails, then an SVG image fallback is applied to the `<img/>` element as a CSS mask targeting attached `str-chat__base-image--load-failed` class.

| Type | Default |
|-----------|-----------------------------------------------------------------------|
| Type | Default |
| --------- | ----------------------------------------------------------------- |
| component | <GHComponentLink text='BaseImage' path='/Gallery/BaseImage.tsx'/> |

### CooldownTimer
Expand All @@ -93,6 +93,14 @@ Custom UI component to display the slow mode cooldown timer.
| --------- | ------------------------------------------------------------------------------ |
| component | <GHComponentLink text='CooldownTimer' path='/MessageInput/CooldownTimer.tsx'/> |

### CustomMessageActionsList

Custom UI component to render set of buttons to be displayed in the <GHComponentLink text='MessageActionsBox' path='/MessageActions/MessageActionsBox.tsx' />.

| Type | Default |
| --------- | ------------------------------------------------------------------------------------------------------- |
| component | <GHComponentLink text='CustomMessageActionsList' path='/MessageActions/CustomMessageActionsList.tsx' /> |

### DateSeparator

Custom UI component for date separators.
Expand Down
3 changes: 3 additions & 0 deletions src/components/Channel/Channel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,8 @@ type ChannelPropsForwardedToComponentContext<
BaseImage?: ComponentContextValue<StreamChatGenerics>['BaseImage'];
/** 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?: ComponentContextValue<StreamChatGenerics>['CooldownTimer'];
/** 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?: ComponentContextValue<StreamChatGenerics>['CustomMessageActionsList'];
/** 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?: ComponentContextValue<StreamChatGenerics>['DateSeparator'];
/** 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) */
Expand Down Expand Up @@ -1008,6 +1010,7 @@ const ChannelInner = <
Avatar: props.Avatar,
BaseImage: props.BaseImage,
CooldownTimer: props.CooldownTimer,
CustomMessageActionsList: props.CustomMessageActionsList,
DateSeparator: props.DateSeparator,
EditMessageInput: props.EditMessageInput,
EmojiPicker: props.EmojiPicker,
Expand Down
31 changes: 31 additions & 0 deletions src/components/Channel/__tests__/Channel.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ import {
} from '../../../mock-builders';
import { MessageList } from '../../MessageList';
import { Thread } from '../../Thread';
import { MessageProvider } from '../../../context';
import { MessageActionsBox } from '../../MessageActions';

jest.mock('../../Loading', () => ({
LoadingErrorIndicator: jest.fn(() => <div />),
Expand Down Expand Up @@ -1530,4 +1532,33 @@ describe('Channel', () => {
});
});
});

describe('Custom Components', () => {
it('should render CustomMessageActionsList if provided', async () => {
const { channel, chatClient } = await initClient();
const CustomMessageActionsList = jest
.fn()
.mockImplementation(() => 'CustomMessageActionsList');

const messageContextValue = {
message: generateMessage(),
messageListRect: {},
};

renderComponent({
channel,
chatClient,
children: (
<MessageProvider value={{ ...messageContextValue }}>
<MessageActionsBox getMessageActions={jest.fn(() => [])} />
</MessageProvider>
),
CustomMessageActionsList,
});

await waitFor(() => {
expect(CustomMessageActionsList).toHaveBeenCalledTimes(1);
});
});
});
});
52 changes: 52 additions & 0 deletions src/components/MessageActions/CustomMessageActionsList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import React from 'react';

import { CustomMessageActions } from '../../context/MessageContext';

import type { StreamMessage } from '../../context/ChannelStateContext';
import type { DefaultStreamChatGenerics } from '../../types/types';

export type CustomMessageActionsListProps<
StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics
> = {
message: StreamMessage<StreamChatGenerics>;
customMessageActions?: CustomMessageActions<StreamChatGenerics>;
};

/**
* @deprecated alias for `CustomMessageActionsListProps`, will be removed in the next major release
*/
export type CustomMessageActionsType<
StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics
> = CustomMessageActionsListProps<StreamChatGenerics>;

export const CustomMessageActionsList = <
StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics
>(
props: CustomMessageActionsListProps<StreamChatGenerics>,
) => {
const { customMessageActions, message } = props;

if (!customMessageActions) return null;

const customActionsArray = Object.keys(customMessageActions);

return (
<>
{customActionsArray.map((customAction) => {
const customHandler = customMessageActions[customAction];

return (
<button
aria-selected='false'
className='str-chat__message-actions-list-item str-chat__message-actions-list-item-button'
key={customAction}
onClick={(event) => customHandler(message, event)}
role='option'
>
{customAction}
</button>
);
})}
</>
);
};
53 changes: 9 additions & 44 deletions src/components/MessageActions/MessageActionsBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,53 +3,17 @@ import clsx from 'clsx';

import { MESSAGE_ACTIONS } from '../Message/utils';

import { useChannelActionContext } from '../../context/ChannelActionContext';
import {
CustomMessageActions,
MessageContextValue,
useChannelActionContext,
useComponentContext,
useMessageContext,
} from '../../context/MessageContext';
import { useTranslationContext } from '../../context/TranslationContext';

import type { StreamMessage } from '../../context/ChannelStateContext';
useTranslationContext,
} from '../../context';

import type { DefaultStreamChatGenerics } from '../../types/types';

export type CustomMessageActionsType<
StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics
> = {
customMessageActions: CustomMessageActions<StreamChatGenerics>;
message: StreamMessage<StreamChatGenerics>;
};

const CustomMessageActionsList = <
StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics
>(
props: CustomMessageActionsType<StreamChatGenerics>,
) => {
const { customMessageActions, message } = props;
const customActionsArray = Object.keys(customMessageActions);

return (
<>
{customActionsArray.map((customAction) => {
const customHandler = customMessageActions[customAction];

return (
<button
aria-selected='false'
className='str-chat__message-actions-list-item str-chat__message-actions-list-item-button'
key={customAction}
onClick={(event) => customHandler(message, event)}
role='option'
>
{customAction}
</button>
);
})}
</>
);
};
import { CustomMessageActionsList as DefaultCustomMessageActionsList } from './CustomMessageActionsList';

type PropsDrilledToMessageActionsBox =
| 'getMessageActions'
Expand Down Expand Up @@ -84,6 +48,9 @@ const UnMemoizedMessageActionsBox = <
open = false,
} = props;

const {
CustomMessageActionsList = DefaultCustomMessageActionsList,
} = useComponentContext<StreamChatGenerics>('MessageActionsBox');
const { setQuotedMessage } = useChannelActionContext<StreamChatGenerics>('MessageActionsBox');
const { customMessageActions, message, messageListRect } = useMessageContext<StreamChatGenerics>(
'MessageActionsBox',
Expand Down Expand Up @@ -139,9 +106,7 @@ const UnMemoizedMessageActionsBox = <
return (
<div className={rootClassName} data-testid='message-actions-box' ref={checkIfReverse}>
<div aria-label='Message Options' className='str-chat__message-actions-list' role='listbox'>
{customMessageActions && (
<CustomMessageActionsList customMessageActions={customMessageActions} message={message} />
)}
<CustomMessageActionsList customMessageActions={customMessageActions} message={message} />
{messageActions.indexOf(MESSAGE_ACTIONS.quote) > -1 && (
<button
aria-selected='false'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import React from 'react';
import { fireEvent, render } from '@testing-library/react';
import renderer from 'react-test-renderer';
import { CustomMessageActionsList } from '../CustomMessageActionsList';
import { act } from 'react-dom/test-utils';

describe('CustomMessageActionsList', () => {
it('should render custom list of actions', () => {
const message = { id: 'mId' };

const actions = {
key0: () => {},
key1: () => {},
};

const tree = renderer.create(
<CustomMessageActionsList customMessageActions={actions} message={message} />,
);

expect(tree.toJSON()).toMatchInlineSnapshot(`
Array [
<button
aria-selected="false"
className="str-chat__message-actions-list-item str-chat__message-actions-list-item-button"
onClick={[Function]}
role="option"
>
key0
</button>,
<button
aria-selected="false"
className="str-chat__message-actions-list-item str-chat__message-actions-list-item-button"
onClick={[Function]}
role="option"
>
key1
</button>,
]
`);
});

it('should allow clicking custom action', () => {
const message = { id: 'mId' };

const actions = {
key0: jest.fn(),
};

const { getByText } = render(
<CustomMessageActionsList customMessageActions={actions} message={message} />,
);

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)
});
});
1 change: 1 addition & 0 deletions src/components/MessageActions/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './MessageActions';
export * from './MessageActionsBox';
export * from './CustomMessageActionsList';
7 changes: 6 additions & 1 deletion src/context/ComponentContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,11 @@ import type { ThreadHeaderProps } from '../components/Thread/ThreadHeader';
import type { TypingIndicatorProps } from '../components/TypingIndicator/TypingIndicator';

import type { CustomTrigger, DefaultStreamChatGenerics, UnknownType } from '../types/types';
import type { BaseImageProps, CooldownTimerProps } from '../components';
import type {
BaseImageProps,
CooldownTimerProps,
CustomMessageActionsListProps,
} from '../components';
import type { LinkPreviewListProps } from '../components/MessageInput/LinkPreviewList';
import type { ReactionOptions } from '../components/Reactions/reactionOptions';

Expand All @@ -50,6 +54,7 @@ export type ComponentContextValue<
Avatar?: React.ComponentType<AvatarProps<StreamChatGenerics>>;
BaseImage?: React.ComponentType<BaseImageProps>;
CooldownTimer?: React.ComponentType<CooldownTimerProps>;
CustomMessageActionsList?: React.ComponentType<CustomMessageActionsListProps<StreamChatGenerics>>;
DateSeparator?: React.ComponentType<DateSeparatorProps>;
EditMessageInput?: React.ComponentType<MessageInputProps<StreamChatGenerics>>;
EmojiPicker?: React.ComponentType;
Expand Down

0 comments on commit 2c6e56c

Please sign in to comment.