Skip to content

Commit 872928c

Browse files
authored
feat: support overriding components in MessageList (#2890)
### 🎯 Goal This PR aligns overriding functionality between the VirtualizedMessageList (VML) and the regular MessageList (ML). The goal is to be able to integrate components like React Aria's [GridList](https://react-spectrum.adobe.com/react-aria/GridList.html) into ML. Currently, VML supports overriding the list and item components via `additionalVirtuosoProps.components`. This PR adds the same ability to ML via the ComponentContext. ### πŸ›  Implementation details We add two more components to ComponentContext: - MessageListWrapper wraps all message list items (default is `ul`). - MessageListItem wraps each message list item (default is `li`). These components are consumed in MessageList and in `renderMessages()` respectively. This makes the following usage possible: ```jsx <Chat client={client}> <Channel channel={channel}> <ComponentProvider value={{ MessageListItem: (props) => ( <div data-testid='message-list-item' {...props} /> ), MessageListWrapper: (props) => ( <div data-testid='message-list-wrapper' {...props} /> ), }}> <MessageList {...msgListProps} /> </ComponentProvider> </Channel> </Chat> ``` Sometimes it's also valuable to be able to access the current `RenderedMessage` item from MessageListItem component. In VML it's possible by accessing Virtuoso's context to get `processedMessages` and using the `data-index` attribute to get item's index. To align this functionality with ML, this PR adds `processedMessages` to MessageListContext, and adds `data-index` attribute to message list items. ### 🎨 UI Changes None by default.
1 parent 5d75143 commit 872928c

File tree

5 files changed

+84
-18
lines changed

5 files changed

+84
-18
lines changed

β€Žsrc/components/MessageList/MessageList.tsxβ€Ž

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,6 @@ const MessageListWithContext = (props: MessageListWithContextProps) => {
8787
} = props;
8888

8989
const [listElement, setListElement] = React.useState<HTMLDivElement | null>(null);
90-
const [ulElement, setUlElement] = React.useState<HTMLUListElement | null>(null);
9190

9291
const { customClasses } = useChatContext('MessageList');
9392

@@ -96,6 +95,7 @@ const MessageListWithContext = (props: MessageListWithContextProps) => {
9695
LoadingIndicator = DefaultLoadingIndicator,
9796
MessageListMainPanel = DefaultMessageListMainPanel,
9897
MessageListNotifications = DefaultMessageListNotifications,
98+
MessageListWrapper = 'ul',
9999
MessageNotification = DefaultMessageNotification,
100100
TypingIndicator = DefaultTypingIndicator,
101101
UnreadMessagesNotification = DefaultUnreadMessagesNotification,
@@ -214,7 +214,7 @@ const MessageListWithContext = (props: MessageListWithContextProps) => {
214214

215215
React.useLayoutEffect(() => {
216216
if (highlightedMessageId) {
217-
const element = ulElement?.querySelector(
217+
const element = listElement?.querySelector(
218218
`[data-message-id='${highlightedMessageId}']`,
219219
);
220220
element?.scrollIntoView({ block: 'center' });
@@ -230,7 +230,13 @@ const MessageListWithContext = (props: MessageListWithContextProps) => {
230230
: `message-list-dialog-manager-${id}`;
231231

232232
return (
233-
<MessageListContextProvider value={{ listElement, scrollToBottom }}>
233+
<MessageListContextProvider
234+
value={{
235+
listElement,
236+
processedMessages: enrichedMessages,
237+
scrollToBottom,
238+
}}
239+
>
234240
<MessageListMainPanel>
235241
<DialogManagerProvider id={dialogManagerId}>
236242
{!threadList && showUnreadMessagesNotification && (
@@ -264,9 +270,9 @@ const MessageListWithContext = (props: MessageListWithContextProps) => {
264270
threshold={loadMoreScrollThreshold}
265271
{...restInternalInfiniteScrollProps}
266272
>
267-
<ul className='str-chat__ul' ref={setUlElement}>
273+
<MessageListWrapper className='str-chat__ul'>
268274
{elements}
269-
</ul>
275+
</MessageListWrapper>
270276
<TypingIndicator threadList={threadList} />
271277

272278
<div key='bottom' />

β€Žsrc/components/MessageList/__tests__/MessageList.test.jsβ€Ž

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,11 @@ import {
2020
import { Chat } from '../../Chat';
2121
import { MessageList } from '../MessageList';
2222
import { Channel } from '../../Channel';
23-
import { useChannelActionContext, useMessageContext } from '../../../context';
23+
import {
24+
ComponentProvider,
25+
useChannelActionContext,
26+
useMessageContext,
27+
} from '../../../context';
2428
import { EmptyStateIndicator as EmptyStateIndicatorMock } from '../../EmptyStateIndicator';
2529
import { ScrollToBottomButton } from '../ScrollToBottomButton';
2630
import { MessageListNotifications } from '../MessageListNotifications';
@@ -47,11 +51,13 @@ const mockedChannelData = generateChannel({
4751

4852
const Avatar = () => <div data-testid='custom-avatar'>Avatar</div>;
4953

50-
const renderComponent = ({ channelProps, chatClient, msgListProps }) =>
54+
const renderComponent = ({ channelProps, chatClient, components = {}, msgListProps }) =>
5155
render(
5256
<Chat client={chatClient}>
5357
<Channel {...channelProps}>
54-
<MessageList {...msgListProps} />
58+
<ComponentProvider value={components}>
59+
<MessageList {...msgListProps} />
60+
</ComponentProvider>
5561
</Channel>
5662
</Chat>,
5763
);
@@ -875,4 +881,48 @@ describe('MessageList', () => {
875881
expect(notificationFunc).toHaveBeenCalledWith(expect.objectContaining(message));
876882
});
877883
});
884+
885+
describe('list wrapper and list item overrides', () => {
886+
it('uses provided list wrapper', async () => {
887+
await act(() => {
888+
renderComponent({
889+
channelProps: { channel },
890+
chatClient,
891+
components: {
892+
MessageListWrapper: (props) => (
893+
<div data-testid='message-list-wrapper' {...props} />
894+
),
895+
},
896+
});
897+
});
898+
899+
await waitFor(() => {
900+
expect(screen.queryByTestId('message-list-wrapper')).toBeInTheDocument();
901+
});
902+
});
903+
904+
it('uses provided list item wrapper', async () => {
905+
await act(() => {
906+
renderComponent({
907+
channelProps: { channel },
908+
chatClient,
909+
components: {
910+
MessageListItem: (props) => (
911+
<div data-testid='message-list-item' {...props} />
912+
),
913+
MessageListWrapper: (props) => (
914+
<div data-testid='message-list-wrapper' {...props} />
915+
),
916+
},
917+
});
918+
});
919+
920+
await waitFor(() => {
921+
const item = screen.queryByTestId('message-list-item');
922+
expect(item).toBeInTheDocument();
923+
expect(item.dataset.index).toBeDefined();
924+
expect(screen.queryByText(message1.text)).toBeInTheDocument();
925+
});
926+
});
927+
});
878928
});

β€Žsrc/components/MessageList/renderMessages.tsxβ€Ž

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ export function defaultRenderMessages({
6464
const {
6565
DateSeparator = DefaultDateSeparator,
6666
HeaderComponent,
67+
MessageListItem = 'li',
6768
MessageSystem = DefaultMessageSystem,
6869
UnreadMessagesSeparator = DefaultUnreadMessagesSeparator,
6970
} = components;
@@ -75,30 +76,31 @@ export function defaultRenderMessages({
7576
const message = messages[index];
7677
if (isDateSeparatorMessage(message)) {
7778
renderedMessages.push(
78-
<li key={`${message.date.toISOString()}-i`}>
79+
<MessageListItem data-index={index} key={`${message.date.toISOString()}-i`}>
7980
<DateSeparator
8081
date={message.date}
8182
formatDate={messageProps.formatDate}
8283
unread={message.unread}
8384
/>
84-
</li>,
85+
</MessageListItem>,
8586
);
8687
} else if (isIntroMessage(message)) {
8788
if (HeaderComponent) {
8889
renderedMessages.push(
89-
<li key='intro'>
90+
<MessageListItem data-index={index} key='intro'>
9091
<HeaderComponent />
91-
</li>,
92+
</MessageListItem>,
9293
);
9394
}
9495
} else if (message.type === 'system') {
9596
renderedMessages.push(
96-
<li
97+
<MessageListItem
98+
data-index={index}
9799
data-message-id={message.id}
98100
key={message.id || message.created_at.toISOString()}
99101
>
100102
<MessageSystem message={message} />
101-
</li>,
103+
</MessageListItem>,
102104
);
103105
} else {
104106
if (!firstMessage) {
@@ -121,14 +123,15 @@ export function defaultRenderMessages({
121123
renderedMessages.push(
122124
<Fragment key={message.id || message.created_at.toISOString()}>
123125
{isFirstUnreadMessage && UnreadMessagesSeparator && (
124-
<li className='str-chat__li str-chat__unread-messages-separator-wrapper'>
126+
<MessageListItem className='str-chat__li str-chat__unread-messages-separator-wrapper'>
125127
<UnreadMessagesSeparator
126128
unreadCount={channelUnreadUiState?.unread_messages}
127129
/>
128-
</li>
130+
</MessageListItem>
129131
)}
130-
<li
132+
<MessageListItem
131133
className={messageClass}
134+
data-index={index}
132135
data-message-id={message.id}
133136
data-testid={messageClass}
134137
>
@@ -141,7 +144,7 @@ export function defaultRenderMessages({
141144
readBy={readData[message.id] || []}
142145
{...messageProps}
143146
/>
144-
</li>
147+
</MessageListItem>
145148
</Fragment>,
146149
);
147150
previousMessage = message;

β€Žsrc/context/ComponentContext.tsxβ€Ž

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,10 @@ export type ComponentContextValue = {
240240
UnreadMessagesSeparator?: React.ComponentType<UnreadMessagesSeparatorProps>;
241241
/** Custom UI component to display a message in the `VirtualizedMessageList`, does not have a default implementation */
242242
VirtualMessage?: React.ComponentType<FixedHeightMessageProps>;
243+
/** Custom UI component to wrap MessageList children. Default is the `ul` tag */
244+
MessageListWrapper?: React.ComponentType;
245+
/** Custom UI component to wrap each element of MessageList. Default is the `li` tag */
246+
MessageListItem?: React.ComponentType;
243247
};
244248

245249
export const ComponentContext = React.createContext<ComponentContextValue>({});

β€Žsrc/context/MessageListContext.tsxβ€Ž

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import React, { createContext, useContext } from 'react';
22
import type { PropsWithChildren } from 'react';
3+
import type { RenderedMessage } from '../components';
34

45
export type MessageListContextValue = {
6+
/** Enriched message list, including date separators and intro message (if enabled) */
7+
processedMessages: RenderedMessage[];
58
/** The scroll container within which the messages and typing indicator are rendered */
69
listElement: HTMLDivElement | null;
710
/** Function that scrolls the `listElement` to the bottom. */

0 commit comments

Comments
Β (0)