Skip to content

Commit 0d094cb

Browse files
authored
feat: configure message group size by max time between messages (#2439)
1 parent 241f5d7 commit 0d094cb

File tree

8 files changed

+259
-15
lines changed

8 files changed

+259
-15
lines changed

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

+11-3
Original file line numberDiff line numberDiff line change
@@ -242,9 +242,9 @@ pinned [message object](https://getstream.io/chat/docs/javascript/message_format
242242

243243
Callback function to map each message in the list to a group style (` 'middle' | 'top' | 'bottom' | 'single'`).
244244

245-
| Type |
246-
| -------------------------------------------------------------------------------------------------------------------------- |
247-
| (message: StreamMessage, previousMessage: StreamMessage, nextMessage: StreamMessage, noGroupByUser: boolean) => GroupStyle |
245+
| Type |
246+
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
247+
| (message: StreamMessage, previousMessage: StreamMessage, nextMessage: StreamMessage, noGroupByUser: boolean, maxTimeBetweenGroupedMessages?: number) => GroupStyle |
248248

249249
### hasMore
250250

@@ -302,6 +302,14 @@ Function called when more messages are to be loaded, provide your own function t
302302
| -------- | ---------------------------------------------------------------------------------------- |
303303
| function | [ChannelActionContextValue['loadMore']](../contexts/channel-action-context.mdx#loadmore) |
304304

305+
### maxTimeBetweenGroupedMessages
306+
307+
Maximum time in milliseconds that should occur between messages to still consider them grouped together.
308+
309+
| Type |
310+
| ------ |
311+
| number |
312+
305313
### Message
306314

307315
Custom UI component to display an individual message.

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

+11-3
Original file line numberDiff line numberDiff line change
@@ -129,9 +129,9 @@ If true, disables the injection of date separator UI components.
129129

130130
Callback function to set group styles for each message.
131131

132-
| Type |
133-
| -------------------------------------------------------------------------------------------------------------------------- |
134-
| (message: StreamMessage, previousMessage: StreamMessage, nextMessage: StreamMessage, noGroupByUser: boolean) => GroupStyle |
132+
| Type |
133+
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
134+
| (message: StreamMessage, previousMessage: StreamMessage, nextMessage: StreamMessage, noGroupByUser: boolean, maxTimeBetweenGroupedMessages?: number) => GroupStyle |
135135

136136
### hasMore
137137

@@ -173,6 +173,14 @@ Function called when more messages are to be loaded, provide your own function t
173173
| -------- | ---------------------------------------------------------------------------------------- |
174174
| function | [ChannelActionContextValue['loadMore']](../contexts/channel-action-context.mdx#loadmore) |
175175

176+
### maxTimeBetweenGroupedMessages
177+
178+
Maximum time in milliseconds that should occur between messages to still consider them grouped together.
179+
180+
| Type |
181+
| ------ |
182+
| number |
183+
176184
### Message
177185

178186
Custom UI component to display an individual message.

src/components/MessageList/MessageList.tsx

+5
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ const MessageListWithContext = <
6464
threshold: loadMoreScrollThreshold = DEFAULT_LOAD_PAGE_SCROLL_THRESHOLD,
6565
...restInternalInfiniteScrollProps
6666
} = {},
67+
maxTimeBetweenGroupedMessages,
6768
messageActions = Object.keys(MESSAGE_ACTIONS),
6869
messages = [],
6970
notifications,
@@ -138,6 +139,7 @@ const MessageListWithContext = <
138139
headerPosition,
139140
hideDeletedMessages,
140141
hideNewMessageSeparator,
142+
maxTimeBetweenGroupedMessages,
141143
messages,
142144
noGroupByUser,
143145
reviewProcessedMessage,
@@ -320,6 +322,7 @@ export type MessageListProps<
320322
previousMessage: StreamMessage<StreamChatGenerics>,
321323
nextMessage: StreamMessage<StreamChatGenerics>,
322324
noGroupByUser: boolean,
325+
maxTimeBetweenGroupedMessages?: number,
323326
) => GroupStyle;
324327
/** Whether the list has more items to load */
325328
hasMore?: boolean;
@@ -343,6 +346,8 @@ export type MessageListProps<
343346
loadMore?: ChannelActionContextValue['loadMore'] | (() => Promise<void>);
344347
/** 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/) */
345348
loadMoreNewer?: ChannelActionContextValue['loadMoreNewer'] | (() => Promise<void>);
349+
/** Maximum time in milliseconds that should occur between messages to still consider them grouped together */
350+
maxTimeBetweenGroupedMessages?: number;
346351
/** The limit to use when paginating messages */
347352
messageLimit?: number;
348353
/** The messages to render in the list, defaults to messages stored in [ChannelStateContext](https://getstream.io/chat/docs/sdk/react/contexts/channel_state_context/) */

src/components/MessageList/VirtualizedMessageList.tsx

+6-1
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,7 @@ const VirtualizedMessageListWithContext = <
193193
loadingMore,
194194
loadMore,
195195
loadMoreNewer,
196+
maxTimeBetweenGroupedMessages,
196197
Message: MessageUIComponentFromProps,
197198
messageActions,
198199
messageLimit = DEFAULT_NEXT_CHANNEL_PAGE_SIZE,
@@ -312,13 +313,14 @@ const VirtualizedMessageListWithContext = <
312313
processedMessages[i - 1],
313314
processedMessages[i + 1],
314315
!shouldGroupByUser,
316+
maxTimeBetweenGroupedMessages,
315317
);
316318
if (style) acc[message.id] = style;
317319
return acc;
318320
}, {}),
319321
// processedMessages were incorrectly rebuilt with a new object identity at some point, hence the .length usage
320322
// eslint-disable-next-line react-hooks/exhaustive-deps
321-
[processedMessages.length, shouldGroupByUser, groupStylesFn],
323+
[maxTimeBetweenGroupedMessages, processedMessages.length, shouldGroupByUser, groupStylesFn],
322324
);
323325

