From 0d094cb989e75f7405e4661e42f8fd52fb35e379 Mon Sep 17 00:00:00 2001 From: MartinCupela <32706194+MartinCupela@users.noreply.github.com> Date: Fri, 28 Jun 2024 09:28:21 +0200 Subject: [PATCH] feat: configure message group size by max time between messages (#2439) --- .../core-components/message-list.mdx | 14 +- .../core-components/virtualized-list.mdx | 14 +- src/components/MessageList/MessageList.tsx | 5 + .../MessageList/VirtualizedMessageList.tsx | 7 +- .../VirtualizedMessageListComponents.tsx | 2 + .../MessageList/__tests__/utils.test.js | 205 +++++++++++++++++- .../hooks/MessageList/useEnrichedMessages.ts | 6 +- src/components/MessageList/utils.ts | 21 +- 8 files changed, 259 insertions(+), 15 deletions(-) diff --git a/docusaurus/docs/React/components/core-components/message-list.mdx b/docusaurus/docs/React/components/core-components/message-list.mdx index e12632906..5d9a83a31 100644 --- a/docusaurus/docs/React/components/core-components/message-list.mdx +++ b/docusaurus/docs/React/components/core-components/message-list.mdx @@ -242,9 +242,9 @@ pinned [message object](https://getstream.io/chat/docs/javascript/message_format Callback function to map each message in the list to a group style (` 'middle' | 'top' | 'bottom' | 'single'`). -| Type | -| -------------------------------------------------------------------------------------------------------------------------- | -| (message: StreamMessage, previousMessage: StreamMessage, nextMessage: StreamMessage, noGroupByUser: boolean) => GroupStyle | +| Type | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| (message: StreamMessage, previousMessage: StreamMessage, nextMessage: StreamMessage, noGroupByUser: boolean, maxTimeBetweenGroupedMessages?: number) => GroupStyle | ### hasMore @@ -302,6 +302,14 @@ Function called when more messages are to be loaded, provide your own function t | -------- | ---------------------------------------------------------------------------------------- | | function | [ChannelActionContextValue['loadMore']](../contexts/channel-action-context.mdx#loadmore) | +### maxTimeBetweenGroupedMessages + +Maximum time in milliseconds that should occur between messages to still consider them grouped together. + +| Type | +| ------ | +| number | + ### Message Custom UI component to display an individual message. diff --git a/docusaurus/docs/React/components/core-components/virtualized-list.mdx b/docusaurus/docs/React/components/core-components/virtualized-list.mdx index 0a050e189..8b1c6fc35 100644 --- a/docusaurus/docs/React/components/core-components/virtualized-list.mdx +++ b/docusaurus/docs/React/components/core-components/virtualized-list.mdx @@ -129,9 +129,9 @@ If true, disables the injection of date separator UI components. Callback function to set group styles for each message. -| Type | -| -------------------------------------------------------------------------------------------------------------------------- | -| (message: StreamMessage, previousMessage: StreamMessage, nextMessage: StreamMessage, noGroupByUser: boolean) => GroupStyle | +| Type | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| (message: StreamMessage, previousMessage: StreamMessage, nextMessage: StreamMessage, noGroupByUser: boolean, maxTimeBetweenGroupedMessages?: number) => GroupStyle | ### hasMore @@ -173,6 +173,14 @@ Function called when more messages are to be loaded, provide your own function t | -------- | ---------------------------------------------------------------------------------------- | | function | [ChannelActionContextValue['loadMore']](../contexts/channel-action-context.mdx#loadmore) | +### maxTimeBetweenGroupedMessages + +Maximum time in milliseconds that should occur between messages to still consider them grouped together. + +| Type | +| ------ | +| number | + ### Message Custom UI component to display an individual message. diff --git a/src/components/MessageList/MessageList.tsx b/src/components/MessageList/MessageList.tsx index 6642b2291..30f1dea12 100644 --- a/src/components/MessageList/MessageList.tsx +++ b/src/components/MessageList/MessageList.tsx @@ -64,6 +64,7 @@ const MessageListWithContext = < threshold: loadMoreScrollThreshold = DEFAULT_LOAD_PAGE_SCROLL_THRESHOLD, ...restInternalInfiniteScrollProps } = {}, + maxTimeBetweenGroupedMessages, messageActions = Object.keys(MESSAGE_ACTIONS), messages = [], notifications, @@ -138,6 +139,7 @@ const MessageListWithContext = < headerPosition, hideDeletedMessages, hideNewMessageSeparator, + maxTimeBetweenGroupedMessages, messages, noGroupByUser, reviewProcessedMessage, @@ -320,6 +322,7 @@ export type MessageListProps< previousMessage: StreamMessage, nextMessage: StreamMessage, noGroupByUser: boolean, + maxTimeBetweenGroupedMessages?: number, ) => GroupStyle; /** Whether the list has more items to load */ hasMore?: boolean; @@ -343,6 +346,8 @@ export type MessageListProps< loadMore?: ChannelActionContextValue['loadMore'] | (() => Promise); /** Function called when newer messages are to be loaded, defaults to function stored in [ChannelActionContext](https://getstream.io/chat/docs/sdk/react/contexts/channel_action_context/) */ loadMoreNewer?: ChannelActionContextValue['loadMoreNewer'] | (() => Promise); + /** Maximum time in milliseconds that should occur between messages to still consider them grouped together */ + maxTimeBetweenGroupedMessages?: number; /** The limit to use when paginating messages */ messageLimit?: number; /** The messages to render in the list, defaults to messages stored in [ChannelStateContext](https://getstream.io/chat/docs/sdk/react/contexts/channel_state_context/) */ diff --git a/src/components/MessageList/VirtualizedMessageList.tsx b/src/components/MessageList/VirtualizedMessageList.tsx index 0cb39d9ac..2633f1f35 100644 --- a/src/components/MessageList/VirtualizedMessageList.tsx +++ b/src/components/MessageList/VirtualizedMessageList.tsx @@ -193,6 +193,7 @@ const VirtualizedMessageListWithContext = < loadingMore, loadMore, loadMoreNewer, + maxTimeBetweenGroupedMessages, Message: MessageUIComponentFromProps, messageActions, messageLimit = DEFAULT_NEXT_CHANNEL_PAGE_SIZE, @@ -312,13 +313,14 @@ const VirtualizedMessageListWithContext = < processedMessages[i - 1], processedMessages[i + 1], !shouldGroupByUser, + maxTimeBetweenGroupedMessages, ); if (style) acc[message.id] = style; return acc; }, {}), // processedMessages were incorrectly rebuilt with a new object identity at some point, hence the .length usage // eslint-disable-next-line react-hooks/exhaustive-deps - [processedMessages.length, shouldGroupByUser, groupStylesFn], + [maxTimeBetweenGroupedMessages, processedMessages.length, shouldGroupByUser, groupStylesFn], ); const { @@ -542,6 +544,7 @@ export type VirtualizedMessageListProps< previousMessage: StreamMessage, nextMessage: StreamMessage, noGroupByUser: boolean, + maxTimeBetweenGroupedMessages?: number, ) => GroupStyle; /** Whether or not the list has more items to load */ hasMore?: boolean; @@ -566,6 +569,8 @@ export type VirtualizedMessageListProps< loadMore?: ChannelActionContextValue['loadMore'] | (() => Promise); /** Function called when new messages are to be loaded, defaults to function stored in [ChannelActionContext](https://getstream.io/chat/docs/sdk/react/contexts/channel_action_context/) */ loadMoreNewer?: ChannelActionContextValue['loadMore'] | (() => Promise); + /** Maximum time in milliseconds that should occur between messages to still consider them grouped together */ + maxTimeBetweenGroupedMessages?: number; /** Custom UI component to display a message, defaults to and accepts same props as [MessageSimple](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Message/MessageSimple.tsx) */ Message?: React.ComponentType>; /** The limit to use when paginating messages */ diff --git a/src/components/MessageList/VirtualizedMessageListComponents.tsx b/src/components/MessageList/VirtualizedMessageListComponents.tsx index 70a477513..9abd64e53 100644 --- a/src/components/MessageList/VirtualizedMessageListComponents.tsx +++ b/src/components/MessageList/VirtualizedMessageListComponents.tsx @@ -174,6 +174,8 @@ export const messageRenderer = < messageList[streamMessageIndex - 1]; const maybeNextMessage: StreamMessage | undefined = messageList[streamMessageIndex + 1]; + + // FIXME: firstOfGroup & endOfGroup should be derived from groupStyles which apply a more complex logic const firstOfGroup = shouldGroupByUser && (message.user?.id !== maybePrevMessage?.user?.id || diff --git a/src/components/MessageList/__tests__/utils.test.js b/src/components/MessageList/__tests__/utils.test.js index 33c1be57d..9566eea4e 100644 --- a/src/components/MessageList/__tests__/utils.test.js +++ b/src/components/MessageList/__tests__/utils.test.js @@ -1,6 +1,6 @@ -import { generateMessage } from '../../../mock-builders'; +import { generateFileAttachment, generateMessage, generateUser } from '../../../mock-builders'; -import { makeDateMessageId, processMessages } from '../utils'; +import { getGroupStyles, makeDateMessageId, processMessages } from '../utils'; import { CUSTOM_MESSAGE_TYPE } from '../../../constants/messageTypes'; const mockedNanoId = 'V1StGXR8_Z5jdHi6B-myT'; @@ -421,3 +421,204 @@ describe('processMessages', () => { }); }); }); + +describe('getGroupStyles', () => { + const user = generateUser(); + let message; + let previousMessage; + let nextMessage; + let noGroupByUser; + beforeEach(() => { + message = generateMessage({ created_at: new Date(2), user }); + previousMessage = generateMessage({ created_at: new Date(1), user }); + nextMessage = generateMessage({ created_at: new Date(100), user }); + noGroupByUser = false; + }); + + describe.each([ + ['bottom', 'next'], + ['top', 'previous'], + ])('marks a message as %s when %s message', (position) => { + it('does not exist', () => { + if (position === 'bottom') { + nextMessage = undefined; + } + if (position === 'top') { + previousMessage = undefined; + } + expect(getGroupStyles(message, previousMessage, nextMessage, noGroupByUser)).toBe(position); + }); + + it('is intro message', () => { + if (position === 'bottom') { + nextMessage = { ...nextMessage, customType: CUSTOM_MESSAGE_TYPE.intro }; + } + if (position === 'top') { + previousMessage = { ...previousMessage, customType: CUSTOM_MESSAGE_TYPE.intro }; + } + expect(getGroupStyles(message, previousMessage, nextMessage, noGroupByUser)).toBe(position); + }); + + it('is date message', () => { + if (position === 'bottom') { + nextMessage = { ...nextMessage, customType: CUSTOM_MESSAGE_TYPE.date }; + } + if (position === 'top') { + previousMessage = { ...previousMessage, customType: CUSTOM_MESSAGE_TYPE.date }; + } + expect(getGroupStyles(message, previousMessage, nextMessage, noGroupByUser)).toBe(position); + }); + + it('is a system message', () => { + if (position === 'bottom') { + nextMessage = { ...nextMessage, type: 'system' }; + } + if (position === 'top') { + previousMessage = { ...previousMessage, type: 'system' }; + } + expect(getGroupStyles(message, previousMessage, nextMessage, noGroupByUser)).toBe(position); + }); + + it('is an error message', () => { + if (position === 'bottom') { + nextMessage = { ...nextMessage, type: 'error' }; + } + if (position === 'top') { + previousMessage = { ...previousMessage, type: 'error' }; + } + expect(getGroupStyles(message, previousMessage, nextMessage, noGroupByUser)).toBe(position); + }); + + it('has attachments', () => { + if (position === 'bottom') { + nextMessage = { ...nextMessage, attachments: [generateFileAttachment()] }; + } + if (position === 'top') { + previousMessage = { ...previousMessage, attachments: [generateFileAttachment()] }; + } + expect(getGroupStyles(message, previousMessage, nextMessage, noGroupByUser)).toBe(position); + }); + + it('is posted by another user', () => { + const user = generateUser({ id: 'XX' }); + if (position === 'bottom') { + nextMessage = { ...nextMessage, user }; + } + if (position === 'top') { + previousMessage = { ...previousMessage, user }; + } + expect(getGroupStyles(message, previousMessage, nextMessage, noGroupByUser)).toBe(position); + }); + + it('is deleted', () => { + if (position === 'bottom') { + nextMessage = { ...nextMessage, deleted_at: new Date() }; + } + if (position === 'top') { + previousMessage = { ...previousMessage, deleted_at: new Date() }; + } + expect(getGroupStyles(message, previousMessage, nextMessage, noGroupByUser)).toBe(position); + }); + }); + + it('marks a message as bottom when the message is edited', () => { + message = { ...message, message_text_updated_at: new Date() }; + expect(getGroupStyles(message, previousMessage, nextMessage, noGroupByUser)).toBe('bottom'); + }); + + it('marks a message as top when the previous message is edited', () => { + previousMessage = { ...previousMessage, message_text_updated_at: new Date() }; + expect(getGroupStyles(message, previousMessage, nextMessage, noGroupByUser)).toBe('top'); + }); + + it('marks a message a top if it has reactions', () => { + message = { ...message, reaction_groups: { X: 'Y' } }; + expect(getGroupStyles(message, previousMessage, nextMessage, noGroupByUser)).toBe('top'); + }); + + it('marks a message a bottom if next message has reactions', () => { + nextMessage = { ...nextMessage, reaction_groups: { X: 'Y' } }; + expect(getGroupStyles(message, previousMessage, nextMessage, noGroupByUser)).toBe('bottom'); + }); + + it('marks a message as top when next message is created later than maxTimeBetweenGroupedMessages milliseconds', () => { + const maxTimeBetweenGroupedMessages = 10; + expect( + getGroupStyles( + message, + previousMessage, + nextMessage, + noGroupByUser, + maxTimeBetweenGroupedMessages, + ), + ).toBe('bottom'); + }); + + it('marks a message as bottom when next message is created later than maxTimeBetweenGroupedMessages milliseconds', () => { + const maxTimeBetweenGroupedMessages = 10; + message = { ...message, created_at: new Date(12) }; + nextMessage = { ...nextMessage, created_at: new Date(14) }; + expect( + getGroupStyles( + message, + previousMessage, + nextMessage, + noGroupByUser, + maxTimeBetweenGroupedMessages, + ), + ).toBe('top'); + }); + + it('marks a message as single when next and previous message is created later than maxTimeBetweenGroupedMessages milliseconds', () => { + const maxTimeBetweenGroupedMessages = 10; + message = { ...message, created_at: new Date(12) }; + expect( + getGroupStyles( + message, + previousMessage, + nextMessage, + noGroupByUser, + maxTimeBetweenGroupedMessages, + ), + ).toBe('single'); + }); + + it('marks a message as middle when next message is created earlier than maxTimeBetweenGroupedMessages milliseconds', () => { + const maxTimeBetweenGroupedMessages = 1000; + expect( + getGroupStyles( + message, + previousMessage, + nextMessage, + noGroupByUser, + maxTimeBetweenGroupedMessages, + ), + ).toBe('middle'); + }); + + it('marks message as middle if not being top, neither bottom message', () => { + expect(getGroupStyles(message, previousMessage, nextMessage, noGroupByUser)).toBe('middle'); + }); + + it('marks message as single if not being top, neither bottom message being deleted', () => { + message = { ...message, deleted_at: new Date() }; + expect(getGroupStyles(message, previousMessage, nextMessage, noGroupByUser)).toBe('single'); + }); + + it('marks message as single if not being top, neither bottom message being error message', () => { + message = { ...message, type: 'error' }; + expect(getGroupStyles(message, previousMessage, nextMessage, noGroupByUser)).toBe('single'); + }); + + it('marks message at the bottom as single being deleted message', () => { + message = { ...message, deleted_at: new Date() }; + nextMessage = undefined; + expect(getGroupStyles(message, previousMessage, nextMessage, noGroupByUser)).toBe('single'); + }); + + it('marks message at the bottom as single being error message', () => { + message = { ...message, type: 'error' }; + nextMessage = undefined; + expect(getGroupStyles(message, previousMessage, nextMessage, noGroupByUser)).toBe('single'); + }); +}); diff --git a/src/components/MessageList/hooks/MessageList/useEnrichedMessages.ts b/src/components/MessageList/hooks/MessageList/useEnrichedMessages.ts index 0692c819c..f85af4c10 100644 --- a/src/components/MessageList/hooks/MessageList/useEnrichedMessages.ts +++ b/src/components/MessageList/hooks/MessageList/useEnrichedMessages.ts @@ -31,8 +31,10 @@ export const useEnrichedMessages = < previousMessage: StreamMessage, nextMessage: StreamMessage, noGroupByUser: boolean, + maxTimeBetweenGroupedMessages?: number, ) => GroupStyle; headerPosition?: number; + maxTimeBetweenGroupedMessages?: number; reviewProcessedMessage?: ProcessMessagesParams['reviewProcessedMessage']; }) => { const { @@ -42,6 +44,7 @@ export const useEnrichedMessages = < headerPosition, hideDeletedMessages, hideNewMessageSeparator, + maxTimeBetweenGroupedMessages, messages, noGroupByUser, reviewProcessedMessage, @@ -80,12 +83,13 @@ export const useEnrichedMessages = < messagesWithDates[i - 1], messagesWithDates[i + 1], noGroupByUser, + maxTimeBetweenGroupedMessages, ); if (style) acc[message.id] = style; return acc; }, {}), // eslint-disable-next-line react-hooks/exhaustive-deps - [messagesWithDates, noGroupByUser], + [maxTimeBetweenGroupedMessages, messagesWithDates, noGroupByUser], ); return { messageGroupStyles, messages: messagesWithDates }; diff --git a/src/components/MessageList/utils.ts b/src/components/MessageList/utils.ts index 50191b5be..b280d24d1 100644 --- a/src/components/MessageList/utils.ts +++ b/src/components/MessageList/utils.ts @@ -292,6 +292,7 @@ export const getGroupStyles = < previousMessage: StreamMessage, nextMessage: StreamMessage, noGroupByUser: boolean, + maxTimeBetweenGroupedMessages?: number, ): GroupStyle => { if (message.customType === CUSTOM_MESSAGE_TYPE.date) return ''; if (message.customType === CUSTOM_MESSAGE_TYPE.intro) return ''; @@ -303,24 +304,34 @@ export const getGroupStyles = < previousMessage.customType === CUSTOM_MESSAGE_TYPE.intro || previousMessage.customType === CUSTOM_MESSAGE_TYPE.date || previousMessage.type === 'system' || + previousMessage.type === 'error' || previousMessage.attachments?.length !== 0 || message.user?.id !== previousMessage.user?.id || - previousMessage.type === 'error' || previousMessage.deleted_at || (message.reaction_groups && Object.keys(message.reaction_groups).length > 0) || - isMessageEdited(previousMessage); + isMessageEdited(previousMessage) || + (maxTimeBetweenGroupedMessages !== undefined && + previousMessage.created_at && + message.created_at && + new Date(message.created_at).getTime() - new Date(previousMessage.created_at).getTime() > + maxTimeBetweenGroupedMessages); const isBottomMessage = !nextMessage || + nextMessage.customType === CUSTOM_MESSAGE_TYPE.intro || nextMessage.customType === CUSTOM_MESSAGE_TYPE.date || nextMessage.type === 'system' || - nextMessage.customType === CUSTOM_MESSAGE_TYPE.intro || + nextMessage.type === 'error' || nextMessage.attachments?.length !== 0 || message.user?.id !== nextMessage.user?.id || - nextMessage.type === 'error' || nextMessage.deleted_at || (nextMessage.reaction_groups && Object.keys(nextMessage.reaction_groups).length > 0) || - isMessageEdited(message); + isMessageEdited(message) || + (maxTimeBetweenGroupedMessages !== undefined && + nextMessage.created_at && + message.created_at && + new Date(nextMessage.created_at).getTime() - new Date(message.created_at).getTime() > + maxTimeBetweenGroupedMessages); if (!isTopMessage && !isBottomMessage) { if (message.deleted_at || message.type === 'error') return 'single';