Skip to content

Commit dba63e8

Browse files
authored
feat: allow overriding the way MessageList renders messages (#2243)
### 🎯 Goal Some users want to be able to append custom elements at the start/end of `MessageList`. For maximum flexibility, we're just giving (optional) full control over how `MessageList` renders its children. ### 🛠 Implementation details New optional prop `renderMessages` is added to `MessageList`. It receives an array of messages to be rendered, all the components from the channel's component context, and some additional metadata. It returns an array of React elements. The default implementation is identical to how `MessageList` currently renders its children. This default implementation is also exported so that users can reuse it in their current renderers. ### 🎨 UI Changes None by default.
1 parent 32c0180 commit dba63e8

File tree

7 files changed

+245
-81
lines changed

7 files changed

+245
-81
lines changed

docusaurus/docs/React/components/core-components/message-list.mdx

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,34 @@ const CustomMessageList = () => (
218218
);
219219
```
220220

221+
#### Custom message list rendering
222+
223+
You can completely change the way the message list is rendered by providing a custom `renderMessages` function. This function takes all the messages fetched so far (along with some additional data) and returns an array of React elements to render. By overriding the default behavior, you can add custom elements to the message list, change the way messages are grouped, add custom separators between messages, etc.
224+
225+
If you provide a custom `renderMessages` function, it's your responsibility to render each message type correctly. You can use the <GHComponentLink text='default implementation' path='/MessageList/renderMessages.tsx'/> as a reference. Or, if you just want to tweak a few things here and there, you can call `defaultRenderMessages` from your custom `renderMessages` function and build from there.
226+
227+
In this example, we use the default implementation for rendering a message list, and we add a custom element at the bottom of the list:
228+
229+
```tsx
230+
const customRenderMessages: MessageRenderer<StreamChatGenerics> = (options) => {
231+
const elements = defaultRenderMessages(options);
232+
elements.push(<li key='caught-up'>You're all caught up!</li>);
233+
return elements;
234+
};
235+
236+
const CustomMessageList = () => (
237+
<MessageList renderMessages={customRenderMessages}/>
238+
);
239+
```
240+
241+
Make sure that the elements you return have `key`, as they will be rendered as an array. It's also a good idea to wrap each element with `<li>` to keep your markup semantically correct.
242+
243+
:::note
244+
`MessageList` will re-render every time `renderMessages` function changes. For best performance, make sure that you don't recreate `renderMessages` function on every render: either move it to the global or module scope, or wrap it with `useCallback`.
245+
:::
246+
247+
Custom message list rendering is only supported in `MessageList` and is currently not supported in `VirtualizedMessageList`.
248+
221249
## Props
222250

223251
### additionalMessageInputProps
@@ -504,9 +532,35 @@ The user roles allowed to pin messages in various channel types (deprecated in f
504532

505533
Custom function to render message text content.
506534

535+
| Type | Default |
536+
| -------- | ------------------------------------------------------------------------------ |
537+
| function | <GHComponentLink text='renderText' path='/Message/renderText/renderText.tsx'/> |
538+
539+
### renderMessages
540+
541+
Custom function to render message text content.
542+
507543
| Type | Default |
508544
| -------- | -------------------------------------------------------------------------------------- |
509-
| function | <GHComponentLink text='renderText' path='/Message/renderText/renderText.tsx'/> |
545+
| function | <GHComponentLink text='defaultRenderMessages' path='/MessageList/renderMessages.tsx'/> |
546+
547+
#### Parameters
548+
549+
The function receives a single object with the following properties:
550+
551+
| Name | Type | Description |
552+
| --------------------- | --------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
553+
| components | [ComponentContextValue](../contexts/component-context.mdx) | UI components, including possible overrides |
554+
| customClasses | object | Object containing [custom CSS classnames](../../components/core-components/chat.mdx#customclasses) to override the library's default container CSS |
555+
| lastReceivedMessageId | string | The latest message ID in the current channel |
556+
| messageGroupStyles | string[] | An array of potential styles to apply to a grouped message (ex: top, bottom, single) |
557+
| messages | Array<[ChannelStateContextValue['messages']](../contexts/channel-state-context.mdx#messages)> | The messages to render in the list |
558+
| readData | object | The read state for for messages submitted by the user themselves |
559+
| sharedMessageProps | object | Object containing props that can be directly passed to the `Message` component |
560+
561+
#### Return value
562+
563+
The function is expected to return an array of valid React nodes: `Array<ReactNode>`. For best performance, each node should have a `key`.
510564

511565
### retrySendMessage
512566

src/components/Channel/Channel.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,8 @@ import {
9292
defaultReactionOptions,
9393
ReactionOptions,
9494
} from '../../components/Reactions/reactionOptions';
95+
import { EventComponent } from '../EventComponent';
96+
import { DateSeparator } from '../DateSeparator';
9597

9698
type ChannelPropsForwardedToComponentContext<
9799
StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics
@@ -1013,7 +1015,7 @@ const ChannelInner = <
10131015
BaseImage: props.BaseImage,
10141016
CooldownTimer: props.CooldownTimer,
10151017
CustomMessageActionsList: props.CustomMessageActionsList,
1016-
DateSeparator: props.DateSeparator,
1018+
DateSeparator: props.DateSeparator || DateSeparator,
10171019
EditMessageInput: props.EditMessageInput,
10181020
EmojiPicker: props.EmojiPicker,
10191021
emojiSearchIndex: props.emojiSearchIndex,
@@ -1031,7 +1033,7 @@ const ChannelInner = <
10311033
MessageOptions: props.MessageOptions,
10321034
MessageRepliesCountButton: props.MessageRepliesCountButton,
10331035
MessageStatus: props.MessageStatus,
1034-
MessageSystem: props.MessageSystem,
1036+
MessageSystem: props.MessageSystem || EventComponent,
10351037
MessageTimestamp: props.MessageTimestamp,
10361038
ModalGallery: props.ModalGallery,
10371039
PinIndicator: props.PinIndicator,

src/components/MessageList/MessageList.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { TypingIndicator as DefaultTypingIndicator } from '../TypingIndicator';
2828
import { MessageListMainPanel } from './MessageListMainPanel';
2929

3030
import type { GroupStyle } from './utils';
31+
import { defaultRenderMessages, MessageRenderer } from './renderMessages';
3132

3233
import type { MessageProps } from '../Message/types';
3334

@@ -62,6 +63,7 @@ const MessageListWithContext = <
6263
unsafeHTML = false,
6364
headerPosition,
6465
read,
66+
renderMessages = defaultRenderMessages,
6567
messageLimit = 100,
6668
loadMore: loadMoreCallback,
6769
loadMoreNewer: loadMoreNewerCallback,
@@ -142,6 +144,7 @@ const MessageListWithContext = <
142144
},
143145
messageGroupStyles,
144146
read,
147+
renderMessages,
145148
returnAllReadData,
146149
threadList,
147150
});
@@ -302,6 +305,8 @@ export type MessageListProps<
302305
messages?: StreamMessage<StreamChatGenerics>[];
303306
/** If true, turns off message UI grouping by user */
304307
noGroupByUser?: boolean;
308+
/** Overrides the way MessageList renders messages */
309+
renderMessages?: MessageRenderer<StreamChatGenerics>;
305310
/** If true, `readBy` data supplied to the `Message` components will include all user read states per sent message */
306311
returnAllReadData?: boolean;
307312
/**

src/components/MessageList/__tests__/MessageList.test.js

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,4 +238,59 @@ describe('MessageList', () => {
238238
const results = await axe(container);
239239
expect(results).toHaveNoViolations();
240240
});
241+
242+
it('should render intro messages', async () => {
243+
const intro = generateMessage({ customType: 'message.intro' });
244+
const headerText = 'header is rendered';
245+
const Header = () => <div>{headerText}</div>;
246+
247+
await act(() => {
248+
renderComponent({
249+
channelProps: { channel, HeaderComponent: Header },
250+
chatClient,
251+
msgListProps: {
252+
messages: [intro],
253+
},
254+
});
255+
});
256+
257+
await waitFor(() => {
258+
expect(screen.queryByText(headerText)).toBeInTheDocument();
259+
});
260+
});
261+
262+
it('should render system messages', async () => {
263+
const system = generateMessage({ text: 'system message is rendered', type: 'system' });
264+
265+
await act(() => {
266+
renderComponent({
267+
channelProps: { channel },
268+
chatClient,
269+
msgListProps: {
270+
messages: [system],
271+
},
272+
});
273+
});
274+
275+
await waitFor(() => {
276+
expect(screen.queryByText(system.text)).toBeInTheDocument();
277+
});
278+
});
279+
280+
it('should use custom message list renderer if provided', async () => {
281+
const customRenderMessages = ({ messages }) =>
282+
messages.map((msg) => <li key={msg.id}>prefixed {msg.text}</li>);
283+
284+
await act(() => {
285+
renderComponent({
286+
channelProps: { channel },
287+
chatClient,
288+
msgListProps: { renderMessages: customRenderMessages },
289+
});
290+
});
291+
292+
await waitFor(() => {
293+
expect(screen.queryByText(`prefixed ${message1.text}`)).toBeInTheDocument();
294+
});
295+
});
241296
});

src/components/MessageList/hooks/MessageList/useMessageListElements.tsx

Lines changed: 18 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -4,38 +4,23 @@ import React, { useMemo } from 'react';
44
import { useLastReadData } from '../useLastReadData';
55
import { getLastReceived, GroupStyle } from '../../utils';
66

7-
import { CUSTOM_MESSAGE_TYPE } from '../../../../constants/messageTypes';
8-
import { DateSeparator as DefaultDateSeparator } from '../../../DateSeparator/DateSeparator';
9-
import { EventComponent } from '../../../EventComponent/EventComponent';
10-
import { Message } from '../../../Message';
11-
127
import { useChatContext } from '../../../../context/ChatContext';
138
import { useComponentContext } from '../../../../context/ComponentContext';
14-
import { isDate } from '../../../../context/TranslationContext';
159

1610
import type { UserResponse } from 'stream-chat';
1711

18-
import type { MessageProps } from '../../../Message/types';
19-
2012
import type { StreamMessage } from '../../../../context/ChannelStateContext';
2113

2214
import type { DefaultStreamChatGenerics } from '../../../../types/types';
23-
24-
type MessagePropsToOmit =
25-
| 'channel'
26-
| 'groupStyles'
27-
| 'initialMessage'
28-
| 'lastReceivedId'
29-
| 'message'
30-
| 'readBy'
31-
| 'threadList';
15+
import { MessageRenderer, SharedMessageProps } from '../../renderMessages';
3216

3317
type UseMessageListElementsProps<
3418
StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics
3519
> = {
3620
enrichedMessages: StreamMessage<StreamChatGenerics>[];
37-
internalMessageProps: Omit<MessageProps<StreamChatGenerics>, MessagePropsToOmit>;
21+
internalMessageProps: SharedMessageProps<StreamChatGenerics>;
3822
messageGroupStyles: Record<string, GroupStyle>;
23+
renderMessages: MessageRenderer<StreamChatGenerics>;
3924
returnAllReadData: boolean;
4025
threadList: boolean;
4126
read?: Record<string, { last_read: Date; user: UserResponse<StreamChatGenerics> }>;
@@ -51,16 +36,13 @@ export const useMessageListElements = <
5136
internalMessageProps,
5237
messageGroupStyles,
5338
read,
39+
renderMessages,
5440
returnAllReadData,
5541
threadList,
5642
} = props;
5743

5844
const { client, customClasses } = useChatContext<StreamChatGenerics>('useMessageListElements');
59-
const {
60-
DateSeparator = DefaultDateSeparator,
61-
HeaderComponent,
62-
MessageSystem = EventComponent,
63-
} = useComponentContext<StreamChatGenerics>('useMessageListElements');
45+
const components = useComponentContext<StreamChatGenerics>('useMessageListElements');
6446

6547
// get the readData, but only for messages submitted by the user themselves
6648
const readData = useLastReadData({
@@ -70,71 +52,29 @@ export const useMessageListElements = <
7052
userID: client.userID,
7153
});
7254

73-
const lastReceivedId = useMemo(() => getLastReceived(enrichedMessages), [enrichedMessages]);
55+
const lastReceivedMessageId = useMemo(() => getLastReceived(enrichedMessages), [
56+
enrichedMessages,
57+
]);
7458

7559
const elements: React.ReactNode[] = useMemo(
7660
() =>
77-
enrichedMessages.map((message) => {
78-
if (
79-
message.customType === CUSTOM_MESSAGE_TYPE.date &&
80-
message.date &&
81-
isDate(message.date)
82-
) {
83-
return (
84-
<li key={`${message.date.toISOString()}-i`}>
85-
<DateSeparator
86-
date={message.date}
87-
formatDate={internalMessageProps.formatDate}
88-
unread={message.unread}
89-
/>
90-
</li>
91-
);
92-
}
93-
94-
if (message.customType === CUSTOM_MESSAGE_TYPE.intro && HeaderComponent) {
95-
return (
96-
<li key='intro'>
97-
<HeaderComponent />
98-
</li>
99-
);
100-
}
101-
102-
if (message.type === 'system') {
103-
return (
104-
<li key={message.id || (message.created_at as string)}>
105-
<MessageSystem message={message} />
106-
</li>
107-
);
108-
}
109-
110-
const groupStyles: GroupStyle = messageGroupStyles[message.id] || '';
111-
const messageClass = customClasses?.message || `str-chat__li str-chat__li--${groupStyles}`;
112-
113-
return (
114-
<li
115-
className={messageClass}
116-
data-message-id={message.id}
117-
data-testid={messageClass}
118-
key={message.id || (message.created_at as string)}
119-
>
120-
<Message
121-
groupStyles={[groupStyles]} /* TODO: convert to simple string */
122-
lastReceivedId={lastReceivedId}
123-
message={message}
124-
readBy={readData[message.id] || []}
125-
threadList={threadList}
126-
{...internalMessageProps}
127-
/>
128-
</li>
129-
);
61+
renderMessages({
62+
components,
63+
customClasses,
64+
lastReceivedMessageId,
65+
messageGroupStyles,
66+
messages: enrichedMessages,
67+
readData,
68+
sharedMessageProps: { ...internalMessageProps, threadList },
13069
}),
13170
// eslint-disable-next-line react-hooks/exhaustive-deps
13271
[
13372
enrichedMessages,
13473
internalMessageProps,
135-
lastReceivedId,
74+
lastReceivedMessageId,
13675
messageGroupStyles,
13776
readData,
77+
renderMessages,
13878
threadList,
13979
],
14080
);

src/components/MessageList/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,5 @@ export * from './MessageNotification';
66
export * from './ScrollToBottomButton';
77
export * from './VirtualizedMessageList';
88
export * from './hooks';
9+
export * from './renderMessages';
910
export * from './utils';

0 commit comments

Comments
 (0)