324326
const {
@@ -542,6 +544,7 @@ export type VirtualizedMessageListProps<
542544
previousMessage: StreamMessage<StreamChatGenerics>,
543545
nextMessage: StreamMessage<StreamChatGenerics>,
544546
noGroupByUser: boolean,
547+
maxTimeBetweenGroupedMessages?: number,
545548
) => GroupStyle;
546549
/** Whether or not the list has more items to load */
547550
hasMore?: boolean;
@@ -566,6 +569,8 @@ export type VirtualizedMessageListProps<
566569
loadMore?: ChannelActionContextValue['loadMore'] | (() => Promise<void>);
567570
/** 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/) */
568571
loadMoreNewer?: ChannelActionContextValue['loadMore'] | (() => Promise<void>);
572+
/** Maximum time in milliseconds that should occur between messages to still consider them grouped together */
573+
maxTimeBetweenGroupedMessages?: number;
569574
/** 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) */
570575
Message?: React.ComponentType<MessageUIComponentProps<StreamChatGenerics>>;
571576
/** The limit to use when paginating messages */

src/components/MessageList/VirtualizedMessageListComponents.tsx

+2
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,8 @@ export const messageRenderer = <
174174
messageList[streamMessageIndex - 1];
175175
const maybeNextMessage: StreamMessage<StreamChatGenerics> | undefined =
176176
messageList[streamMessageIndex + 1];
177+
178+
// FIXME: firstOfGroup & endOfGroup should be derived from groupStyles which apply a more complex logic
177179
const firstOfGroup =
178180
shouldGroupByUser &&
179181
(message.user?.id !== maybePrevMessage?.user?.id ||

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

+203-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { generateMessage } from '../../../mock-builders';
1+
import { generateFileAttachment, generateMessage, generateUser } from '../../../mock-builders';
22

3-
import { makeDateMessageId, processMessages } from '../utils';
3+
import { getGroupStyles, makeDateMessageId, processMessages } from '../utils';
44
import { CUSTOM_MESSAGE_TYPE } from '../../../constants/messageTypes';
55

66
const mockedNanoId = 'V1StGXR8_Z5jdHi6B-myT';
@@ -421,3 +421,204 @@ describe('processMessages', () => {
421421
});
422422
});
423423
});
424+
425+
describe('getGroupStyles', () => {
426+
const user = generateUser();
427+
let message;
428+
let previousMessage;
429+
let nextMessage;
430+
let noGroupByUser;
431+
beforeEach(() => {
432+
message = generateMessage({ created_at: new Date(2), user });
433+
previousMessage = generateMessage({ created_at: new Date(1), user });
434+
nextMessage = generateMessage({ created_at: new Date(100), user });
435+
noGroupByUser = false;
436+
});
437+
438+
describe.each([
439+
['bottom', 'next'],
440+
['top', 'previous'],
441+
])('marks a message as %s when %s message', (position) => {
442+
it('does not exist', () => {
443+
if (position === 'bottom') {
444+
nextMessage = undefined;
445+
}
446+
if (position === 'top') {
447+
previousMessage = undefined;
448+
}
449+
expect(getGroupStyles(message, previousMessage, nextMessage, noGroupByUser)).toBe(position);
450+
});
451+
452+
it('is intro message', () => {
453+
if (position === 'bottom') {
454+
nextMessage = { ...nextMessage, customType: CUSTOM_MESSAGE_TYPE.intro };
455+
}
456+
if (position === 'top') {
457+
previousMessage = { ...previousMessage, customType: CUSTOM_MESSAGE_TYPE.intro };
458+
}
459+
expect(getGroupStyles(message, previousMessage, nextMessage, noGroupByUser)).toBe(position);
460+
});
461+
462+
it('is date message', () => {
463+
if (position === 'bottom') {
464+
nextMessage = { ...nextMessage, customType: CUSTOM_MESSAGE_TYPE.date };
465+
}
466+
if (position === 'top') {
467+
previousMessage = { ...previousMessage, customType: CUSTOM_MESSAGE_TYPE.date };
468+
}
469+
expect(getGroupStyles(message, previousMessage, nextMessage, noGroupByUser)).toBe(position);
470+
});
471+
472+
it('is a system message', () => {
473+
if (position === 'bottom') {
474+
nextMessage = { ...nextMessage, type: 'system' };
475+
}
476+
if (position === 'top') {
477+
previousMessage = { ...previousMessage, type: 'system' };
478+
}
479+
expect(getGroupStyles(message, previousMessage, nextMessage, noGroupByUser)).toBe(position);
480+
});
481+
482+
it('is an error message', () => {
483+
if (position === 'bottom') {
484+
nextMessage = { ...nextMessage, type: 'error' };
485+
}
486+
if (position === 'top') {
487+
previousMessage = { ...previousMessage, type: 'error' };
488+
}
489+
expect(getGroupStyles(message, previousMessage, nextMessage, noGroupByUser)).toBe(position);
490+
});
491+
492+
it('has attachments', () => {
493+
if (position === 'bottom') {
494+
nextMessage = { ...nextMessage, attachments: [generateFileAttachment()] };
495+
}
496+
if (position === 'top') {
497+
previousMessage = { ...previousMessage, attachments: [generateFileAttachment()] };
498+
}
499+
expect(getGroupStyles(message, previousMessage, nextMessage, noGroupByUser)).toBe(position);
500+
});
501+
502+
it('is posted by another user', () => {
503+
const user = generateUser({ id: 'XX' });
504+
if (position === 'bottom') {
505+
nextMessage = { ...nextMessage, user };
506+
}
507+
if (position === 'top') {
508+
previousMessage = { ...previousMessage, user };
509+
}
510+
expect(getGroupStyles(message, previousMessage, nextMessage, noGroupByUser)).toBe(position);
511+
});
512+
513+
it('is deleted', () => {
514+
if (position === 'bottom') {
515+
nextMessage = { ...nextMessage, deleted_at: new Date() };
516+
}
517+
if (position === 'top') {
518+
previousMessage = { ...previousMessage, deleted_at: new Date() };
519+
}
520+
expect(getGroupStyles(message, previousMessage, nextMessage, noGroupByUser)).toBe(position);
521+
});
522+
});
523+
524+
it('marks a message as bottom when the message is edited', () => {
525+
message = { ...message, message_text_updated_at: new Date() };
526+
expect(getGroupStyles(message, previousMessage, nextMessage, noGroupByUser)).toBe('bottom');
527+
});
528+
529+
it('marks a message as top when the previous message is edited', () => {
530+
previousMessage = { ...previousMessage, message_text_updated_at: new Date() };
531+
expect(getGroupStyles(message, previousMessage, nextMessage, noGroupByUser)).toBe('top');
532+
});
533+
534+
it('marks a message a top if it has reactions', () => {
535+
message = { ...message, reaction_groups: { X: 'Y' } };
536+
expect(getGroupStyles(message, previousMessage, nextMessage, noGroupByUser)).toBe('top');
537+
});
538+
539+
it('marks a message a bottom if next message has reactions', () => {
540+
nextMessage = { ...nextMessage, reaction_groups: { X: 'Y' } };
541+
expect(getGroupStyles(message, previousMessage, nextMessage, noGroupByUser)).toBe('bottom');
542+
});
543+
544+
it('marks a message as top when next message is created later than maxTimeBetweenGroupedMessages milliseconds', () => {
545+
const maxTimeBetweenGroupedMessages = 10;
546+
expect(
547+
getGroupStyles(
548+
message,
549+
previousMessage,
550+
nextMessage,
551+
noGroupByUser,
552+
maxTimeBetweenGroupedMessages,
553+
),
554+
).toBe('bottom');
555+
});
556+
557+
it('marks a message as bottom when next message is created later than maxTimeBetweenGroupedMessages milliseconds', () => {
558+
const maxTimeBetweenGroupedMessages = 10;
559+
message = { ...message, created_at: new Date(12) };
560+
nextMessage = { ...nextMessage, created_at: new Date(14) };
561+
expect(
562+
getGroupStyles(
563+
message,
564+
previousMessage,
565+
nextMessage,
566+
noGroupByUser,
567+
maxTimeBetweenGroupedMessages,
568+
),
569+
).toBe('top');
570+
});
571+
572+
it('marks a message as single when next and previous message is created later than maxTimeBetweenGroupedMessages milliseconds', () => {
573+
const maxTimeBetweenGroupedMessages = 10;
574+
message = { ...message, created_at: new Date(12) };
575+
expect(
576+
getGroupStyles(
577+
message,
578+
previousMessage,
579+
nextMessage,
580+
noGroupByUser,
581+
maxTimeBetweenGroupedMessages,
582+
),
583+
).toBe('single');
584+
});
585+
586+
it('marks a message as middle when next message is created earlier than maxTimeBetweenGroupedMessages milliseconds', () => {
587+
const maxTimeBetweenGroupedMessages = 1000;
588+
expect(
589+
getGroupStyles(
590+
message,
591+
previousMessage,
592+
nextMessage,
593+
noGroupByUser,
594+
maxTimeBetweenGroupedMessages,
595+
),
596+
).toBe('middle');
597+
});
598+
599+
it('marks message as middle if not being top, neither bottom message', () => {
600+
expect(getGroupStyles(message, previousMessage, nextMessage, noGroupByUser)).toBe('middle');
601+
});
602+
603+
it('marks message as single if not being top, neither bottom message being deleted', () => {
604+
message = { ...message, deleted_at: new Date() };
605+
expect(getGroupStyles(message, previousMessage, nextMessage, noGroupByUser)).toBe('single');
606+
});
607+
608+
it('marks message as single if not being top, neither bottom message being error message', () => {
609+
message = { ...message, type: 'error' };
610+
expect(getGroupStyles(message, previousMessage, nextMessage, noGroupByUser)).toBe('single');
611+
});
612+
613+
it('marks message at the bottom as single being deleted message', () => {
614+
message = { ...message, deleted_at: new Date() };
615+
nextMessage = undefined;
616+
expect(getGroupStyles(message, previousMessage, nextMessage, noGroupByUser)).toBe('single');
617+
});
618+
619+
it('marks message at the bottom as single being error message', () => {
620+
message = { ...message, type: 'error' };
621+
nextMessage = undefined;
622+
expect(getGroupStyles(message, previousMessage, nextMessage, noGroupByUser)).toBe('single');
623+
});
624+
});

