Skip to content

Commit

Permalink
feat: configure message group size by max time between messages (#2439)
Browse files Browse the repository at this point in the history
  • Loading branch information
MartinCupela authored Jun 28, 2024
1 parent 241f5d7 commit 0d094cb
Show file tree
Hide file tree
Showing 8 changed files with 259 additions and 15 deletions.
14 changes: 11 additions & 3 deletions docusaurus/docs/React/components/core-components/message-list.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions src/components/MessageList/MessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ const MessageListWithContext = <
threshold: loadMoreScrollThreshold = DEFAULT_LOAD_PAGE_SCROLL_THRESHOLD,
...restInternalInfiniteScrollProps
} = {},
maxTimeBetweenGroupedMessages,
messageActions = Object.keys(MESSAGE_ACTIONS),
messages = [],
notifications,
Expand Down Expand Up @@ -138,6 +139,7 @@ const MessageListWithContext = <
headerPosition,
hideDeletedMessages,
hideNewMessageSeparator,
maxTimeBetweenGroupedMessages,
messages,
noGroupByUser,
reviewProcessedMessage,
Expand Down Expand Up @@ -320,6 +322,7 @@ export type MessageListProps<
previousMessage: StreamMessage<StreamChatGenerics>,
nextMessage: StreamMessage<StreamChatGenerics>,
noGroupByUser: boolean,
maxTimeBetweenGroupedMessages?: number,
) => GroupStyle;
/** Whether the list has more items to load */
hasMore?: boolean;
Expand All @@ -343,6 +346,8 @@ export type MessageListProps<
loadMore?: ChannelActionContextValue['loadMore'] | (() => Promise<void>);
/** 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<void>);
/** 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/) */
Expand Down
7 changes: 6 additions & 1 deletion src/components/MessageList/VirtualizedMessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@ const VirtualizedMessageListWithContext = <
loadingMore,
loadMore,
loadMoreNewer,
maxTimeBetweenGroupedMessages,
Message: MessageUIComponentFromProps,
messageActions,
messageLimit = DEFAULT_NEXT_CHANNEL_PAGE_SIZE,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -542,6 +544,7 @@ export type VirtualizedMessageListProps<
previousMessage: StreamMessage<StreamChatGenerics>,
nextMessage: StreamMessage<StreamChatGenerics>,
noGroupByUser: boolean,
maxTimeBetweenGroupedMessages?: number,
) => GroupStyle;
/** Whether or not the list has more items to load */
hasMore?: boolean;
Expand All @@ -566,6 +569,8 @@ export type VirtualizedMessageListProps<
loadMore?: ChannelActionContextValue['loadMore'] | (() => Promise<void>);
/** 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<void>);
/** 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<MessageUIComponentProps<StreamChatGenerics>>;
/** The limit to use when paginating messages */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,8 @@ export const messageRenderer = <
messageList[streamMessageIndex - 1];
const maybeNextMessage: StreamMessage<StreamChatGenerics> | 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 ||
Expand Down
205 changes: 203 additions & 2 deletions src/components/MessageList/__tests__/utils.test.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,10 @@ export const useEnrichedMessages = <
previousMessage: StreamMessage<StreamChatGenerics>,
nextMessage: StreamMessage<StreamChatGenerics>,
noGroupByUser: boolean,
maxTimeBetweenGroupedMessages?: number,
) => GroupStyle;
headerPosition?: number;
maxTimeBetweenGroupedMessages?: number;
reviewProcessedMessage?: ProcessMessagesParams<StreamChatGenerics>['reviewProcessedMessage'];
}) => {
const {
Expand All @@ -42,6 +44,7 @@ export const useEnrichedMessages = <
headerPosition,
hideDeletedMessages,
hideNewMessageSeparator,
maxTimeBetweenGroupedMessages,
messages,
noGroupByUser,
reviewProcessedMessage,
Expand Down Expand Up @@ -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 };
Expand Down
Loading

0 comments on commit 0d094cb

Please sign in to comment.