src/components/MessageList/hooks/MessageList/useEnrichedMessages.ts

+5-1
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,10 @@ export const useEnrichedMessages = <
3131
previousMessage: StreamMessage<StreamChatGenerics>,
3232
nextMessage: StreamMessage<StreamChatGenerics>,
3333
noGroupByUser: boolean,
34+
maxTimeBetweenGroupedMessages?: number,
3435
) => GroupStyle;
3536
headerPosition?: number;
37+
maxTimeBetweenGroupedMessages?: number;
3638
reviewProcessedMessage?: ProcessMessagesParams<StreamChatGenerics>['reviewProcessedMessage'];
3739
}) => {
3840
const {
@@ -42,6 +44,7 @@ export const useEnrichedMessages = <
4244
headerPosition,
4345
hideDeletedMessages,
4446
hideNewMessageSeparator,
47+
maxTimeBetweenGroupedMessages,
4548
messages,
4649
noGroupByUser,
4750
reviewProcessedMessage,
@@ -80,12 +83,13 @@ export const useEnrichedMessages = <
8083
messagesWithDates[i - 1],
8184
messagesWithDates[i + 1],
8285
noGroupByUser,
86+
maxTimeBetweenGroupedMessages,
8387
);
8488
if (style) acc[message.id] = style;
8589
return acc;
8690
}, {}),
8791
// eslint-disable-next-line react-hooks/exhaustive-deps
88-
[messagesWithDates, noGroupByUser],
92+
[maxTimeBetweenGroupedMessages, messagesWithDates, noGroupByUser],
8993
);
9094

9195
return { messageGroupStyles, messages: messagesWithDates };

0 commit comments

Comments
 (0)